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 = $(''); + _.each(Result.properties, function(name) { + row.append($('').text(result.get(name))); + }); + tbody.append(row); + }, this); + if (this.collection.getTotalPages()) { + this.$('#current-page').text(this.collection.getCurrentPage()); + } else { + this.$('#current-page').text(0); + } + }, + + _show: function() { + this.$el.show(700); + }, + + _hide: function() { + this.$el.hide(); + }, + + _updateInfo: function(info) { + var $exportInfo = this.$('.data-export-info'); + $exportInfo.empty(); + $exportInfo.append($('

').text(info)); + }, + + events: { + 'click #first-page': '_firstPage', + 'click #prev-page': '_prevPage', + 'click #next-page': '_nextPage', + 'click #last-page': '_lastPage' + }, + + _firstPage: function() { + this.collection.getFirstPage(); + this._updateControls(); + }, + + _prevPage: function() { + if (this.collection.hasPreviousPage()) { + this.collection.getPreviousPage(); + } + this._updateControls(); + }, + + _nextPage: function() { + if (this.collection.hasNextPage()) { + this.collection.getNextPage(); + } + this._updateControls(); + }, + + _lastPage: function() { + this.collection.getLastPage(); + this._updateControls(); + }, + + _updateControls: function() { + var currentPage = this.collection.getCurrentPage(), + totalPages = this.collection.getTotalPages() || 0, + backward = ["#first-page", "#prev-page"], + forward = ["#next-page", "#last-page"]; + this._enable(backward, currentPage > 1); + this._enable(forward, currentPage < totalPages); + }, + + _enable: function(controls, condition) { + _.each(controls, function(control) { + this.$(control).prop('disabled', !condition); + }, this); + } + + }); + + var resultsView = new ResultsView({ + collection: new Results([]), + el: $element.find('#results') + }); + + // Status area + + var StatusView = Backbone.View.extend({ + + initialize: function() { + this.listenTo(this, 'processing', this._showSpinner); + this.listenTo(this, 'notify', this._displayMessage); + this.listenTo(this, 'stopped', this._empty); + this.listenTo(resultsView, 'rendered', this._empty); + }, + + _showSpinner: function() { + this.$el.empty(); + this.$el.append( + $('').addClass('icon fa fa-spinner fa-spin') + ).css('text-align', 'center'); + }, + + _displayMessage: function(message) { + this.$el.append($('

').text(message)); + }, + + _empty: function() { + this.$el.empty(); + } + + }); + + var statusView = new StatusView({ + el: $element.find('.data-export-status') + }); + + // Set up gettext in case it isn't available in the client runtime: + if (typeof gettext == "undefined") { + window.gettext = function gettext_stub(string) { return string; }; + window.ngettext = function ngettext_stub(strA, strB, n) { return n == 1 ? strA : strB; }; + } + var $startButton = $element.find('.data-export-start'); + var $cancelButton = $element.find('.data-export-cancel'); + var $downloadButton = $element.find('.data-export-download'); + var $deleteButton = $element.find('.data-export-delete'); + var $blockTypes = $element.find("select[name='block_types']"); + var $rootBlockId = $element.find("select[name='root_block_id']"); + var $username = $element.find("input[name='username']"); + var $matchString = $element.find("input[name='match_string']"); + var $resultTable = $element.find('.data-export-results'); + + var status; + function getStatus() { + $.ajax({ + type: 'POST', + url: runtime.handlerUrl(element, 'get_status'), + data: '{}', + success: updateStatus, + dataType: 'json' + }); + } + + function updateStatus(newStatus) { + var statusChanged = ! _.isEqual(newStatus, status); + status = newStatus; + if (status.export_pending) { + // Keep polling for status updates when an export is running. + setTimeout(getStatus, 1000); + } + if (statusChanged) updateView(); + } + + function disableActions() { + $startButton.prop('disabled', true); + $cancelButton.prop('disabled', true); + $downloadButton.prop('disabled', true); + $deleteButton.prop('disabled', true); + } + + function showInfo(info) { + resultsView.trigger('update', info); + } + + function showResults() { + if (status.last_export_result) { + $resultTable.show(); + } + } + + function hideResults() { + resultsView.trigger('processing'); + } + + function showSpinner() { + statusView.trigger('processing'); + } + + function hideSpinner() { + statusView.trigger('stopped'); + } + + function showStatusMessage(message) { + statusView.trigger('notify', message); + } + + function handleError(data) { + // Shim to make the XBlock JsonHandlerError response work with our format. + status = {'last_export_result': JSON.parse(data.responseText), 'export_pending': false}; + updateView(); + } + + function updateView() { + var startTime; + $startButton.toggle(!status.export_pending).prop('disabled', false); + $cancelButton.toggle(status.export_pending).prop('disabled', false); + $downloadButton.toggle(Boolean(status.download_url)).prop('disabled', false); + $deleteButton.toggle(Boolean(status.last_export_result)).prop('disabled', false); + if (status.last_export_result) { + if (status.last_export_result.error) { + hideResults(); + hideSpinner(); + showStatusMessage(_.template( + gettext('Data export failed. Reason: <%= error %>'), + {'error': status.last_export_result.error} + )); + } else { + startTime = new Date(status.last_export_result.start_timestamp * 1000); + showInfo( + _.template( + ngettext( + 'Results retrieved on <%= creation_time %> (<%= seconds %> second).', + 'Results retrieved on <%= creation_time %> (<%= seconds %> seconds).', + status.last_export_result.generation_time_s.toFixed(1) + ), + { + 'creation_time': startTime.toString(), + 'seconds': status.last_export_result.generation_time_s.toFixed(1) + } + )); + resultsView.collection.getFirstPage(); + } + } else { + if (status.export_pending) { + showStatusMessage(gettext('The report is currently being generated…')); + } else { + hideSpinner(); + } + } + } + + function addHandler($button, handlerName, form_submit) { + $button.on('click', function() { + var data; + if (form_submit) { + data = { + block_types: $blockTypes.val(), + root_block_id: $rootBlockId.val(), + username: $username.val(), + match_string: $matchString.val() + }; + data = JSON.stringify(data); + } else { + data = '{}'; + } + $.ajax({ + type: 'POST', + url: runtime.handlerUrl(element, handlerName), + data: data, + success: updateStatus, + error: handleError, + dataType: 'json' + }); + showSpinner(); + disableActions(); + }); + } + + addHandler($startButton, 'start_export', true); + addHandler($cancelButton, 'cancel_export'); + addHandler($deleteButton, 'delete_export'); + + $startButton.on('click', hideResults); + $cancelButton.on('click', showResults); + $deleteButton.on('click', hideResults); + + $downloadButton.on('click', function() { + window.location.href = status.download_url; + }); + + showSpinner(); + disableActions(); + getStatus(); + +} diff --git a/problem_builder/public/js/mentoring-table.js b/problem_builder/public/js/mentoring-table.js deleted file mode 100644 index c7c34200..00000000 --- a/problem_builder/public/js/mentoring-table.js +++ /dev/null @@ -1,9 +0,0 @@ -function MentoringTableBlock(runtime, element) { - // Display an exceprt for long answers, with a "more" link to display the full text - $('.answer-table', element).shorten({ - moreText: 'more', - lessText: 'less', - showChars: '500' - }); - return {}; -} diff --git a/problem_builder/public/js/mentoring.js b/problem_builder/public/js/mentoring.js index dbbd345e..04840e0c 100644 --- a/problem_builder/public/js/mentoring.js +++ b/problem_builder/public/js/mentoring.js @@ -61,7 +61,7 @@ function MentoringBlock(runtime, element) { if (typeof obj !== 'undefined' && typeof obj[fn] == 'function') { return obj[fn].apply(obj, Array.prototype.slice.call(arguments, 2)); } else { - return undefined; + return null; } } @@ -107,12 +107,14 @@ function MentoringBlock(runtime, element) { function getChildByName(name) { for (var i = 0; i < children.length; i++) { var child = children[i]; - if (child && child.name === name) { + if (child && typeof child.name !== 'undefined' && child.name.toString() === name) { return child; } } } + ProblemBuilderUtil.transformClarifications(element); + if (data.mode === 'standard') { MentoringStandardView(runtime, element, mentoring); } diff --git a/problem_builder/public/js/mentoring_assessment_view.js b/problem_builder/public/js/mentoring_assessment_view.js index 7cc15ba7..f72289a3 100644 --- a/problem_builder/public/js/mentoring_assessment_view.js +++ b/problem_builder/public/js/mentoring_assessment_view.js @@ -1,6 +1,7 @@ function MentoringAssessmentView(runtime, element, mentoring) { var gradeTemplate = _.template($('#xblock-grade-template').html()); - var submitDOM, nextDOM, reviewDOM, tryAgainDOM; + var reviewQuestionsTemplate = _.template($('#xblock-review-questions-template').html()); + var submitDOM, nextDOM, reviewDOM, tryAgainDOM, messagesDOM, reviewLinkDOM; var submitXHR; var checkmark; var active_child; @@ -21,14 +22,36 @@ function MentoringAssessmentView(runtime, element, mentoring) { $('.grade').html(''); $('.attempts').html(''); + messagesDOM.empty().hide(); + } + + function no_more_attempts() { + var attempts_data = $('.attempts', element).data(); + return (attempts_data.max_attempts > 0) && (attempts_data.num_attempts >= attempts_data.max_attempts); } function renderGrade() { + notify('navigation', {state: 'unlock'}) var data = $('.grade', element).data(); + data.enable_extended = (no_more_attempts() && data.extended_feedback); + _.extend(data, { + 'runDetails': function(label) { + if (! data.enable_extended) { + return '' + } + var self = this; + return reviewQuestionsTemplate({'questions': self[label], 'label': label}) + } + }); cleanAll(); $('.grade', element).html(gradeTemplate(data)); + reviewLinkDOM.hide(); reviewDOM.hide(); submitDOM.hide(); + if (data.enable_extended) { + nextDOM.unbind('click'); + nextDOM.bind('click', reviewNextChild) + } nextDOM.hide(); tryAgainDOM.show(); @@ -40,6 +63,11 @@ function MentoringAssessmentView(runtime, element, mentoring) { } mentoring.renderAttempts(); + if (data.assessment_message && (data.max_attempts === 0 || data.num_attempts < data.max_attempts)) { + mentoring.setContent(messagesDOM, data.assessment_message); + messagesDOM.show(); + } + $('a.question-link', element).click(reviewJump); } function handleTryAgain(result) { @@ -47,6 +75,7 @@ function MentoringAssessmentView(runtime, element, mentoring) { return; active_child = -1; + notify('navigation', {state: 'lock'}) displayNextChild(); tryAgainDOM.hide(); submitDOM.show(); @@ -56,7 +85,6 @@ function MentoringAssessmentView(runtime, element, mentoring) { } function tryAgain() { - var success = true; var handlerUrl = runtime.handlerUrl(element, 'try_again'); if (submitXHR) { submitXHR.abort(); @@ -65,21 +93,30 @@ function MentoringAssessmentView(runtime, element, mentoring) { } function initXBlockView() { + notify('navigation', {state: 'lock'}) submitDOM = $(element).find('.submit .input-main'); nextDOM = $(element).find('.submit .input-next'); reviewDOM = $(element).find('.submit .input-review'); tryAgainDOM = $(element).find('.submit .input-try-again'); + reviewLinkDOM = $(element).find('.review-link'); checkmark = $('.assessment-checkmark', element); + messagesDOM = $('.assessment-messages', element); submitDOM.show(); submitDOM.bind('click', submit); nextDOM.bind('click', displayNextChild); nextDOM.show(); - reviewDOM.bind('click', renderGrade); tryAgainDOM.bind('click', tryAgain); active_child = mentoring.step; + function renderGradeEvent(event) { + event.preventDefault(); + renderGrade(); + } + reviewLinkDOM.bind('click', renderGradeEvent); + reviewDOM.bind('click', renderGradeEvent); + var options = { onChange: onChange }; @@ -102,24 +139,92 @@ function MentoringAssessmentView(runtime, element, mentoring) { return (active_child == mentoring.steps.length); } - function displayNextChild() { - cleanAll(); + function notify(name, data){ + // Notification interface does not exist in the workbench. + if (runtime.notify) { + runtime.notify(name, data) + } + } - // find the next real child block to display. HTMLBlock are always displayed - active_child++; + function reviewJump(event) { + // Used only during extended feedback. Assumes completion and attempts exhausted. + event.preventDefault(); + + var target = parseInt($(event.target).data('step')) - 1; + reviewDisplayChild(target); + } + + function reviewDisplayChild(child_index) { + active_child = child_index; + cleanAll(); var child = mentoring.steps[active_child]; $(child.element).show(); $(child.element).find("input, textarea").first().focus(); mentoring.publish_event({ - event_type: 'xblock.problem_builder.assessment.shown', - exercise_id: child.name + event_type: 'xblock.mentoring.assessment.review', + exercise_id: $(mentoring.steps[active_child]).attr('name') }); + post_display(true); + get_results(); + } + + function reviewNextChild() { + nextDOM.attr('disabled', 'disabled'); + nextDOM.hide(); + findNextChild(); + reviewDisplayChild(active_child) + } - if (isDone()) + function displayNextChild() { + cleanAll(); + findNextChild(true); + // find the next real child block to display. HTMLBlock are always displayed + if (isDone()) { renderGrade(); + } else { + post_display(); + } + } + + function findNextChild(fire_event) { + // find the next real child block to display. HTMLBlock are always displayed + ++active_child; + var child = mentoring.steps[active_child]; + $(child.element).show(); + $(child.element).find("input, textarea").first().focus(); + if (fire_event) { + mentoring.publish_event({ + event_type: 'xblock.problem_builder.assessment.shown', + exercise_id: child.name.toString() + }); + } + } + + function post_display(show_link) { nextDOM.attr('disabled', 'disabled'); - reviewDOM.attr('disabled', 'disabled'); - validateXBlock(); + if (no_more_attempts()) { + if (show_link) { + reviewLinkDOM.show(); + } else { + reviewDOM.show(); + reviewDOM.removeAttr('disabled') + } + } else { + reviewDOM.attr('disabled', 'disabled'); + } + validateXBlock(show_link); + if (show_link && ! isLastChild()) { + // User should also be able to browse forward if we're showing the review link. + nextDOM.show(); + nextDOM.removeAttr('disabled'); + } + if (show_link) { + // The user has no more tries, so the try again button is noise. A disabled submit button + // emphasizes that the user cannot change their answer. + tryAgainDOM.hide(); + submitDOM.show(); + submitDOM.attr('disabled', 'disabled') + } } function onChange() { @@ -131,19 +236,20 @@ function MentoringAssessmentView(runtime, element, mentoring) { } } - function handleSubmitResults(result) { - $('.grade', element).data('score', result.score); - $('.grade', element).data('correct_answer', result.correct_answer); - $('.grade', element).data('incorrect_answer', result.incorrect_answer); - $('.grade', element).data('partially_correct_answer', result.partially_correct_answer); - $('.grade', element).data('max_attempts', result.max_attempts); - $('.grade', element).data('num_attempts', result.num_attempts); - $('.attempts', element).data('max_attempts', result.max_attempts); - $('.attempts', element).data('num_attempts', result.num_attempts); - - if (result.completed === 'partial') { + function handleResults(response) { + $('.grade', element).data('score', response.score); + $('.grade', element).data('correct_answer', response.correct_answer); + $('.grade', element).data('incorrect_answer', response.incorrect_answer); + $('.grade', element).data('partially_correct_answer', response.partially_correct_answer); + $('.grade', element).data('max_attempts', response.max_attempts); + $('.grade', element).data('num_attempts', response.num_attempts); + $('.grade', element).data('assessment_message', response.assessment_message); + $('.attempts', element).data('max_attempts', response.max_attempts); + $('.attempts', element).data('num_attempts', response.num_attempts); + + if (response.completed === 'partial') { checkmark.addClass('checkmark-partially-correct icon-ok fa-check'); - } else if (result.completed === 'correct') { + } else if (response.completed === 'correct') { checkmark.addClass('checkmark-correct icon-ok fa-check'); } else { checkmark.addClass('checkmark-incorrect icon-exclamation fa-exclamation'); @@ -151,40 +257,58 @@ function MentoringAssessmentView(runtime, element, mentoring) { submitDOM.attr('disabled', 'disabled'); - /* Something went wrong with student submission, denied next question */ - if (result.step != active_child+1) { - active_child = result.step-1; - displayNextChild(); - } else { - nextDOM.removeAttr("disabled"); - if (nextDOM.is(':visible')) { nextDOM.focus(); } - reviewDOM.removeAttr("disabled"); - if (reviewDOM.is(':visible')) { reviewDOM.focus(); } + /* We're not dealing with the current step */ + if (response.step != active_child+1) { + return } + nextDOM.removeAttr("disabled"); + reviewDOM.removeAttr("disabled"); + if (nextDOM.is(':visible')) { nextDOM.focus(); } + if (reviewDOM.is(':visible')) { reviewDOM.focus(); } } - function submit() { - var success = true; + function handleReviewResults(response) { + handleResults(response); + var options = { + max_attempts: response.max_attempts, + num_attempts: response.num_attempts + }; + var result = response.results[1]; + var child = mentoring.steps[active_child]; + callIfExists(child, 'handleSubmit', result, options); + callIfExists(child, 'handleReview', result, options); + } + + function handleSubmitResults(response){ + handleResults(response); + // Update grade information + $('.grade').data(response); + } + + + function calculate_results(handler_name, callback) { var data = {}; var child = mentoring.steps[active_child]; if (child && child.name !== undefined) { - data[child.name] = callIfExists(child, 'submit'); + data[child.name.toString()] = callIfExists(child, handler_name); } - var handlerUrl = runtime.handlerUrl(element, 'submit'); + var handlerUrl = runtime.handlerUrl(element, handler_name); if (submitXHR) { submitXHR.abort(); } - submitXHR = $.post(handlerUrl, JSON.stringify(data)).success(handleSubmitResults); + submitXHR = $.post(handlerUrl, JSON.stringify(data)).success(callback); } - function validateXBlock() { - var is_valid = true; - var data = $('.attempts', element).data(); - var steps = mentoring.steps; + function submit() { + calculate_results('submit', handleSubmitResults) + } - // if ((data.max_attempts > 0) && (data.num_attempts >= data.max_attempts)) { - // is_valid = false; - // } + function get_results() { + calculate_results('get_results', handleReviewResults) + } + + function validateXBlock(hide_nav) { + var is_valid = true; var child = mentoring.steps[active_child]; if (child && child.name !== undefined) { var child_validation = callIfExists(child, 'validate'); @@ -201,7 +325,7 @@ function MentoringAssessmentView(runtime, element, mentoring) { submitDOM.removeAttr("disabled"); } - if (isLastChild()) { + if (isLastChild() && ! hide_nav) { nextDOM.hide(); reviewDOM.show(); } diff --git a/problem_builder/public/js/mentoring_edit.js b/problem_builder/public/js/mentoring_edit.js index 131a8649..b5f545a5 100644 --- a/problem_builder/public/js/mentoring_edit.js +++ b/problem_builder/public/js/mentoring_edit.js @@ -5,7 +5,7 @@ function MentoringEditComponents(runtime, element) { var updateButtons = function() { $buttons.each(function() { var msg_type = $(this).data('boilerplate'); - $(this).toggleClass('disabled', $('.xblock .message.'+msg_type).length > 0); + $(this).toggleClass('disabled', $('.xblock .submission-message.'+msg_type).length > 0); }); }; updateButtons(); @@ -17,5 +17,8 @@ function MentoringEditComponents(runtime, element) { $(this).addClass('disabled'); } }); + + ProblemBuilderUtil.transformClarifications(element); + runtime.listenTo('deleted-child', updateButtons); } diff --git a/problem_builder/public/js/mentoring_standard_view.js b/problem_builder/public/js/mentoring_standard_view.js index 50452c61..7357d5ab 100644 --- a/problem_builder/public/js/mentoring_standard_view.js +++ b/problem_builder/public/js/mentoring_standard_view.js @@ -4,49 +4,83 @@ function MentoringStandardView(runtime, element, mentoring) { var callIfExists = mentoring.callIfExists; - function handleSubmitResults(results) { + function handleSubmitResults(response, disable_submit) { messagesDOM.empty().hide(); - $.each(results.submitResults || [], function(index, submitResult) { - var input = submitResult[0]; - var result = submitResult[1]; + $.each(response.results || [], function(index, result_spec) { + var input = result_spec[0]; + var result = result_spec[1]; var child = mentoring.getChildByName(input); var options = { - max_attempts: results.max_attempts, - num_attempts: results.num_attempts + max_attempts: response.max_attempts, + num_attempts: response.num_attempts }; callIfExists(child, 'handleSubmit', result, options); }); - $('.attempts', element).data('max_attempts', results.max_attempts); - $('.attempts', element).data('num_attempts', results.num_attempts); + $('.attempts', element).data('max_attempts', response.max_attempts); + $('.attempts', element).data('num_attempts', response.num_attempts); mentoring.renderAttempts(); // Messages should only be displayed upon hitting 'submit', not on page reload - mentoring.setContent(messagesDOM, results.message); + mentoring.setContent(messagesDOM, response.message); if (messagesDOM.html().trim()) { messagesDOM.prepend('

' + mentoring.data.feedback_label + '
'); messagesDOM.show(); } - submitDOM.attr('disabled', 'disabled'); + // this method is called on successful submission and on page load + // results will be empty only for initial load if no submissions was made + // in such case we must allow submission to support submitting empty read-only long answer recaps + if (disable_submit || response.results.length > 0) { + submitDOM.attr('disabled', 'disabled'); + } } - function submit() { - var success = true; + function handleSubmitError(jqXHR, textStatus, errorThrown, disable_submit) { + if (textStatus == "error") { + var errMsg = errorThrown; + // Check if there's a more specific JSON error message: + if (jqXHR.responseText) { + // Is there a more specific error message we can show? + try { + errMsg = JSON.parse(jqXHR.responseText).error; + } catch (error) { errMsg = jqXHR.responseText.substr(0, 300); } + } + + mentoring.setContent(messagesDOM, errMsg); + messagesDOM.show(); + } + + if (disable_submit) { + submitDOM.attr('disabled', 'disabled'); + } + } + + function calculate_results(handler_name, disable_submit) { var data = {}; var children = mentoring.children; for (var i = 0; i < children.length; i++) { var child = children[i]; - if (child && child.name !== undefined && typeof(child.submit) !== "undefined") { - data[child.name] = child.submit(); + if (child && child.name !== undefined && typeof(child[handler_name]) !== "undefined") { + data[child.name.toString()] = child[handler_name](); } } - var handlerUrl = runtime.handlerUrl(element, 'submit'); + var handlerUrl = runtime.handlerUrl(element, handler_name); if (submitXHR) { submitXHR.abort(); } - submitXHR = $.post(handlerUrl, JSON.stringify(data)).success(handleSubmitResults); + submitXHR = $.post(handlerUrl, JSON.stringify(data)) + .success(function(response) { handleSubmitResults(response, disable_submit); }) + .error(function(jqXHR, textStatus, errorThrown) { handleSubmitError(jqXHR, textStatus, errorThrown, disable_submit); }); + } + + function get_results(){ + calculate_results('get_results', false); + } + + function submit() { + calculate_results('submit', true); } function clearResults() { @@ -74,11 +108,15 @@ function MentoringStandardView(runtime, element, mentoring) { }; mentoring.initChildren(options); - - mentoring.renderAttempts(); mentoring.renderDependency(); - validateXBlock(); + get_results(); + + var submitPossible = submitDOM.length > 0; + if (submitPossible) { + mentoring.renderAttempts(); + validateXBlock(); + } // else display_submit is false and this is read-only } // validate all children diff --git a/problem_builder/public/js/questionnaire.js b/problem_builder/public/js/questionnaire.js index 033a2b3d..d54b64fd 100644 --- a/problem_builder/public/js/questionnaire.js +++ b/problem_builder/public/js/questionnaire.js @@ -18,16 +18,23 @@ function MessageView(element, mentoring) { // Set the width/height var tip = $('.tip', popupDOM)[0]; var data = $(tip).data(); + var innerDOM = popupDOM.find('.tip-choice-group'); if (data && data.width) { popupDOM.css('width', data.width); + innerDOM.css('width', data.width); } else { popupDOM.css('width', ''); + innerDOM.css('width', ''); } if (data && data.height) { popupDOM.css('height', data.height); + popupDOM.css('maxHeight', data.height); + innerDOM.css('maxHeight', data.height); } else { popupDOM.css('height', ''); + popupDOM.css('maxHeight', ''); + innerDOM.css('maxHeight', ''); } var container = popupDOM.parent('.choice-tips-container'); @@ -90,23 +97,24 @@ function MCQBlock(runtime, element) { } }, - handleSubmit: function(result) { - if (this.mode === 'assessment') - return; + handleReview: function(result){ + $('.choice input[value="' + result.submission + '"]', element).prop('checked', true); + $('.choice input', element).prop('disabled', true); + }, + handleSubmit: function(result) { mentoring = this.mentoring; var messageView = MessageView(element, mentoring); messageView.clearResult(); - var choiceInputs = $('.choice input', element); + var choiceInputs = $('.choice-selector input', element); $.each(choiceInputs, function(index, choiceInput) { var choiceInputDOM = $(choiceInput); var choiceDOM = choiceInputDOM.closest('.choice'); var choiceResultDOM = $('.choice-result', choiceDOM); var choiceTipsDOM = $('.choice-tips', choiceDOM); - var choiceTipsCloseDOM; if (result.status === "correct" && choiceInputDOM.val() === result.submission) { choiceDOM.addClass('correct'); @@ -122,7 +130,6 @@ function MCQBlock(runtime, element) { messageView.showMessage(choiceTipsDOM); } - choiceTipsCloseDOM = $('.close', choiceTipsDOM); choiceResultDOM.off('click').on('click', function() { if (choiceTipsDOM.html() !== '') { messageView.showMessage(choiceTipsDOM); @@ -171,9 +178,14 @@ function MRQBlock(runtime, element) { return checkedValues; }, + handleReview: function(result) { + $.each(result.submissions, function (index, value) { + $('input[type="checkbox"][value="' + value + '"]').prop('checked', true) + }); + $('input', element).prop('disabled', true); + }, + handleSubmit: function(result, options) { - if (this.mode === 'assessment') - return; mentoring = this.mentoring; @@ -186,14 +198,13 @@ function MRQBlock(runtime, element) { var questionnaireDOM = $('fieldset.questionnaire', element); var data = questionnaireDOM.data(); - var hide_results = (data.hide_results === 'True') ? true : false; + var hide_results = (data.hide_results === 'True'); $.each(result.choices, function(index, choice) { var choiceInputDOM = $('.choice input[value='+choice.value+']', element); var choiceDOM = choiceInputDOM.closest('.choice'); var choiceResultDOM = $('.choice-result', choiceDOM); var choiceTipsDOM = $('.choice-tips', choiceDOM); - var choiceTipsCloseDOM; /* show hint if checked or max_attempts is disabled */ if (!hide_results && @@ -208,7 +219,6 @@ function MRQBlock(runtime, element) { mentoring.setContent(choiceTipsDOM, choice.tips); - choiceTipsCloseDOM = $('.close', choiceTipsDOM); choiceResultDOM.off('click').on('click', function() { messageView.showMessage(choiceTipsDOM); }); diff --git a/problem_builder/public/js/questionnaire_edit.js b/problem_builder/public/js/questionnaire_edit.js new file mode 100644 index 00000000..fb266ad5 --- /dev/null +++ b/problem_builder/public/js/questionnaire_edit.js @@ -0,0 +1,4 @@ +function QuestionnaireEdit(runtime, element) { + 'use strict'; + ProblemBuilderUtil.transformClarifications(element); +} diff --git a/problem_builder/public/js/review_blocks.js b/problem_builder/public/js/review_blocks.js new file mode 100644 index 00000000..ffe8ef84 --- /dev/null +++ b/problem_builder/public/js/review_blocks.js @@ -0,0 +1,221 @@ +// Client side code for the Problem Builder Dashboard XBlock +// So far, this code is only used to generate a downloadable report. +function ExportBase(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 = $(initData.reportContentSelector, 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); +} + +function PBDashboardBlock(runtime, element, initData) { + new ExportBase(runtime, element, initData); +} + +function MentoringTableBlock(runtime, element, initData) { + // Display an excerpt for long answers, with a "more" link to display the full text + + var $element = $(element), + $shareButton = $element.find('.mentoring-share-button'), + $doShareButton = $element.find('.do-share-button'), + $shareMenu = $element.find('.mentoring-share-with'), + $displayDropdown = $element.find('.mentoring-display-dropdown'), + $errorHolder = $element.find('.share-errors'), + $deleteShareButton = $element.find('.remove-share'), + $newShareContainer = $($element.find('.new-share-container')[0]), + $addShareField = $($element.find('.add-share-field')[0]), + $notification = $($element.find('.share-notification')), + $closeNotification = $($element.find('.notification-close')), + tableLoadURL = runtime.handlerUrl(element, 'table_render'), + deleteShareUrl = runtime.handlerUrl(element, 'remove_share'), + sharedListLoadUrl = runtime.handlerUrl(element, 'get_shared_list'), + clearNotificationUrl = runtime.handlerUrl(element, 'clear_notification'), + shareResultsUrl = runtime.handlerUrl(element, 'share_results'); + + function loadTable(data) { + $element.find('.mentoring-table-target').html(data['content']); + $('.answer-table', element).shorten({ + moreText: 'more', + lessText: 'less', + showChars: '500' + }); + } + + function errorMessage(event) { + $errorHolder.text(JSON.parse(event.responseText)['error']) + } + + function sharedRefresh(data) { + $element.find('.shared-with-container').html(data['content']); + $deleteShareButton = $($deleteShareButton.selector); + $deleteShareButton.on('click', deleteShare); + } + + function postShareRefresh(data) { + sharedRefresh(data); + $element.find(".new-share-container").each(function(index, container) { + if (index === 0) { + var $container = $(container); + $container.find('.add-share-username').val(''); + $container.find('.add-share-field').show(); + return; + } + $(container).remove() + }); + $errorHolder.html(''); + } + + function postShare() { + $.ajax({ + type: "POST", + url: sharedListLoadUrl, + data: JSON.stringify({}), + success: postShareRefresh, + error: errorMessage + }); + } + + function updateShare() { + var usernames = []; + $element.find('.add-share-username').each(function(index, username) { + usernames.push($(username).val()) + }); + $.ajax({ + type: "POST", + url: shareResultsUrl, + data: JSON.stringify({'usernames': usernames}), + success: postShare, + error: errorMessage + }); + } + + function menuHider(event) { + if (!$(event.target).closest($shareMenu).length) { + // We're clicking outside of the menu, so hide it. + $shareMenu.hide(); + $(document).off('click.mentoring_share_menu_hide'); + } + } + + $shareButton.on('click', function (event) { + if (!$shareMenu.is(':visible')){ + event.stopPropagation(); + $(document).on('click.mentoring_share_menu_hide', menuHider); + $shareMenu.show(); + } + }); + $doShareButton.on('click', updateShare); + + function postLoad(data) { + loadTable(data); + new ExportBase(runtime, element, initData); + } + + $.ajax({ + type: "POST", + url: tableLoadURL, + data: JSON.stringify({'target_username': $displayDropdown.val()}), + success: postLoad + }); + + $.ajax({ + type: "POST", + url: sharedListLoadUrl, + data: JSON.stringify({}), + success: sharedRefresh + }); + + $displayDropdown.on('change', function () { + if ($displayDropdown[0].selectedIndex !== 0) { + $shareButton.prop('disabled', true); + $element.find('.report-download-container').hide(); + } else { + $shareButton.prop('disabled', false); + $element.find('.report-download-container').show(); + } + $.ajax({ + type: "POST", + url: tableLoadURL, + data: JSON.stringify({'target_username': $displayDropdown.val()}), + success: loadTable + }) + }); + + function addShare() { + var container = $newShareContainer.clone(); + container.find('.add-share-username').val(''); + container.insertAfter($element.find('.new-share-container').last()); + container.find('.add-share-field').on('click', addShare); + var buttons = $element.find('.new-share-container .add-share-field'); + buttons.hide(); + buttons.last().show(); + } + + function deleteShare(event) { + event.preventDefault(); + $.ajax({ + type: "POST", + url: deleteShareUrl, + data: JSON.stringify({'username': $(event.target).parent().prev()[0].innerHTML}), + success: function () { + $(event.target).parent().parent().remove(); + $errorHolder.html(''); + }, + error: errorMessage + }); + } + + $closeNotification.on('click', function () { + // Don't need server approval to hide it. + $notification.hide(); + $.ajax({ + type: "POST", + url: clearNotificationUrl, + data: JSON.stringify({'usernames': $notification.data('shared')}) + }) + }); + + $addShareField.on('click', addShare); +} diff --git a/problem_builder/public/js/util.js b/problem_builder/public/js/util.js new file mode 100644 index 00000000..b4d7d1f6 --- /dev/null +++ b/problem_builder/public/js/util.js @@ -0,0 +1,40 @@ +window.ProblemBuilderUtil = { + + transformClarifications: function(element) { + var $element = $(element); + + var transformExisting = function(node) { + $('.pb-clarification', node).each(function() { + var item = $(this); + var content = item.html(); + var clarification = $( + '' + + '' + + '' + + '' + ); + clarification.find('i').attr('data-tooltip', content); + clarification.find('span.sr').html(content); + item.empty().append(clarification); + }); + }; + + // Transform all span.pb-clarifications already existing inside the element. + transformExisting($element); + + // Transform all future span.pb-clarifications using mutation observer. + // It's only needed in the Studio when editing xblock children because the + // block's JS init function isn't called after edits in the Studio. + if (window.MutationObserver) { + var observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + Array.prototype.forEach.call(mutation.addedNodes, function(node) { + transformExisting(node); + }); + }) + }); + observer.observe($element[0], {childList: true, subtree: true}); + } + } + +}; diff --git a/problem_builder/public/js/vendor/backbone-min.js b/problem_builder/public/js/vendor/backbone-min.js new file mode 100644 index 00000000..bce4fbc1 --- /dev/null +++ b/problem_builder/public/js/vendor/backbone-min.js @@ -0,0 +1 @@ +(function(){var t=this;var e=t.Backbone;var i=[];var r=i.push;var s=i.slice;var n=i.splice;var a;if(typeof exports!=="undefined"){a=exports}else{a=t.Backbone={}}a.VERSION="1.0.0";var h=t._;if(!h&&typeof require!=="undefined")h=require("underscore");a.$=t.jQuery||t.Zepto||t.ender||t.$;a.noConflict=function(){t.Backbone=e;return this};a.emulateHTTP=false;a.emulateJSON=false;var o=a.Events={on:function(t,e,i){if(!l(this,"on",t,[e,i])||!e)return this;this._events||(this._events={});var r=this._events[t]||(this._events[t]=[]);r.push({callback:e,context:i,ctx:i||this});return this},once:function(t,e,i){if(!l(this,"once",t,[e,i])||!e)return this;var r=this;var s=h.once(function(){r.off(t,s);e.apply(this,arguments)});s._callback=e;return this.on(t,s,i)},off:function(t,e,i){var r,s,n,a,o,u,c,f;if(!this._events||!l(this,"off",t,[e,i]))return this;if(!t&&!e&&!i){this._events={};return this}a=t?[t]:h.keys(this._events);for(o=0,u=a.length;o").attr(t);this.setElement(e,false)}else{this.setElement(h.result(this,"el"),false)}}});a.sync=function(t,e,i){var r=k[t];h.defaults(i||(i={}),{emulateHTTP:a.emulateHTTP,emulateJSON:a.emulateJSON});var s={type:r,dataType:"json"};if(!i.url){s.url=h.result(e,"url")||U()}if(i.data==null&&e&&(t==="create"||t==="update"||t==="patch")){s.contentType="application/json";s.data=JSON.stringify(i.attrs||e.toJSON(i))}if(i.emulateJSON){s.contentType="application/x-www-form-urlencoded";s.data=s.data?{model:s.data}:{}}if(i.emulateHTTP&&(r==="PUT"||r==="DELETE"||r==="PATCH")){s.type="POST";if(i.emulateJSON)s.data._method=r;var n=i.beforeSend;i.beforeSend=function(t){t.setRequestHeader("X-HTTP-Method-Override",r);if(n)return n.apply(this,arguments)}}if(s.type!=="GET"&&!i.emulateJSON){s.processData=false}if(s.type==="PATCH"&&window.ActiveXObject&&!(window.external&&window.external.msActiveXFilteringEnabled)){s.xhr=function(){return new ActiveXObject("Microsoft.XMLHTTP")}}var o=i.xhr=a.ajax(h.extend(s,i));e.trigger("request",e,o,i);return o};var k={create:"POST",update:"PUT",patch:"PATCH","delete":"DELETE",read:"GET"};a.ajax=function(){return a.$.ajax.apply(a.$,arguments)};var S=a.Router=function(t){t||(t={});if(t.routes)this.routes=t.routes;this._bindRoutes();this.initialize.apply(this,arguments)};var $=/\((.*?)\)/g;var T=/(\(\?)?:\w+/g;var H=/\*\w+/g;var A=/[\-{}\[\]+?.,\\\^$|#\s]/g;h.extend(S.prototype,o,{initialize:function(){},route:function(t,e,i){if(!h.isRegExp(t))t=this._routeToRegExp(t);if(h.isFunction(e)){i=e;e=""}if(!i)i=this[e];var r=this;a.history.route(t,function(s){var n=r._extractParameters(t,s);i&&i.apply(r,n);r.trigger.apply(r,["route:"+e].concat(n));r.trigger("route",e,n);a.history.trigger("route",r,e,n)});return this},navigate:function(t,e){a.history.navigate(t,e);return this},_bindRoutes:function(){if(!this.routes)return;this.routes=h.result(this,"routes");var t,e=h.keys(this.routes);while((t=e.pop())!=null){this.route(t,this.routes[t])}},_routeToRegExp:function(t){t=t.replace(A,"\\$&").replace($,"(?:$1)?").replace(T,function(t,e){return e?t:"([^/]+)"}).replace(H,"(.*?)");return new RegExp("^"+t+"$")},_extractParameters:function(t,e){var i=t.exec(e).slice(1);return h.map(i,function(t){return t?decodeURIComponent(t):null})}});var I=a.History=function(){this.handlers=[];h.bindAll(this,"checkUrl");if(typeof window!=="undefined"){this.location=window.location;this.history=window.history}};var N=/^[#\/]|\s+$/g;var P=/^\/+|\/+$/g;var O=/msie [\w.]+/;var C=/\/$/;I.started=false;h.extend(I.prototype,o,{interval:50,getHash:function(t){var e=(t||this).location.href.match(/#(.*)$/);return e?e[1]:""},getFragment:function(t,e){if(t==null){if(this._hasPushState||!this._wantsHashChange||e){t=this.location.pathname;var i=this.root.replace(C,"");if(!t.indexOf(i))t=t.substr(i.length)}else{t=this.getHash()}}return t.replace(N,"")},start:function(t){if(I.started)throw new Error("Backbone.history has already been started");I.started=true;this.options=h.extend({},{root:"/"},this.options,t);this.root=this.options.root;this._wantsHashChange=this.options.hashChange!==false;this._wantsPushState=!!this.options.pushState;this._hasPushState=!!(this.options.pushState&&this.history&&this.history.pushState);var e=this.getFragment();var i=document.documentMode;var r=O.exec(navigator.userAgent.toLowerCase())&&(!i||i<=7);this.root=("/"+this.root+"/").replace(P,"/");if(r&&this._wantsHashChange){this.iframe=a.$('