diff --git a/.gitignore b/.gitignore
index 767c1df8..3bd816cc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,6 @@
/workbench.*
/dist
/templates
+*.iml
+.idea/*
+problem_builder.tests.*
diff --git a/LICENSE.MIT b/LICENSE.MIT
new file mode 100644
index 00000000..1ded74e9
--- /dev/null
+++ b/LICENSE.MIT
@@ -0,0 +1,30 @@
+------------------------------------------------------------------------------
+This license applies to the following third-party libraries included
+in this repository:
+
+ - backbone.paginator
+ - Backbone.js
+ - Underscore.js
+------------------------------------------------------------------------------
+
+The MIT License (MIT)
+
+Copyright (c) [year] [fullname]
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/doc/img/mrq-3.png b/doc/img/mrq-3.png
index 5f5ece98..588c6129 100644
Binary files a/doc/img/mrq-3.png and b/doc/img/mrq-3.png differ
diff --git a/problem_builder/__init__.py b/problem_builder/__init__.py
index cf1ee771..eba42a0f 100644
--- a/problem_builder/__init__.py
+++ b/problem_builder/__init__.py
@@ -2,6 +2,7 @@
from .answer import AnswerBlock, AnswerRecapBlock
from .choice import ChoiceBlock
from .dashboard import DashboardBlock
+from .instructor_tool import InstructorToolBlock
from .mcq import MCQBlock, RatingBlock
from .mrq import MRQBlock
from .message import MentoringMessageBlock
diff --git a/problem_builder/answer.py b/problem_builder/answer.py
index f09016d5..b1314f1c 100644
--- a/problem_builder/answer.py
+++ b/problem_builder/answer.py
@@ -31,6 +31,7 @@
from xblock.validation import ValidationMessage
from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import StudioEditableXBlockMixin
+from problem_builder.sub_api import SubmittingXBlockMixin, sub_api
from .step import StepMixin
import uuid
@@ -84,6 +85,23 @@ def get_model_object(self, name=None):
)
return answer_data
+ @property
+ def student_input(self):
+ if self.name:
+ return self.get_model_object().student_input
+ return ''
+
+ @XBlock.json_handler
+ def answer_value(self, data, suffix=''):
+ """ Current value of the answer, for refresh by client """
+ return {'value': self.student_input}
+
+ @XBlock.json_handler
+ def refresh_html(self, data, suffix=''):
+ """ Complete HTML view of the XBlock, for refresh by client """
+ frag = self.mentoring_view({})
+ return {'html': frag.content}
+
def validate_field_data(self, validation, data):
"""
Validate this block's field data.
@@ -102,7 +120,7 @@ def _(self, text):
@XBlock.needs("i18n")
-class AnswerBlock(AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock):
+class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock):
"""
A field where the student enters an answer
@@ -131,7 +149,8 @@ class AnswerBlock(AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock):
display_name=_("Question"),
help=_("Question to ask the student"),
scope=Scope.content,
- default=""
+ default="",
+ multiline_editor=True,
)
weight = Float(
display_name=_("Weight"),
@@ -164,7 +183,7 @@ def student_input(self):
def mentoring_view(self, context=None):
""" Render this XBlock within a mentoring block. """
- context = context or {}
+ context = context.copy() if context else {}
context['self'] = self
context['hide_header'] = context.get('hide_header', False) or not self.show_title
html = loader.render_template('templates/html/answer_editable.html', context)
@@ -179,6 +198,18 @@ def student_view(self, context=None):
""" Normal view of this XBlock, identical to mentoring_view """
return self.mentoring_view(context)
+ def get_results(self, previous_response=None):
+ # Previous result is actually stored in database table-- ignore.
+ return {
+ 'student_input': self.student_input,
+ 'status': self.status,
+ 'weight': self.weight,
+ 'score': 1 if self.status == 'correct' else 0,
+ }
+
+ def get_last_result(self):
+ return self.get_results(None) if self.student_input else {}
+
def submit(self, submission):
"""
The parent block is handling a student submission, including a new answer for this
@@ -186,13 +217,16 @@ def submit(self, submission):
"""
self.student_input = submission[0]['value'].strip()
self.save()
+
+ if sub_api:
+ # Also send to the submissions API:
+ item_key = self.student_item_key
+ # Need to do this by our own ID, since an answer can be referred to multiple times.
+ item_key['item_id'] = self.name
+ sub_api.create_submission(item_key, self.student_input)
+
log.info(u'Answer submitted for`{}`: "{}"'.format(self.name, self.student_input))
- return {
- 'student_input': self.student_input,
- 'status': self.status,
- 'weight': self.weight,
- 'score': 1 if self.status == 'correct' else 0,
- }
+ return self.get_results()
@property
def status(self):
@@ -258,22 +292,35 @@ class AnswerRecapBlock(AnswerMixin, StudioEditableXBlockMixin, XBlock):
)
editable_fields = ('name', 'display_name', 'description')
- @property
- def student_input(self):
- if self.name:
- return self.get_model_object().student_input
- return ''
+ css_path = 'public/css/answer.css'
def mentoring_view(self, context=None):
""" Render this XBlock within a mentoring block. """
- context = context or {}
+ context = context.copy() if context else {}
+ student_submissions_key = context.get('student_submissions_key')
context['title'] = self.display_name
context['description'] = self.description
- context['student_input'] = self.student_input
+ if student_submissions_key:
+ location = self.location.replace(branch=None, version=None) # Standardize the key in case it isn't already
+ target_key = {
+ 'student_id': student_submissions_key,
+ 'course_id': unicode(location.course_key),
+ 'item_id': self.name,
+ 'item_type': u'pb-answer',
+ }
+ submissions = sub_api.get_submissions(target_key, limit=1)
+ try:
+ context['student_input'] = submissions[0]['answer']
+ except IndexError:
+ context['student_input'] = None
+ else:
+ context['student_input'] = self.student_input
html = loader.render_template('templates/html/answer_read_only.html', context)
fragment = Fragment(html)
- fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/answer.css'))
+ fragment.add_css_url(self.runtime.local_resource_url(self, self.css_path))
+ fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/answer_recap.js'))
+ fragment.initialize_js('AnswerRecapBlock')
return fragment
def student_view(self, context=None):
diff --git a/problem_builder/dashboard.py b/problem_builder/dashboard.py
index 20e78d08..43e8ef70 100644
--- a/problem_builder/dashboard.py
+++ b/problem_builder/dashboard.py
@@ -58,6 +58,39 @@ def _(text):
# Classes ###########################################################
+class ExportMixin(object):
+ """
+ Used by blocks which need to provide a downloadable export.
+ """
+ def _get_user_full_name(self):
+ """
+ Get the full name of the current user, for the downloadable report.
+ """
+ user_service = self.runtime.service(self, 'user')
+ if user_service:
+ return user_service.get_current_user().full_name
+ return ""
+
+ def _get_course_name(self):
+ """
+ Get the name of the current course, for the downloadable report.
+ """
+ try:
+ course_key = self.scope_ids.usage_id.course_key
+ except AttributeError:
+ return "" # We are not in an edX runtime
+ try:
+ course_root_key = course_key.make_usage_key('course', 'course')
+ return self.runtime.get_block(course_root_key).display_name
+ except Exception: # ItemNotFoundError most likely, but we can't import that exception in non-edX environments
+ # We may be on old mongo:
+ try:
+ course_root_key = course_key.make_usage_key('course', course_key.run)
+ return self.runtime.get_block(course_root_key).display_name
+ except Exception:
+ return ""
+
+
class ColorRule(object):
"""
A rule used to conditionally set colors
@@ -155,7 +188,7 @@ class InvalidUrlName(ValueError):
@XBlock.needs("i18n")
@XBlock.wants("user")
-class DashboardBlock(StudioEditableXBlockMixin, XBlock):
+class DashboardBlock(StudioEditableXBlockMixin, ExportMixin, XBlock):
"""
A block to summarize self-assessment results.
"""
@@ -260,7 +293,7 @@ class DashboardBlock(StudioEditableXBlockMixin, XBlock):
'color_rules', 'visual_rules', 'visual_title', 'visual_desc', 'header_html', 'footer_html',
)
css_path = 'public/css/dashboard.css'
- js_path = 'public/js/dashboard.js'
+ js_path = 'public/js/review_blocks.js'
def get_mentoring_blocks(self, mentoring_ids, ignore_errors=True):
"""
@@ -343,34 +376,6 @@ def color_for_value(self, value):
return rule.color_str
return None
- def _get_user_full_name(self):
- """
- Get the full name of the current user, for the downloadable report.
- """
- user_service = self.runtime.service(self, 'user')
- if user_service:
- return user_service.get_current_user().full_name
- return ""
-
- def _get_course_name(self):
- """
- Get the name of the current course, for the downloadable report.
- """
- try:
- course_key = self.scope_ids.usage_id.course_key
- except AttributeError:
- return "" # We are not in an edX runtime
- try:
- course_root_key = course_key.make_usage_key('course', 'course')
- return self.runtime.get_block(course_root_key).display_name
- except Exception: # ItemNotFoundError most likely, but we can't import that exception in non-edX environments
- # We may be on old mongo:
- try:
- course_root_key = course_key.make_usage_key('course', course_key.run)
- return self.runtime.get_block(course_root_key).display_name
- except Exception:
- return ""
-
def _get_problem_questions(self, mentoring_block):
""" Generator returning only children of specified block that are MCQs """
for child_id in mentoring_block.children:
@@ -469,7 +474,11 @@ def student_view(self, context=None): # pylint: disable=unused-argument
fragment = Fragment(html)
fragment.add_css_url(self.runtime.local_resource_url(self, self.css_path))
fragment.add_javascript_url(self.runtime.local_resource_url(self, self.js_path))
- fragment.initialize_js('PBDashboardBlock', {'reportTemplate': report_template})
+ fragment.initialize_js(
+ 'PBDashboardBlock', {
+ 'reportTemplate': report_template,
+ 'reportContentSelector': '.dashboard-report'
+ })
return fragment
def validate_field_data(self, validation, data):
diff --git a/problem_builder/instructor_tool.py b/problem_builder/instructor_tool.py
new file mode 100644
index 00000000..5bc795ab
--- /dev/null
+++ b/problem_builder/instructor_tool.py
@@ -0,0 +1,344 @@
+# -*- 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 .
+#
+"""
+Instructor Tool: An XBlock for instructors to export student answers from a course.
+
+All processing is done offline.
+"""
+import json
+from django.core.paginator import Paginator
+from xblock.core import XBlock
+from xblock.exceptions import JsonHandlerError
+from xblock.fields import Scope, String, Dict, List
+from xblock.fragment import Fragment
+from xblockutils.resources import ResourceLoader
+
+loader = ResourceLoader(__name__)
+
+PAGE_SIZE = 15
+
+
+# Make '_' a no-op so we can scrape strings
+def _(text):
+ return text
+
+
+@XBlock.needs("i18n")
+@XBlock.wants('user')
+class InstructorToolBlock(XBlock):
+ """
+ InstructorToolBlock: An XBlock for instructors to export student answers from a course.
+
+ All processing is done offline.
+ """
+ display_name = String(
+ display_name=_("Title (Display name)"),
+ help=_("Title to display"),
+ default=_("Instructor Tool"),
+ scope=Scope.settings
+ )
+ active_export_task_id = String(
+ # The UUID of the celery AsyncResult for the most recent export,
+ # IF we are sill waiting for it to finish
+ default="",
+ scope=Scope.user_state,
+ )
+ last_export_result = Dict(
+ # The info dict returned by the most recent successful export.
+ # If the export failed, it will have an "error" key set.
+ default=None,
+ scope=Scope.user_state,
+ )
+ display_data = List(
+ # The list of results associated with the most recent successful export.
+ # Stored separately to avoid the overhead of sending it to the client.
+ default=None,
+ scope=Scope.user_state,
+ )
+ has_author_view = True
+
+ @property
+ def display_name_with_default(self):
+ return "Instructor Tool"
+
+ def author_view(self, context=None):
+ """ Studio View """
+ # Warn the user that this block will only work from the LMS. (Since the CMS uses
+ # different celery queues; our task listener is waiting for tasks on the LMS queue)
+ return Fragment(u'
Instructor Tool Block
This block only works from the LMS.
')
+
+ def studio_view(self, context=None):
+ """ View for editing Instructor Tool block in Studio. """
+ # Display friendly message explaining that the block is not editable.
+ return Fragment(u'
This is a preconfigured block. It is not editable.
')
+
+ def check_pending_export(self):
+ """
+ If we're waiting for an export, see if it has finished, and if so, get the result.
+ """
+ from .tasks import export_data as export_data_task # Import here since this is edX LMS specific
+ if self.active_export_task_id:
+ async_result = export_data_task.AsyncResult(self.active_export_task_id)
+ if async_result.ready():
+ self._save_result(async_result)
+
+ def _save_result(self, task_result):
+ """ Given an AsyncResult or EagerResult, save it. """
+ self.active_export_task_id = ''
+ if task_result.successful():
+ if isinstance(task_result.result, dict) and not task_result.result.get('error'):
+ self.display_data = task_result.result['display_data']
+ del task_result.result['display_data']
+ self.last_export_result = task_result.result
+ else:
+ self.last_export_result = {'error': u'Unexpected result: {}'.format(repr(task_result.result))}
+ self.display_data = None
+ else:
+ self.last_export_result = {'error': unicode(task_result.result)}
+ self.display_data = None
+
+ @XBlock.json_handler
+ def get_result_page(self, data, suffix=''):
+ """ Return requested page of `last_export_result`. """
+ paginator = Paginator(self.display_data, PAGE_SIZE)
+ page = data.get('page', None)
+ return {
+ 'display_data': paginator.page(page).object_list,
+ 'num_results': len(self.display_data),
+ 'page_size': PAGE_SIZE
+ }
+
+ def student_view(self, context=None):
+ """ Normal View """
+ if not self.user_is_staff():
+ return Fragment(u'
This interface can only be used by course staff.
')
+ block_choices = {
+ _('Multiple Choice Question'): 'MCQBlock',
+ _('Rating Question'): 'RatingBlock',
+ _('Long Answer'): 'AnswerBlock',
+ }
+ eligible_block_types = ('pb-mcq', 'pb-rating', 'pb-answer')
+ flat_block_tree = []
+
+ def get_block_id(block):
+ """
+ Return ID of `block`, taking into account needs of both LMS/CMS and workbench runtimes.
+ """
+ usage_id = block.scope_ids.usage_id
+ # Try accessing block ID. If usage_id does not have it, return usage_id itself
+ return unicode(getattr(usage_id, 'block_id', usage_id))
+
+ def get_block_name(block):
+ """
+ Return name of `block`.
+
+ Try attributes in the following order:
+ - block.question
+ - block.name (fallback for old courses)
+ - block.display_name
+ - block ID
+ """
+ for attribute in ('question', 'name', 'display_name'):
+ if getattr(block, attribute, None):
+ return getattr(block, attribute, None)
+ return get_block_id(block)
+
+ def get_block_type(block):
+ """
+ Return type of `block`, taking into account different key styles that might be in use.
+ """
+ try:
+ block_type = block.runtime.id_reader.get_block_type(block.scope_ids.def_id)
+ except AttributeError:
+ block_type = block.runtime.id_reader.get_block_type(block.scope_ids.usage_id)
+ return block_type
+
+ def build_tree(block, ancestors):
+ """
+ Build up a tree of information about the XBlocks descending from root_block
+ """
+ block_id = get_block_id(block)
+ block_name = get_block_name(block)
+ block_type = get_block_type(block)
+ if block_type != 'pb-choice':
+ eligible = block_type in eligible_block_types
+ if eligible:
+ # If this block is a question whose answers we can export,
+ # we mark all of its ancestors as exportable too
+ if ancestors and not ancestors[-1]["eligible"]:
+ for ancestor in ancestors:
+ ancestor["eligible"] = True
+
+ new_entry = {
+ "depth": len(ancestors),
+ "id": block_id,
+ "name": block_name,
+ "eligible": eligible,
+ }
+ flat_block_tree.append(new_entry)
+ if block.has_children and not getattr(block, "has_dynamic_children", lambda: False)():
+ for child_id in block.children:
+ build_tree(block.runtime.get_block(child_id), ancestors=(ancestors + [new_entry]))
+
+ root_block = self
+ while root_block.parent:
+ root_block = root_block.get_parent()
+ root_block_id = get_block_id(root_block)
+ root_entry = {
+ "depth": 0,
+ "id": root_block_id,
+ "name": "All",
+ }
+ flat_block_tree.append(root_entry)
+
+ for child_id in root_block.children:
+ child_block = root_block.runtime.get_block(child_id)
+ build_tree(child_block, [root_entry])
+
+ html = loader.render_template(
+ 'templates/html/instructor_tool.html',
+ {'block_choices': block_choices, 'block_tree': flat_block_tree}
+ )
+ fragment = Fragment(html)
+ fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/instructor_tool.css'))
+ fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/instructor_tool.js'))
+ fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/underscore-min.js'))
+ fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/backbone-min.js'))
+ fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/backbone.paginator.min.js'))
+ fragment.initialize_js('InstructorToolBlock')
+ return fragment
+
+ @property
+ def download_url_for_last_report(self):
+ """ Get the URL for the last report, if any """
+ # Unfortunately this is a bit inefficient due to the ReportStore API
+ if not self.last_export_result or self.last_export_result['error'] is not None:
+ return None
+ from instructor_task.models import ReportStore
+ report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD')
+ course_key = getattr(self.scope_ids.usage_id, 'course_key', None)
+ return dict(report_store.links_for(course_key)).get(self.last_export_result['report_filename'])
+
+ def _get_status(self):
+ self.check_pending_export()
+ return {
+ 'export_pending': bool(self.active_export_task_id),
+ 'last_export_result': self.last_export_result,
+ 'download_url': self.download_url_for_last_report,
+ }
+
+ def raise_error(self, code, message):
+ """
+ Raises an error and marks the block with a simulated failed task dict.
+ """
+ self.last_export_result = {
+ 'error': message,
+ }
+ self.display_data = None
+ raise JsonHandlerError(code, message)
+
+ @XBlock.json_handler
+ def get_status(self, data, suffix=''):
+ return self._get_status()
+
+ @XBlock.json_handler
+ def delete_export(self, data, suffix=''):
+ self._delete_export()
+ return self._get_status()
+
+ def _delete_export(self):
+ self.last_export_result = None
+ self.display_data = None
+ self.active_export_task_id = ''
+
+ @XBlock.json_handler
+ def start_export(self, data, suffix=''):
+ """ Start a new asynchronous export """
+ block_types = data.get('block_types', None)
+ username = data.get('username', None)
+ root_block_id = data.get('root_block_id', None)
+ match_string = data.get('match_string', None)
+
+ # Process user-submitted data
+ if block_types == 'all':
+ block_types = []
+ else:
+ block_types = [block_types]
+
+ user_service = self.runtime.service(self, 'user')
+ if not self.user_is_staff():
+ return {'error': 'permission denied'}
+ if not username:
+ user_id = None
+ else:
+ user_id = user_service.get_anonymous_user_id(username, unicode(self.runtime.course_id))
+ if user_id is None:
+ self.raise_error(404, _("Could not find the specified username."))
+
+ if not root_block_id:
+ root_block_id = self.scope_ids.usage_id
+ # Block ID not in workbench runtime.
+ root_block_id = unicode(getattr(root_block_id, 'block_id', root_block_id))
+
+ # Launch task
+ from .tasks import export_data as export_data_task # Import here since this is edX LMS specific
+ self._delete_export()
+ # Make sure we nail down our state before sending off an asynchronous task.
+ self.save()
+ async_result = export_data_task.delay(
+ # course_id not available in workbench.
+ unicode(getattr(self.runtime, 'course_id', 'course_id')),
+ root_block_id,
+ block_types,
+ user_id,
+ match_string,
+ )
+ if async_result.ready():
+ # In development mode, the task may have executed synchronously.
+ # Store the result now, because we won't be able to retrieve it later :-/
+ if async_result.successful():
+ # Make sure the result can be represented as JSON, since the non-eager celery
+ # requires that
+ json.dumps(async_result.result)
+ self._save_result(async_result)
+ else:
+ # The task is running asynchronously. Store the result ID so we can query its progress:
+ self.active_export_task_id = async_result.id
+ return self._get_status()
+
+ @XBlock.json_handler
+ def cancel_export(self, request, suffix=''):
+ from .tasks import export_data as export_data_task # Import here since this is edX LMS specific
+ if self.active_export_task_id:
+ async_result = export_data_task.AsyncResult(self.active_export_task_id)
+ async_result.revoke()
+ self._delete_export()
+
+ def _get_user_attr(self, attr):
+ """Get an attribute of the current user."""
+ user_service = self.runtime.service(self, 'user')
+ if user_service:
+ # May be None when creating bok choy test fixtures
+ return user_service.get_current_user().opt_attrs.get(attr)
+ return None
+
+ def user_is_staff(self):
+ """Return a Boolean value indicating whether the current user is a member of staff."""
+ return self._get_user_attr('edx-platform.user_is_staff')
diff --git a/problem_builder/mcq.py b/problem_builder/mcq.py
index 9ba34775..55cc78af 100644
--- a/problem_builder/mcq.py
+++ b/problem_builder/mcq.py
@@ -74,15 +74,15 @@ def describe_choice_correctness(self, choice_value):
return self._(u"Wrong")
return self._(u"Not Acceptable")
- def submit(self, submission):
- log.debug(u'Received MCQ submission: "%s"', submission)
-
+ def calculate_results(self, submission):
correct = submission in self.correct_choices
tips_html = []
for tip in self.get_tips():
if submission in tip.values:
tips_html.append(tip.render('mentoring_view').content)
+ formatted_tips = None
+
if tips_html:
formatted_tips = loader.render_template('templates/html/tip_choice_group.html', {
'tips_html': tips_html,
@@ -94,25 +94,34 @@ def submit(self, submission):
# Also send to the submissions API:
sub_api.create_submission(self.student_item_key, submission)
- result = {
+ return {
'submission': submission,
'status': 'correct' if correct else 'incorrect',
- 'tips': formatted_tips if tips_html else None,
+ 'tips': formatted_tips,
'weight': self.weight,
'score': 1 if correct else 0,
}
+
+ def get_results(self, previous_result):
+ return self.calculate_results(previous_result['submission'])
+
+ def get_last_result(self):
+ return self.get_results({'submission': self.student_choice}) if self.student_choice else {}
+
+ def submit(self, submission):
+ log.debug(u'Received MCQ submission: "%s"', submission)
+ result = self.calculate_results(submission)
+ self.student_choice = submission
log.debug(u'MCQ submission result: %s', result)
return result
- def author_edit_view(self, context):
+ def get_author_edit_view_fragment(self, context):
"""
The options for the 1-5 values of the Likert scale aren't child blocks but we want to
show them in the author edit view, for clarity.
"""
fragment = Fragment(u"
{}
".format(self.question))
self.render_children(context, fragment, can_reorder=True, can_add=False)
- fragment.add_content(loader.render_template('templates/html/questionnaire_add_buttons.html', {}))
- fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/questionnaire-edit.css'))
return fragment
def validate_field_data(self, validation, data):
@@ -183,7 +192,7 @@ def human_readable_choices(self):
{"display_name": dn, "value": val} for val, dn in zip(self.FIXED_VALUES, display_names)
] + super(RatingBlock, self).human_readable_choices
- def author_edit_view(self, context):
+ def get_author_edit_view_fragment(self, context):
"""
The options for the 1-5 values of the Likert scale aren't child blocks but we want to
show them in the author edit view, for clarity.
@@ -196,6 +205,4 @@ def author_edit_view(self, context):
'accepted_statuses': [None] + [self.describe_choice_correctness(c) for c in "12345"],
}))
self.render_children(context, fragment, can_reorder=True, can_add=False)
- fragment.add_content(loader.render_template('templates/html/questionnaire_add_buttons.html', {}))
- fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/questionnaire-edit.css'))
return fragment
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index d61901c3..1a24cf6c 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -21,11 +21,12 @@
# Imports ###########################################################
import logging
+import json
from collections import namedtuple
from xblock.core import XBlock
-from xblock.exceptions import NoSuchViewError
+from xblock.exceptions import NoSuchViewError, JsonHandlerError
from xblock.fields import Boolean, Scope, String, Integer, Float, List
from xblock.fragment import Fragment
from xblock.validation import ValidationMessage
@@ -61,6 +62,10 @@ def _(text):
Score = namedtuple("Score", ["raw", "percentage", "correct", "incorrect", "partially_correct"])
+CORRECT = 'correct'
+INCORRECT = 'incorrect'
+PARTIAL = 'partial'
+
@XBlock.needs("i18n")
@XBlock.wants('settings')
@@ -116,6 +121,12 @@ class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioC
scope=Scope.content,
multiline_editor=True
)
+ show_title = Boolean(
+ display_name=_("Show title"),
+ help=_("Display the title?"),
+ default=True,
+ scope=Scope.content
+ )
# Settings
weight = Float(
@@ -143,6 +154,7 @@ class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioC
# Has the student attempted this mentoring step?
default=False,
scope=Scope.user_state
+ # TODO: Does anything use this 'attempted' field? May want to delete it.
)
completed = Boolean(
# Has the student completed this mentoring step?
@@ -166,6 +178,11 @@ class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioC
default=[],
scope=Scope.user_state
)
+ extended_feedback = Boolean(
+ help=_("Show extended feedback details when all attempts are used up."),
+ default=False,
+ Scope=Scope.content
+ )
# Global user state
next_step = String(
@@ -176,7 +193,7 @@ class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioC
editable_fields = (
'display_name', 'mode', 'followed_by', 'max_attempts', 'enforce_dependency',
- 'display_submit', 'feedback_label', 'weight',
+ 'display_submit', 'feedback_label', 'weight', 'extended_feedback'
)
icon_class = 'problem'
has_score = True
@@ -207,17 +224,50 @@ def get_theme(self):
return xblock_settings[self.theme_key]
return _default_theme_config
+ def get_question_number(self, question_id):
+ """
+ Get the step number of the question id
+ """
+ for child_id in self.children:
+ question = self.runtime.get_block(child_id)
+ if isinstance(question, StepMixin) and (question.name == question_id):
+ return question.step_number
+ raise ValueError("Question ID in answer set not a step of this Mentoring Block!")
+
+ def answer_mapper(self, answer_status):
+ """
+ Create a JSON-dumpable object with readable key names from a list of student answers.
+ """
+ answer_map = []
+ for answer in self.student_results:
+ if answer[1]['status'] == answer_status:
+ try:
+ answer_map.append({
+ 'number': self.get_question_number(answer[0]),
+ 'id': answer[0],
+ 'details': answer[1],
+ })
+ except ValueError:
+ pass # The question has been deleted since the student answered it.
+ return answer_map
+
@property
def score(self):
"""Compute the student score taking into account the weight of each step."""
- weights = (float(self.runtime.get_block(step_id).weight) for step_id in self.steps)
- total_child_weight = sum(weights)
+ steps = [self.runtime.get_block(step_id) for step_id in self.steps]
+ steps_map = {q.name: q for q in steps}
+ total_child_weight = sum(float(step.weight) for step in steps)
if total_child_weight == 0:
- return Score(0, 0, 0, 0, 0)
- score = sum(r[1]['score'] * r[1]['weight'] for r in self.student_results) / total_child_weight
- correct = sum(1 for r in self.student_results if r[1]['status'] == 'correct')
- incorrect = sum(1 for r in self.student_results if r[1]['status'] == 'incorrect')
- partially_correct = sum(1 for r in self.student_results if r[1]['status'] == 'partial')
+ return Score(0, 0, [], [], [])
+ points_earned = 0
+ for q_name, q_details in self.student_results:
+ question = steps_map.get(q_name)
+ if question:
+ points_earned += q_details['score'] * question.weight
+ score = points_earned / total_child_weight
+ correct = self.answer_mapper(CORRECT)
+ incorrect = self.answer_mapper(INCORRECT)
+ partially_correct = self.answer_mapper(PARTIAL)
return Score(score, int(round(score * 100)), correct, incorrect, partially_correct)
@@ -231,6 +281,11 @@ def student_view(self, context):
# Migrate stored data if necessary
self.migrate_fields()
+ # Validate self.step:
+ num_steps = len(self.steps)
+ if self.step > num_steps:
+ self.step = num_steps
+
fragment = Fragment()
child_content = u""
@@ -257,16 +312,19 @@ def student_view(self, context):
fragment.add_content(loader.render_template('templates/html/mentoring.html', {
'self': self,
'title': self.display_name,
+ 'show_title': self.show_title,
'child_content': child_content,
'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/problem-builder.css'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/underscore-min.js'))
+ fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/util.js'))
js_file = 'public/js/mentoring_{}_view.js'.format('assessment' if self.is_assessment else 'standard')
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.js'))
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_review_questions.html'), "text/html")
self.include_theme_files(fragment)
# Workbench doesn't have font awesome, so add it:
@@ -343,15 +401,150 @@ def publish_event(self, data, suffix=''):
return {'result': 'ok'}
+ def get_message(self, completed):
+ """
+ Get the message to display to a student following a submission in normal mode.
+ """
+ if completed:
+ # Student has achieved a perfect score
+ return self.get_message_html('completed')
+ elif self.max_attempts_reached:
+ # Student has not achieved a perfect score and cannot try again
+ return self.get_message_html('max_attempts_reached')
+ else:
+ # Student did not achieve a perfect score but can try again:
+ return self.get_message_html('incomplete')
+
+ @property
+ def assessment_message(self):
+ """
+ Get the message to display to a student following a submission in assessment mode.
+ """
+ if not self.max_attempts_reached:
+ return self.get_message_html('on-assessment-review')
+ else:
+ return None
+
+ def show_extended_feedback(self):
+ return self.extended_feedback and self.max_attempts_reached
+
+ def feedback_dispatch(self, target_data, stringify):
+ if self.show_extended_feedback():
+ if stringify:
+ return json.dumps(target_data)
+ else:
+ return target_data
+
+ def correct_json(self, stringify=True):
+ return self.feedback_dispatch(self.score.correct, stringify)
+
+ def incorrect_json(self, stringify=True):
+ return self.feedback_dispatch(self.score.incorrect, stringify)
+
+ def partial_json(self, stringify=True):
+ return self.feedback_dispatch(self.score.partially_correct, stringify)
+
+ @XBlock.json_handler
+ def get_results(self, queries, suffix=''):
+ """
+ Gets detailed results in the case of extended feedback.
+
+ Right now there are two ways to get results-- through the template upon loading up
+ the mentoring block, or after submission of an AJAX request like in
+ submit or get_results here.
+ """
+ if self.mode == 'standard':
+ results, completed, show_message = self._get_standard_results()
+ mentoring_completed = completed
+ else:
+ if not self.show_extended_feedback():
+ return {
+ 'results': [],
+ 'error': 'Extended feedback results cannot be obtained.'
+ }
+
+ results, completed, show_message = self._get_assessment_results(queries)
+ mentoring_completed = True
+
+ result = {
+ 'results': results,
+ 'completed': completed,
+ 'step': self.step,
+ 'max_attempts': self.max_attempts,
+ 'num_attempts': self.num_attempts,
+ }
+
+ if show_message:
+ result['message'] = self.get_message(mentoring_completed)
+
+ return result
+
+ def _get_standard_results(self):
+ """
+ Gets previous submissions results as if submit was called with exactly the same values as last time.
+ """
+ results = []
+ completed = True
+ show_message = bool(self.student_results)
+
+ # In standard mode, all children is visible simultaneously, so need collecting responses from all of them
+ for child_id in self.steps:
+ child = self.runtime.get_block(child_id)
+ child_result = child.get_last_result()
+ results.append([child.name, child_result])
+ completed = completed and (child_result.get('status', None) == 'correct')
+
+ return results, completed, show_message
+
+ def _get_assessment_results(self, queries):
+ """
+ Gets detailed results in the case of extended feedback.
+
+ It may be a good idea to eventually have this function get results
+ in the general case instead of loading them in the template in the future,
+ and only using it for extended feedback situations.
+
+ Right now there are two ways to get results-- through the template upon loading up
+ the mentoring block, or after submission of an AJAX request like in
+ submit or get_results here.
+ """
+ results = []
+ completed = True
+ choices = dict(self.student_results)
+ # Only one child should ever be of concern with this method.
+ for child_id in self.steps:
+ child = self.runtime.get_block(child_id)
+ if child.name and child.name in queries:
+ results = [child.name, child.get_results(choices[child.name])]
+ # Children may have their own definition of 'completed' which can vary from the general case
+ # of the whole mentoring block being completed. This is because in standard mode, all children
+ # must be correct to complete the block. In assessment mode with extended feedback, completion
+ # happens when you're out of attempts, no matter how you did.
+ completed = choices[child.name]['status']
+ break
+
+ return results, completed, True
+
@XBlock.json_handler
def submit(self, submissions, suffix=''):
log.info(u'Received submissions: {}'.format(submissions))
+ # server-side check that the user is allowed to submit:
+ if self.max_attempts_reached:
+ raise JsonHandlerError(403, "Maximum number of attempts already reached.")
+ elif self.has_missing_dependency:
+ raise JsonHandlerError(
+ 403,
+ "You need to complete all previous steps before being able to complete the current one."
+ )
+
+ # This has now been attempted:
self.attempted = True
if self.is_assessment:
return self.handle_assessment_submit(submissions, suffix)
submit_results = []
+ previously_completed = self.completed
completed = True
for child_id in self.steps:
child = self.runtime.get_block(child_id)
@@ -362,45 +555,32 @@ def submit(self, submissions, suffix=''):
child.save()
completed = completed and (child_result['status'] == 'correct')
- if self.max_attempts_reached:
- message = self.get_message_html('max_attempts_reached')
- elif completed:
- message = self.get_message_html('completed')
- else:
- message = self.get_message_html('incomplete')
-
- # Once it has been completed once, keep completion even if user changes values
- if self.completed:
- completed = True
-
- # server-side check to not set completion if the max_attempts is reached
- if self.max_attempts_reached:
- completed = False
-
- if self.has_missing_dependency:
- completed = False
- 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:
+ if completed and self.next_step == self.url_name:
self.next_step = self.followed_by
- # Once it was completed, lock score
- if not self.completed:
- # save user score and results
+ # Update the score and attempts, unless the user had already achieved a perfect score ("completed"):
+ if not previously_completed:
+ # Update the results
while self.student_results:
self.student_results.pop()
for result in submit_results:
self.student_results.append(result)
+ # Save the user's latest score
self.runtime.publish(self, 'grade', {
'value': self.score.raw,
'max_value': 1,
})
- if not self.completed and self.max_attempts > 0:
- self.num_attempts += 1
+ # Mark this as having used an attempt:
+ if self.max_attempts > 0:
+ self.num_attempts += 1
- self.completed = completed is True
+ # Save the completion status.
+ # Once it has been completed once, keep completion even if user changes values
+ self.completed = bool(completed) or previously_completed
+ message = self.get_message(completed)
raw_score = self.score.raw
self.runtime.publish(self, 'xblock.problem_builder.submitted', {
@@ -410,12 +590,11 @@ def submit(self, submissions, suffix=''):
})
return {
- 'submitResults': submit_results,
+ 'results': submit_results,
'completed': self.completed,
- 'attempted': self.attempted,
'message': message,
'max_attempts': self.max_attempts,
- 'num_attempts': self.num_attempts
+ 'num_attempts': self.num_attempts,
}
def handle_assessment_submit(self, submissions, suffix):
@@ -424,6 +603,7 @@ def handle_assessment_submit(self, submissions, suffix):
children = [self.runtime.get_block(child_id) for child_id in self.children]
children = [child for child in children if not isinstance(child, MentoringMessageBlock)]
steps = [child for child in children if isinstance(child, StepMixin)] # Faster than the self.steps property
+ assessment_message = None
for child in children:
if child.name and child.name in submissions:
@@ -452,13 +632,13 @@ def handle_assessment_submit(self, submissions, suffix):
if current_child == steps[-1]:
log.info(u'Last assessment step submitted: {}'.format(submissions))
- if not self.max_attempts_reached:
- self.runtime.publish(self, 'grade', {
- 'value': score.raw,
- 'max_value': 1,
- 'score_type': 'proficiency',
- })
- event_data['final_grade'] = score.raw
+ self.runtime.publish(self, 'grade', {
+ 'value': score.raw,
+ 'max_value': 1,
+ 'score_type': 'proficiency',
+ })
+ event_data['final_grade'] = score.raw
+ assessment_message = self.assessment_message
self.num_attempts += 1
self.completed = True
@@ -471,14 +651,18 @@ def handle_assessment_submit(self, submissions, suffix):
return {
'completed': completed,
- 'attempted': self.attempted,
'max_attempts': self.max_attempts,
'num_attempts': self.num_attempts,
'step': self.step,
'score': score.percentage,
- 'correct_answer': score.correct,
- 'incorrect_answer': score.incorrect,
- 'partially_correct_answer': score.partially_correct,
+ 'correct_answer': len(score.correct),
+ 'incorrect_answer': len(score.incorrect),
+ 'partially_correct_answer': len(score.partially_correct),
+ 'correct': self.correct_json(stringify=False),
+ 'incorrect': self.incorrect_json(stringify=False),
+ 'partial': self.partial_json(stringify=False),
+ 'extended_feedback': self.show_extended_feedback() or '',
+ 'assessment_message': assessment_message,
}
@XBlock.json_handler
@@ -550,7 +734,7 @@ def author_preview_view(self, context):
fragment.add_content(loader.render_template('templates/html/mentoring_url_name.html', {
"url_name": self.url_name
}))
- fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/mentoring_edit.css'))
+ fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/problem-builder-edit.css'))
self.include_theme_files(fragment)
return fragment
@@ -558,12 +742,17 @@ def author_edit_view(self, context):
"""
Add some HTML to the author view that allows authors to add child blocks.
"""
- fragment = super(MentoringBlock, self).author_edit_view(context)
+ fragment = Fragment(u'
') # This DIV is needed for CSS to apply to the previews
+ self.render_children(context, fragment, can_reorder=True, can_add=False)
+ fragment.add_content(u'
')
fragment.add_content(loader.render_template('templates/html/mentoring_add_buttons.html', {}))
fragment.add_content(loader.render_template('templates/html/mentoring_url_name.html', {
"url_name": self.url_name
}))
- fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/mentoring_edit.css'))
+ fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/problem-builder.css'))
+ fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/problem-builder-edit.css'))
+ fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/problem-builder-tinymce-content.css'))
+ fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/util.js'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/mentoring_edit.js'))
fragment.initialize_js('MentoringEditComponents')
return fragment
@@ -572,10 +761,16 @@ def get_content_titles(self):
"""
By default, each Sequential block in a course ("Subsection" in Studio parlance) will
display the display_name of each descendant in a tooltip above the content. We don't
- want that - we only want to display the mentoring block as a whole as one item.
+ want that - we only want to display one title for this mentoring block as a whole.
Otherwise things like "Choice (yes) (Correct)" will appear in the tooltip.
+
+ If this block has no title set, don't display any title. Then, if this is the only block
+ in the unit, the unit's title will be used. (Why isn't it always just used?)
"""
- return [self.display_name]
+ has_explicitly_set_title = self.fields['display_name'].is_set_on(self)
+ if has_explicitly_set_title:
+ return [self.display_name]
+ return []
@staticmethod
def workbench_scenarios():
diff --git a/problem_builder/message.py b/problem_builder/message.py
index d57d52d2..67735373 100644
--- a/problem_builder/message.py
+++ b/problem_builder/message.py
@@ -40,6 +40,52 @@ class MentoringMessageBlock(XBlock, StudioEditableXBlockMixin):
A message which can be conditionally displayed at the mentoring block level,
for example upon completion of the block
"""
+ MESSAGE_TYPES = {
+ "completed": {
+ "display_name": _(u"Completed"),
+ "long_display_name": _(u"Message shown when complete"),
+ "default": _(u"Great job!"),
+ "description": _(
+ u"In standard mode, this message will be shown when the student achieves a "
+ "perfect score. "
+ "This message is ignored in assessment mode."
+ ),
+ },
+ "incomplete": {
+ "display_name": _(u"Incomplete"),
+ "long_display_name": _(u"Message shown when incomplete"),
+ "default": _(u"Not quite! You can try again, though."),
+ "description": _(
+ u"In standard mode, this message will be shown when the student gets at least "
+ "one question wrong, but is allowed to try again. "
+ "This message is ignored in assessment mode."
+ ),
+ },
+ "max_attempts_reached": {
+ "display_name": _(u"Reached max. # of attempts"),
+ "long_display_name": _(u"Message shown when student reaches max. # of attempts"),
+ "default": _(u"Sorry, you have used up all of your allowed submissions."),
+ "description": _(
+ u"In standard mode, this message will be shown when the student has used up "
+ "all of their allowed attempts without achieving a perfect score. "
+ "This message is ignored in assessment mode."
+ ),
+ },
+ "on-assessment-review": {
+ "display_name": _(u"Review with attempts left"),
+ "long_display_name": _(u"Message shown during review when attempts remain"),
+ "default": _(
+ u"You may try this assessment again, and only the latest score will be used."
+ ),
+ "description": _(
+ u"In assessment mode, this message will be shown when the student is reviewing "
+ "their answers to the assessment, if the student is allowed to try again. "
+ "This message is ignored in standard mode and is not shown if the student has "
+ "used up all of their allowed attempts."
+ ),
+ },
+ }
+
content = String(
display_name=_("Message"),
help=_("Message to display upon completion"),
@@ -53,9 +99,10 @@ class MentoringMessageBlock(XBlock, StudioEditableXBlockMixin):
scope=Scope.content,
default="completed",
values=(
- {"display_name": "Completed", "value": "completed"},
- {"display_name": "Incompleted", "value": "incomplete"},
- {"display_name": "Reached max. # of attemps", "value": "max_attempts_reached"},
+ {"value": "completed", "display_name": MESSAGE_TYPES["completed"]["display_name"]},
+ {"value": "incomplete", "display_name": MESSAGE_TYPES["incomplete"]["display_name"]},
+ {"value": "max_attempts_reached", "display_name": MESSAGE_TYPES["max_attempts_reached"]["display_name"]},
+ {"value": "on-assessment-review", "display_name": MESSAGE_TYPES["on-assessment-review"]["display_name"]},
),
)
editable_fields = ("content", )
@@ -66,32 +113,44 @@ def _(self, text):
def mentoring_view(self, context=None):
""" Render this message for use by a mentoring block. """
- html = u'
{content}
'.format(msg_type=self.type, content=self.content)
+ html = u'
{content}
'.format(
+ msg_type=self.type,
+ content=self.content
+ )
return Fragment(html)
def student_view(self, context=None):
""" Normal view of this XBlock, identical to mentoring_view """
return self.mentoring_view(context)
+ def author_view(self, context=None):
+ fragment = self.mentoring_view(context)
+ fragment.content += u'
{}
'.format(self.help_text)
+ return fragment
+
@property
def display_name_with_default(self):
- if self.type == 'max_attempts_reached':
- max_attempts = self.get_parent().max_attempts
- return self._(u"Message when student reaches max. # of attempts ({limit})").format(
- limit=self._(u"unlimited") if max_attempts == 0 else max_attempts
- )
- if self.type == 'completed':
- return self._(u"Message shown when complete")
- if self.type == 'incomplete':
- return self._(u"Message shown when incomplete")
- return u"INVALID MESSAGE"
+ try:
+ return self._(self.MESSAGE_TYPES[self.type]["long_display_name"])
+ except KeyError:
+ return u"INVALID MESSAGE"
+
+ @property
+ def help_text(self):
+ try:
+ return self._(self.MESSAGE_TYPES[self.type]["description"])
+ except KeyError:
+ return u"This message is not a valid message type!"
@classmethod
def get_template(cls, template_id):
"""
Used to interact with Studio's create_xblock method to instantiate pre-defined templates.
"""
- return {'data': {'type': template_id, 'content': "Message goes here."}}
+ return {'data': {
+ 'type': template_id,
+ 'content': cls.MESSAGE_TYPES[template_id]["default"],
+ }}
@classmethod
def parse_xml(cls, node, runtime, keys, id_generator):
@@ -100,7 +159,8 @@ def parse_xml(cls, node, runtime, keys, id_generator):
"""
block = runtime.construct_xblock_from_class(cls, keys)
block.content = unicode(node.text or u"")
- block.type = node.attrib['type']
+ if 'type' in node.attrib: # 'type' is optional - default is 'completed'
+ block.type = node.attrib['type']
for child in node:
block.content += etree.tostring(child, encoding='unicode')
diff --git a/problem_builder/south_migrations/0001_initial.py b/problem_builder/migrations/0001_initial.py
similarity index 100%
rename from problem_builder/south_migrations/0001_initial.py
rename to problem_builder/migrations/0001_initial.py
diff --git a/problem_builder/south_migrations/0002_copy_from_mentoring.py b/problem_builder/migrations/0002_copy_from_mentoring.py
similarity index 100%
rename from problem_builder/south_migrations/0002_copy_from_mentoring.py
rename to problem_builder/migrations/0002_copy_from_mentoring.py
diff --git a/problem_builder/migrations/0003_auto__add_share__add_unique_share_shared_by_shared_with_block_id.py b/problem_builder/migrations/0003_auto__add_share__add_unique_share_shared_by_shared_with_block_id.py
new file mode 100644
index 00000000..407a7cfc
--- /dev/null
+++ b/problem_builder/migrations/0003_auto__add_share__add_unique_share_shared_by_shared_with_block_id.py
@@ -0,0 +1,112 @@
+# -*- coding: utf-8 -*-
+from south.utils import datetime_utils as datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+ def forwards(self, orm):
+ # Adding model 'Share'
+ db.create_table('problem_builder_share', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('shared_by', self.gf('django.db.models.fields.related.ForeignKey')(
+ related_name='problem_builder_shared_by', to=orm['auth.User']
+ )),
+ ('submission_uid', self.gf('django.db.models.fields.CharField')(max_length=32)),
+ ('block_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
+ ('shared_with', self.gf('django.db.models.fields.related.ForeignKey')(
+ related_name='problem_builder_shared_with', to=orm['auth.User']
+ )),
+ ('notified', self.gf('django.db.models.fields.BooleanField')(default=False, db_index=True)),
+ ))
+ db.send_create_signal('problem_builder', ['Share'])
+
+ # Adding unique constraint on 'Share', fields ['shared_by', 'shared_with', 'block_id']
+ db.create_unique('problem_builder_share', ['shared_by_id', 'shared_with_id', 'block_id'])
+
+ def backwards(self, orm):
+ # Removing unique constraint on 'Share', fields ['shared_by', 'shared_with', 'block_id']
+ db.delete_unique('problem_builder_share', ['shared_by_id', 'shared_with_id', 'block_id'])
+
+ # Deleting model 'Share'
+ db.delete_table('problem_builder_share')
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {
+ 'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'
+ })
+ },
+ 'auth.permission': {
+ 'Meta': {
+ 'ordering': "('content_type__app_label', 'content_type__model', 'codename')",
+ 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'
+ },
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {
+ 'to': "orm['contenttypes.ContentType']"
+ }),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {
+ 'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'
+ }),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {
+ 'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'
+ }),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {
+ 'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)",
+ 'object_name': 'ContentType', 'db_table': "'django_content_type'"
+ },
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ },
+ 'problem_builder.answer': {
+ 'Meta': {'unique_together': "(('student_id', 'course_id', 'name'),)", 'object_name': 'Answer'},
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
+ 'created_on': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified_on': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
+ 'student_id': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
+ 'student_input': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'})
+ },
+ 'problem_builder.share': {
+ 'Meta': {'unique_together': "(('shared_by', 'shared_with', 'block_id'),)", 'object_name': 'Share'},
+ 'block_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'notified': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_index': 'True'}),
+ 'shared_by': ('django.db.models.fields.related.ForeignKey', [], {
+ 'related_name': "'problem_builder_shared_by'", 'to': "orm['auth.User']"
+ }),
+ 'shared_with': ('django.db.models.fields.related.ForeignKey', [], {
+ 'related_name': "'problem_builder_shared_with'", 'to': "orm['auth.User']"
+ }),
+ 'submission_uid': ('django.db.models.fields.CharField', [], {'max_length': '32'})
+ }
+ }
+
+ complete_apps = ['problem_builder']
diff --git a/problem_builder/south_migrations/__init__.py b/problem_builder/migrations/__init__.py
similarity index 100%
rename from problem_builder/south_migrations/__init__.py
rename to problem_builder/migrations/__init__.py
diff --git a/problem_builder/models.py b/problem_builder/models.py
index 5491995c..5afc3df7 100644
--- a/problem_builder/models.py
+++ b/problem_builder/models.py
@@ -21,6 +21,7 @@
# Imports ###########################################################
from django.db import models
+from django.contrib.auth.models import User
# Classes ###########################################################
@@ -47,3 +48,19 @@ def save(self, *args, **kwargs):
# Force validation of max_length
self.full_clean()
super(Answer, self).save(*args, **kwargs)
+
+
+class Share(models.Model):
+ """
+ The XBlock User Service does not permit XBlocks instantiated with non-staff users
+ to query for arbitrary anonymous user IDs. In order to make sharing work, we have
+ to store them here.
+ """
+ shared_by = models.ForeignKey(User, related_name='problem_builder_shared_by')
+ submission_uid = models.CharField(max_length=32)
+ block_id = models.CharField(max_length=255, db_index=True)
+ shared_with = models.ForeignKey(User, related_name='problem_builder_shared_with')
+ notified = models.BooleanField(default=False, db_index=True)
+
+ class Meta(object):
+ unique_together = (('shared_by', 'shared_with', 'block_id'),)
diff --git a/problem_builder/mrq.py b/problem_builder/mrq.py
index 0fec59ca..deb1a262 100644
--- a/problem_builder/mrq.py
+++ b/problem_builder/mrq.py
@@ -81,16 +81,38 @@ def describe_choice_correctness(self, choice_value):
return self._(u"Ignored")
return self._(u"Not Acceptable")
+ def get_results(self, previous_result):
+ """
+ Get the results a student has already submitted.
+ """
+ result = self.calculate_results(previous_result['submissions'])
+ result['completed'] = True
+ return result
+
+ def get_last_result(self):
+ if self.student_choices:
+ return self.get_results({'submissions': self.student_choices})
+ else:
+ return {}
+
def submit(self, submissions):
log.debug(u'Received MRQ submissions: "%s"', submissions)
- score = 0
+ result = self.calculate_results(submissions)
+ self.student_choices = submissions
+
+ log.debug(u'MRQ submissions result: %s', result)
+ return result
+ def calculate_results(self, submissions):
+ score = 0
results = []
+
for choice in self.custom_choices:
choice_completed = True
choice_tips_html = []
choice_selected = choice.value in submissions
+
if choice.value in self.required_choices:
if not choice_selected:
choice_completed = False
@@ -106,22 +128,20 @@ def submit(self, submissions):
choice_result = {
'value': choice.value,
'selected': choice_selected,
- }
+ }
# Only include tips/results in returned response if we want to display them
if not self.hide_results:
loader = ResourceLoader(__name__)
choice_result['completed'] = choice_completed
choice_result['tips'] = loader.render_template('templates/html/tip_choice_group.html', {
'tips_html': choice_tips_html,
- })
+ })
results.append(choice_result)
- self.student_choices = submissions
-
status = 'incorrect' if score <= 0 else 'correct' if score >= len(results) else 'partial'
- result = {
+ return {
'submissions': submissions,
'status': status,
'choices': results,
@@ -130,9 +150,6 @@ def submit(self, submissions):
'score': (float(score) / len(results)) if results else 0,
}
- log.debug(u'MRQ submissions result: %s', result)
- return result
-
def validate_field_data(self, validation, data):
"""
Validate this block's field data.
diff --git a/problem_builder/public/css/instructor_tool.css b/problem_builder/public/css/instructor_tool.css
new file mode 100644
index 00000000..60d576e4
--- /dev/null
+++ b/problem_builder/public/css/instructor_tool.css
@@ -0,0 +1,74 @@
+.data-export-options, .data-export-results, .data-export-status {
+ margin-top: 2em;
+}
+.data-export-options, .data-export-results table {
+ border: 2px solid #999;
+}
+.data-export-options, .data-export-results thead {
+ background-color: #ddd;
+}
+.data-export-options {
+ display: table;
+ padding: 1em;
+}
+.data-export-header, .data-export-row {
+ display: table-row;
+}
+.data-export-header h3, .data-export-results thead {
+ font-weight: bold;
+}
+.data-export-header h3 {
+ margin-top: 0px;
+ margin-bottom: 10px;
+}
+.data-export-field-container, .data-export-options .data-export-actions {
+ display: table-cell;
+ padding-left: 1em;
+}
+.data-export-field-container {
+ width: 43%;
+}
+.data-export-options .data-export-actions {
+ max-width: 10%;
+}
+.data-export-field {
+ margin-top: .5em;
+ margin-bottom: .5em;
+}
+.data-export-field label span {
+ padding-right: .5em;
+ vertical-align: middle;
+}
+.data-export-field input, .data-export-field select {
+ width: 55%;
+ float: right;
+}
+.data-export-results, .data-export-download, .data-export-cancel, .data-export-delete {
+ display: none;
+}
+.data-export-results table {
+ width: 100%;
+ margin-top: 1em;
+}
+.data-export-results thead {
+ border-bottom: 2px solid #999;
+}
+.data-export-results td {
+ border-left: 1px solid #999;
+ padding: 5px;
+}
+.data-export-results tr:nth-child(odd) {
+ background-color: #eee;
+}
+.data-export-info p {
+ font-size: 75%;
+}
+.data-export-status {
+ margin-bottom: 1em;
+}
+.data-export-status i {
+ font-size: 3em;
+}
+.data-export-actions {
+ text-align: right;
+}
diff --git a/problem_builder/public/css/mentoring-table.css b/problem_builder/public/css/mentoring-table.css
index 0f6b20ed..af544c6c 100644
--- a/problem_builder/public/css/mentoring-table.css
+++ b/problem_builder/public/css/mentoring-table.css
@@ -52,3 +52,117 @@
position: absolute;
width: 1px;
}
+
+.mentoring-table-container .share-with-container {
+ text-align: right;
+}
+
+.share-with-instructions {
+ max-width: 14.5em;
+ margin-bottom: 0;
+ text-align: left;
+}
+
+.mentoring-share-panel {
+ float: right;
+ margin-bottom: .5em;
+}
+
+.mentoring-share-panel .mentoring-share-with {
+ position: absolute;
+ right: 3.15em;
+ background-color: rgb(255, 255, 255);
+ border: 2px solid rgb(221, 221, 221);
+ padding: 1em;
+ font-size: .8em;
+}
+
+.mentoring-share-with .share-header {
+ text-align: left;
+}
+
+.mentoring-share-with .share-action-buttons {
+ text-align: center;
+ padding-top: .5em;
+}
+
+.mentoring-share-with .add-share-username {
+ margin-right: 1em;
+}
+
+.mentoring-share-with .remove-share {
+ color: black;
+ margin-right: 1.45em;
+}
+
+.mentoring-share-with .add-share-field {
+ line-height: normal;
+ padding-top: .40em;
+ padding-bottom: .40em;
+}
+
+.mentoring-share-with .share-errors {
+ color: darkred;
+ font-size: .75em;
+ text-align: center;
+ display: table-caption;
+}
+
+.new-share-container {
+ margin-top: .5em;
+ vertical-align: top;
+ width: 100%;
+ text-align: left;
+}
+
+ul.shared-list {
+ padding-left: 0;
+ padding-right: 0;
+ margin: 0 0 .25em 0;
+}
+
+.share-errors-container {
+ display: table;
+ margin: 0 auto;
+}
+
+.shared-list li {
+ list-style-type: none;
+ display: block;
+ padding: .25em 0 .25em 0;
+ margin: 0;
+}
+
+.shared-list li .username {
+ display: inline-block;
+ float: left;
+}
+
+.share-panel-container {
+ text-align: right;
+}
+
+.share-notification {
+ border: 2px solid rgb(200, 200, 200);
+ max-width: 15em;
+ padding: 1em;
+ background-color: rgb(255, 255, 255);
+ position: absolute;
+ right: 3.15em;
+ font-size: .8em;
+}
+
+.share-notification .notification-close {
+ float: right;
+ font-size: 1.2em;
+ color: black;
+ cursor: pointer;
+}
+
+.report-download-container {
+ text-align: right;
+}
+
+.mentoring .identification {
+ padding-bottom: 1em;
+}
\ No newline at end of file
diff --git a/problem_builder/public/css/mentoring_edit.css b/problem_builder/public/css/problem-builder-edit.css
similarity index 87%
rename from problem_builder/public/css/mentoring_edit.css
rename to problem_builder/public/css/problem-builder-edit.css
index 17d76e5c..c1500e27 100644
--- a/problem_builder/public/css/mentoring_edit.css
+++ b/problem_builder/public/css/problem-builder-edit.css
@@ -26,3 +26,11 @@
border-color: #888;
cursor: default;
}
+
+.xblock[data-block-type=problem-builder] .submission-message-help p {
+ border-top: 1px solid #ddd;
+ font-size: 0.85em;
+ font-style: italic;
+ margin-top: 1em;
+ padding-top: 0.3em;
+}
diff --git a/problem_builder/public/css/problem-builder-tinymce-content.css b/problem_builder/public/css/problem-builder-tinymce-content.css
new file mode 100644
index 00000000..75c46e9b
--- /dev/null
+++ b/problem_builder/public/css/problem-builder-tinymce-content.css
@@ -0,0 +1,15 @@
+/* Some styling to make clarifications stand out a bit in
+ studio HTML edit view. */
+
+.mce-content-body .pb-clarification {
+ color: #999;
+ font-size: 0.75em;
+}
+
+.mce-content-body .pb-clarification::before {
+ content: "(?)["
+}
+
+.mce-content-body .pb-clarification::after {
+ content: "]"
+}
diff --git a/problem_builder/public/css/mentoring.css b/problem_builder/public/css/problem-builder.css
similarity index 77%
rename from problem_builder/public/css/mentoring.css
rename to problem_builder/public/css/problem-builder.css
index 4b97c44a..5bef983a 100644
--- a/problem_builder/public/css/mentoring.css
+++ b/problem_builder/public/css/problem-builder.css
@@ -2,14 +2,13 @@
margin: 1em 0em;
}
-.mentoring .messages {
+.mentoring .messages,
+.mentoring .assessment-messages {
display: none;
- margin-top: 10px;
- border-top: 2px solid #eaeaea;
- padding: 12px 0px 20px;
}
-.mentoring .messages .title1 {
+.mentoring .messages .title1,
+.mentoring .assessment-messages .title1 {
color: #333333;
text-transform: uppercase;
font-weight: bold;
@@ -72,7 +71,7 @@
display: inline-block;
vertical-align: middle;
font-size: 13px;
- font-weight: bold;
+ font-weight: 600;
}
.mentoring .attempts > span {
@@ -119,6 +118,10 @@
margin-right: 10px;
}
+.mentoring .grade .grade-result {
+ margin: 20px;
+}
+
.mentoring .grade .checkmark-incorrect {
margin-left: 10px;
margin-right: 20px;
@@ -139,3 +142,42 @@
.mentoring input[type="radio"] {
margin: 0;
}
+
+.mentoring .review-list {
+ list-style: none;
+ padding-left: 0 !important;
+ margin-left: 0;
+}
+.mentoring .review-list li {
+ display: inline;
+}
+
+.mentoring .review-list li a{
+ font-weight: bold;
+}
+
+.mentoring .results-section {
+ float: left;
+}
+
+.mentoring .results-section p {
+ margin: 4px;
+}
+
+.mentoring .clear {
+ display: block;
+ clear: both;
+}
+
+.mentoring .review-link {
+ float: right;
+ display: none;
+}
+
+.pb-clarification span.clarification i {
+ font-style: normal;
+}
+
+.pb-clarification span.clarification i:hover {
+ color: rgb(0, 159, 230);
+}
diff --git a/problem_builder/public/css/questionnaire.css b/problem_builder/public/css/questionnaire.css
index c56344d4..cbb15eac 100644
--- a/problem_builder/public/css/questionnaire.css
+++ b/problem_builder/public/css/questionnaire.css
@@ -1,19 +1,23 @@
.mentoring .questionnaire .choices-list {
+ display: table;
position: relative;
+ width: 100%;
+ border-spacing: 0 6px;
padding-top: 10px;
margin-bottom: 10px;
}
.mentoring .questionnaire .choice-result {
- display: inline-block;
+ display: table-cell;
width: 40px;
- vertical-align: middle;
+ vertical-align: top;
cursor: pointer;
float: none;
}
.mentoring .questionnaire .choice {
overflow-y: hidden;
+ display: table-row;
}
.mentoring .questionnaire .choice-result.checkmark-correct,
@@ -32,10 +36,12 @@
background: none repeat scroll 0 0 #66A5B5;
font-family: arial;
font-size: 14px;
- overflow-y: auto;
opacity: 0.9;
- padding: 10px;
+ padding: 22px 10px 10px 10px;
width: 300px;
+ min-height: 40px;
+ max-height: 180px;
+ z-index: 10000;
}
.mentoring .questionnaire .choice-tips .title {
@@ -48,6 +54,9 @@
.mentoring .questionnaire .feedback .tip-choice-group,
.mentoring .questionnaire .feedback .message-content {
position: relative;
+ overflow-y: auto;
+ line-height: normal;
+ max-height: 180px;
}
.mentoring .questionnaire .choice-tips .close,
@@ -69,26 +78,16 @@
}
.mentoring .choices-list .choice-selector {
- margin-right: 5px;
+ display: table-cell;
+ vertical-align: top;
+ width: 28px;
+ padding-top: 3px;
+ padding-right: 5px;
}
.mentoring .choice-label {
- display: inline-block;
- margin-top: 8px;
- margin-bottom: 5px;
+ display: table-cell;
+ vertical-align: top;
line-height: 1.3;
-}
-
-.mentoring .choices-list .choice-text > .xblock-light-child * {
- vertical-align: middle;
-}
-
-.mentoring .choices-list .choice-text > .xblock-light-child,
-.mentoring .choices-list .choice-text > .xblock-light-child > .html_child {
- /*
- HTML Light Child content is wrapped in two divs: div.xblock-light-child and just div
- On the other hand, choice are usually rendered inline.
- Hence, we render first two divs inline, than all the actual content of HTML is rendered as is
- */
- display: inline-block;
+ padding-top: 4px;
}
diff --git a/problem_builder/public/js/answer.js b/problem_builder/public/js/answer.js
index 8d6c48d9..af9eb962 100644
--- a/problem_builder/public/js/answer.js
+++ b/problem_builder/public/js/answer.js
@@ -11,26 +11,38 @@ function AnswerBlock(runtime, element) {
if (completed === 'True' && this.mode === 'standard') {
checkmark.addClass('checkmark-correct icon-ok fa-check');
}
+
+ // In the LMS, the HTML of multiple units can be loaded at once,
+ // and the user can flip among them. If that happens, the answer in
+ // our HTML may be out of date.
+ this.refreshAnswer();
},
submit: function() {
return $(':input', element).serializeArray();
},
+ handleReview: function(result) {
+ $('textarea', element).prop('disabled', true);
+ },
+
handleSubmit: function(result) {
- if (this.mode === 'assessment')
- return;
var checkmark = $('.answer-checkmark', element);
- $(element).find('.message').text((result || {}).error || '');
this.clearResult();
- if (result.status === "correct") {
- checkmark.addClass('checkmark-correct icon-ok fa-check');
+ if (this.mode === 'assessment') {
+ // Display of checkmark would be redundant.
+ return
}
- else {
- checkmark.addClass('checkmark-incorrect icon-exclamation fa-exclamation');
+ if (result.status) {
+ if (result.status === "correct") {
+ checkmark.addClass('checkmark-correct icon-ok fa-check');
+ }
+ else {
+ checkmark.addClass('checkmark-incorrect icon-exclamation fa-exclamation');
+ }
}
},
@@ -62,6 +74,25 @@ function AnswerBlock(runtime, element) {
}
}
return true;
+ },
+
+ refreshAnswer: function() {
+ $.ajax({
+ type: 'POST',
+ url: runtime.handlerUrl(element, 'answer_value'),
+ data: '{}',
+ dataType: 'json',
+ success: function(data) {
+ // Update the answer to the latest, unless the user has made an edit
+ var newAnswer = data.value;
+ var $textarea = $(':input', element);
+ var currentAnswer = $textarea.val();
+ var origAnswer = $('.orig-student-answer', element).text();
+ if (currentAnswer == origAnswer && currentAnswer != newAnswer) {
+ $textarea.val(newAnswer);
+ }
+ },
+ });
}
};
}
diff --git a/problem_builder/public/js/answer_recap.js b/problem_builder/public/js/answer_recap.js
new file mode 100644
index 00000000..a267a17f
--- /dev/null
+++ b/problem_builder/public/js/answer_recap.js
@@ -0,0 +1,22 @@
+function AnswerRecapBlock(runtime, element) {
+ return {
+ init: function(options) {
+ // In the LMS, the HTML of multiple units can be loaded at once,
+ // and the user can flip among them. If that happens, the answer in
+ // our HTML may be out of date.
+ this.refreshAnswer();
+ },
+
+ refreshAnswer: function() {
+ $.ajax({
+ type: 'POST',
+ url: runtime.handlerUrl(element, 'refresh_html'),
+ data: '{}',
+ dataType: 'json',
+ success: function(data) {
+ $(element).html(data.html);
+ }
+ });
+ }
+ };
+}
diff --git a/problem_builder/public/js/dashboard.js b/problem_builder/public/js/dashboard.js
deleted file mode 100644
index fdd4ef99..00000000
--- a/problem_builder/public/js/dashboard.js
+++ /dev/null
@@ -1,51 +0,0 @@
-// Client side code for the Problem Builder Dashboard XBlock
-// So far, this code is only used to generate a downloadable report.
-function PBDashboardBlock(runtime, element, initData) {
- "use strict";
-
- var reportTemplate = initData.reportTemplate;
-
- var generateDataUriFromImageURL = function(imgURL) {
- // Given the URL to an image, IF the image has already been cached by the browser,
- // returns a data: URI with the contents of the image (image will be converted to PNG)
- var img = new Image();
- img.src = imgURL;
- if (!img.complete)
- return imgURL;
-
- // Create an in-memory canvas from which we can extract a data URL:
- var canvas = document.createElement("canvas");
- canvas.width = img.naturalWidth;
- canvas.height = img.naturalHeight;
- // Draw the image onto our temporary canvas:
- canvas.getContext('2d').drawImage(img, 0, 0);
- return canvas.toDataURL("image/png");
- };
-
- var unicodeStringToBase64 = function(str) {
- // Convert string to base64. A bit weird in order to support unicode, per
- // https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/btoa
- return window.btoa(unescape(encodeURIComponent(str)));
- };
-
- var downloadReport = function(ev) {
- // Download Report:
- // Change the URL to a data: URI before continuing with the click event.
- if ($(this).attr('href').charAt(0) == '#') {
- var $report = $('.dashboard-report', element).clone();
- // Convert all images in $report to data URIs:
- $report.find('image').each(function() {
- var origURL = $(this).attr('xlink:href');
- $(this).attr('xlink:href', generateDataUriFromImageURL(origURL));
- });
- // Take the resulting HTML and put it into the template we have:
- var wrapperHTML = reportTemplate.replace('REPORT_GOES_HERE', $report.html());
- //console.log(wrapperHTML);
- var dataURI = "data:text/html;base64," + unicodeStringToBase64(wrapperHTML);
- $(this).attr('href', dataURI);
- }
- };
-
- var $downloadLink = $('.report-download-link', element);
- $downloadLink.on('click', downloadReport);
-}
diff --git a/problem_builder/public/js/instructor_tool.js b/problem_builder/public/js/instructor_tool.js
new file mode 100644
index 00000000..c1e9c8a7
--- /dev/null
+++ b/problem_builder/public/js/instructor_tool.js
@@ -0,0 +1,374 @@
+function InstructorToolBlock(runtime, element) {
+ 'use strict';
+ var $element = $(element);
+
+ // Pagination
+
+ $(document).ajaxSend(function(event, jqxhr, options) {
+ if (options.url.indexOf('get_result_page') !== -1) {
+ options.data = JSON.stringify(options.data);
+ }
+ });
+
+ var Result = Backbone.Model.extend({
+
+ initialize: function(attrs, options) {
+ _.each(_.zip(Result.properties, options.values), function(pair) {
+ this.set(pair[0], pair[1]);
+ }, this);
+ }
+
+ }, { properties: ['section', 'subsection', 'unit', 'type', 'question', 'answer', 'username'] });
+
+ var Results = Backbone.PageableCollection.extend({
+
+ model: Result,
+
+ state: {
+ order: 0
+ },
+
+ url: runtime.handlerUrl(element, 'get_result_page'),
+
+ parseState: function(response) {
+ return {
+ totalRecords: response.num_results,
+ pageSize: response.page_size
+ };
+ },
+
+ parseRecords: function(response) {
+ return _.map(response.display_data, function(row) {
+ return new Result(null, { values: row });
+ });
+ },
+
+ fetchOptions: {
+ reset: true,
+ type: 'POST',
+ contentType: 'application/json',
+ processData: false
+ },
+
+ getFirstPage: function() {
+ Backbone.PageableCollection.prototype
+ .getFirstPage.call(this, this.fetchOptions);
+ },
+
+ getPreviousPage: function() {
+ Backbone.PageableCollection.prototype
+ .getPreviousPage.call(this, this.fetchOptions);
+ },
+
+ getNextPage: function() {
+ Backbone.PageableCollection.prototype
+ .getNextPage.call(this, this.fetchOptions);
+ },
+
+ getLastPage: function() {
+ Backbone.PageableCollection.prototype
+ .getLastPage.call(this, this.fetchOptions);
+ },
+
+ getCurrentPage: function() {
+ return this.state.currentPage;
+ },
+
+ getTotalPages: function() {
+ return this.state.totalPages;
+ }
+
+ });
+
+ var ResultsView = Backbone.View.extend({
+
+ initialize: function() {
+ this.listenTo(this.collection, 'reset', this.render);
+ this.listenTo(this, 'rendered', this._show);
+ this.listenTo(this, 'processing', this._hide);
+ this.listenTo(this, 'error', this._hide);
+ this.listenTo(this, 'update', this._updateInfo);
+ },
+
+ render: function() {
+ this._insertRecords();
+ this._updateControls();
+ this.$('#total-pages').text(this.collection.getTotalPages() || 0);
+ this.trigger('rendered');
+ return this;
+ },
+
+ _insertRecords: function() {
+ var tbody = this.$('tbody');
+ tbody.empty();
+ this.collection.each(function(result, index) {
+ var row = $('