diff --git a/.gitignore b/.gitignore index 767c1df8..3737045f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,8 @@ /workbench.* /dist /templates +/var +*.iml +.idea/* +dump.rdb +problem_builder.tests.* diff --git a/AUTHORS b/AUTHORS index ad7de53c..096711dc 100644 --- a/AUTHORS +++ b/AUTHORS @@ -9,3 +9,4 @@ Alan Boudreault Eugeny Kolpakov Braden MacDonald Jonathan Piacenti +Tim Krones 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/README.md b/README.md index ec26fc3c..7dd87268 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,24 @@ -Problem Builder XBlock ----------------------- +Problem Builder and Step Builder +-------------------------------- [![Build Status](https://travis-ci.org/open-craft/problem-builder.svg?branch=master)](https://travis-ci.org/open-craft/problem-builder) -This XBlock allows creation of questions of various types and simulating the -workflow of real-life mentoring, within an edX course. +This repository provides two XBlocks: Problem Builder and Step Builder. -It supports: +Both blocks allow to create questions of various types. They can be +used to simulate the workflow of real-life mentoring, within an edX +course. + +Supported features include: * **Free-form answers** (textarea) which can be shared accross different XBlock instances (for example, to allow a student to - review and edit an answer he gave before). -* **Self-assessment MCQs** (multiple choice), to display predetermined - feedback to a student based on his choices in the + review and edit an answer they gave before). +* **Self-assessment MCQs** (multiple choice questions), to display + predetermined feedback to a student based on his choices in the self-assessment. Supports rating scales and arbitrary answers. * **MRQs (Multiple Response Questions)**, a type of multiple choice - question that allows the student to choose more than one choice. + question that allows the student to select more than one choice. * **Answer recaps** that display a read-only summary of a user's answer to a free-form question asked earlier in the course. * **Progression tracking**, to require that the student has @@ -26,15 +29,15 @@ It supports: * **Dashboards**, for displaying a summary of the student's answers to multiple choice questions. [Details](doc/Dashboard.md) -The screenshot shows an example of a problem builder block containing a -free-form question, two MCQs and one MRQ. +The following screenshot shows an example of a Problem Builder block +containing a free-form question, two MCQs and one MRQ: ![Problem Builder Example](doc/img/mentoring-example.png) Installation ------------ -Install the requirements into the python virtual environment of your +Install the requirements into the Python virtual environment of your `edx-platform` installation by running the following command from the root folder: @@ -45,14 +48,20 @@ $ pip install -r requirements.txt Enabling in Studio ------------------ -You can enable the Problem Builder XBlock in studio through the advanced -settings. +You can enable the Problem Builder and Step Builder XBlocks in Studio +by modifying the advanced settings for your course: + +1. From the main page of a specific course, navigate to **Settings** -> + **Advanced Settings** from the top menu. +2. Find the **Advanced Module List** setting. +3. To enable Problem Builder for your course, add `"problem-builder"` + to the modules listed there. +4. To enable Step Builder for your course, add `"step-builder"` to the + modules listed there. +5. Click the **Save changes** button. -1. From the main page of a specific course, navigate to `Settings -> - Advanced Settings` from the top menu. -2. Check for the `advanced_modules` policy key, and add `"problem-builder"` - to the policy value list. -3. Click the "Save changes" button. +Note that it is perfectly fine to enable both Problem Builder and Step +Builder for your course -- the blocks do not interfere with each other. Usage ----- diff --git a/doc/Usage.md b/doc/Usage.md index 3bf75868..c52cffda 100644 --- a/doc/Usage.md +++ b/doc/Usage.md @@ -1,23 +1,30 @@ -Mentoring Block Usage +Problem Builder Usage ===================== -When you add the `Problem Builder` component to a course in the studio, the -built-in editing tools guide you through the process of configuring the -block and adding individual questions. +When you add the **Problem Builder** component to a course in the +studio, the built-in editing tools guide you through the process of +configuring the block and adding individual questions. ### Problem Builder modes There are 2 mentoring modes available: -* *standard*: Traditional mentoring. All questions are displayed on the +* **standard**: Traditional mentoring. All questions are displayed on the page and submitted at the same time. The students get some tips and feedback about their answers. This is the default mode. -* *assessment*: Questions are displayed and submitted one by one. The +* **assessment**: Questions are displayed and submitted one by one. The students don't get tips or feedback, but only know if their answer was correct. Assessment mode comes with a default `max_attempts` of `2`. -Below are some LMS screenshots of a problem builder block in assessment mode. +**Note that assessment mode is deprecated**: In the future, Problem +Builder will only provide functionality that is currently part of +standard mode. Assessment mode will remain functional for a while to +ensure backward compatibility with courses that are currently using +it. If you want to use assessment functionality for a new course, +please use the Step Builder XBlock (described below). + +Below are some LMS screenshots of a Problem Builder block in assessment mode. Question before submitting an answer: @@ -35,9 +42,85 @@ Score review and the "Try Again" button: ![Assessment Step 4](img/assessment-4.png) -### Free-form Question -Free-form questions are represented by a "Long Answer" component. +Step Builder Usage +================== + +The Step Builder XBlock replaces assessment mode functionality of the +Problem Builder XBlock, while allowing to group questions into explict +steps: + +Instead of adding questions to Step Builder itself, you'll need to add +one or more **Mentoring Step** blocks to Step Builder. You can then +add one or more questions to each step. This allows you to group +questions into logical units (without being limited to showing only a +single question per step). As students progress through the block, +Step Builder will display one step at a time. All questions belonging +to a step need to be completed before the step can be submitted. + +In addition to regular steps, Step Builder also provides a **Review +Step** block which + +* allows students to review their performance + +* allows students to jump back to individual steps to review their + answers (if **Extended feedback** setting is on and maximum number + of attempts has been reached) + +* supports customizable messages that will be shown when + + * the block is *complete*, i.e., if all answers that the student + provided are correct + * the block is *incomplete*, i.e., if some answers that the student + provided are incorrect or partially correct + * the student has used up all attempts + +Note that only one such block is allowed per instance. + +**Screenshots: Step** + +Step with multiple questions (before submitting it): + +![Step with multiple questions, before submit](img/step-with-multiple-questions-before-submit.png) + +Step with multiple questions (after submitting it): + +![Step with multiple questions, after submit](img/step-with-multiple-questions-after-submit.png) + +As indicated by the orange check mark, this step is *partially* +correct (i.e., some answers are correct and some are incorrect or +partially correct). + +**Screenshots: Review Step** + +Unlimited attempts available, all answers correct: + +![Unlimited attempts available](img/review-step-unlimited-attempts-available.png) + +Limited attempts, some attempts remaining, some answers incorrect: + +![Some attempts remaining](img/review-step-some-attempts-remaining.png) + +Limited attempts, no attempts remaining, extended feedback off: + +![No attempts remaining, extended feedback off](img/review-step-no-attempts-remaining-extended-feedback-off.png) + +Limited attempts, no attempts remaining, extended feedback on: + +![No attempts remaining, extended feedback on](img/review-step-no-attempts-remaining-extended-feedback-on.png) + +**Screenshots: Step-level feedback** + +Reviewing performance for a single step: + +![Reviewing performance for single step](img/reviewing-performance-for-single-step.png) + +Question Types +============== + +### Free-form Questions + +Free-form questions are represented by a **Long Answer** component. Example screenshot before answering the question: @@ -47,39 +130,41 @@ Screenshot after answering the question: ![Answer Complete](img/answer-2.png) -You can add "Long Answer Recap" components to problem builder blocks later on -in the course to provide a read-only view of any answer that the student -entered earlier. +You can add **Long Answer Recap** components to problem builder blocks +later on in the course to provide a read-only view of any answer that +the student entered earlier. The read-only answer is rendered as a quote in the LMS: ![Answer Read-Only](img/answer-3.png) -### Multiple Choice Questions (MCQ) +### Multiple Choice Questions (MCQs) Multiple Choice Questions can be added to a problem builder component and have the following configurable options: -* Question - The question to ask the student -* Message - A feedback message to display to the student after they +* **Question** - The question to ask the student +* **Message** - A feedback message to display to the student after they have made their choice. -* Weight - The weight is used when computing total grade/score of +* **Weight** - The weight is used when computing total grade/score of the problem builder block. The larger the weight, the more influence this question will have on the grade. Value of zero means this question has no influence on the grade (float, defaults to `1`). -* Correct Choice - Specify which choice[s] is considered correct. If +* **Correct Choice[s]** - Specify which choice[s] are considered correct. If a student selects a choice that is not indicated as correct here, the student will get the question wrong. -Using the Studio editor, you can add "Custom Choice" blocks to the MCQ. -Each Custom Choice represents one of the options from which students -will choose their answer. +Using the Studio editor, you can add **Custom Choice** blocks to an +MCQ. Each Custom Choice represents one of the options from which +students will choose their answer. -You can also add "Tip" entries. Each "Tip" must be configured to link -it to one or more of the choices. If the student chooses a choice, the +You can also add **Tip** entries. Each Tip must be configured to link +it to one or more of the choices. If the student selects a choice, the +tip will be displayed. +**Screenshots** -Screenshot: Before attempting to answer the questions: +Before attempting to answer the questions: ![MCQ Initial](img/mcq-1.png) @@ -91,7 +176,7 @@ After successfully completing the questions: ![MCQ Success](img/mcq-3.png) -#### Rating MCQ +#### Rating Questions When constructing questions where the student rates some topic on the scale from `1` to `5` (e.g. a Likert Scale), you can use the Rating @@ -100,11 +185,10 @@ The `Low` and `High` settings specify the text shown next to the lowest and highest valued choice. Rating questions are a specialized type of MCQ, and the same -instructions apply. You can also still add "Custom Choice" components +instructions apply. You can also still add **Custom Choice** components if you want additional choices to be available such as "I don't know". - -### Self-assessment Multiple Response Questions (MRQ) +### Self-assessment Multiple Response Questions (MRQs) Multiple Response Questions are set up similarly to MCQs. The answers are rendered as checkboxes. Unlike MCQs where only a single answer can @@ -113,24 +197,26 @@ time. MRQ questions have these configurable settings: -* Question - The question to ask the student -* Required Choices - For any choices selected here, if the student +* **Question** - The question to ask the student +* **Required Choices** - For any choices selected here, if the student does *not* select that choice, they will lose marks. -* Ignored Choices - For any choices selected here, the student will +* **Ignored Choices** - For any choices selected here, the student will always be considered correct whether they choose this choice or not. * Message - A feedback message to display to the student after they have made their choice. -* Weight - The weight is used when computing total grade/score of +* **Weight** - The weight is used when computing total grade/score of the problem builder block. The larger the weight, the more influence this question will have on the grade. Value of zero means this question has no influence on the grade (float, defaults to `1`). -* Hide Result - If set to True, the feedback icons next to each - choice will not be displayed (This is false by default). +* **Hide Result** - If set to `True`, the feedback icons next to each + choice will not be displayed (This is `False` by default). -The "Custom Choice" and "Tip" components work the same way as they +The **Custom Choice** and **Tip** components work the same way as they do when used with MCQs (see above). -Screenshot - Before attempting to answer the questions: +**Screenshots** + +Before attempting to answer the questions: ![MRQ Initial](img/mrq-1.png) @@ -146,24 +232,33 @@ After successfully completing the questions: ![MRQ Success](img/mrq-4.png) +Other Components +================ + ### Tables -The problem builder table allows you to present answers to multiple -free-form questions in a concise way. Once you create an "Answer -Recap Table" inside a Mentoring component in Studio, you will be -able to add columns to the table. Each column has an optional -"Header" setting that you can use to add a header to that column. -Each column can contain one or more "Answer Recap" element, as -well as HTML components. +Tables allow you to present answers to multiple free-form questions in +a concise way. Once you create an **Answer Recap Table** inside a +Mentoring component in Studio, you will be able to add columns to the +table. Each column has an optional **Header** setting that you can use +to add a header to that column. Each column can contain one or more +**Answer Recap** elements, as well as HTML components. Screenshot: ![Table Screenshot](img/mentoring-table.png) +### "Dashboard" Self-Assessment Summary Block + +[Instructions for using the "Dashboard" Self-Assessment Summary Block](Dashboard.md) + +Configuration Options +==================== + ### Maximum Attempts -You can set the number of maximum attempts for the unit completion by -setting the Max. Attempts option of the Mentoring component. +You can limit the number of times students are allowed to complete a +Mentoring component by setting the **Max. attempts allowed** option. Before submitting an answer for the first time: @@ -173,12 +268,8 @@ After submitting a wrong answer two times: ![Max Attempts Reached](img/max-attempts-reached.png) -### Custom tip popup window size - -You can specify With and Height attributes of any Tip component to -customize the popup window size. The value of those attribute should -be valid CSS (e.g. `50px`). +### Custom Window Size for Tip Popups -### "Dashboard" Self-Assessment Summary Block - -[Instructions for using the "Dashboard" Self-Assessment Summary Block](Dashboard.md) +You can specify **Width** and **Height** attributes of any Tip +component to customize the popup window size. The value of those +attributes should be valid CSS (e.g. `50px`). 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/doc/img/review-step-no-attempts-remaining-extended-feedback-off.png b/doc/img/review-step-no-attempts-remaining-extended-feedback-off.png new file mode 100644 index 00000000..e20798fb Binary files /dev/null and b/doc/img/review-step-no-attempts-remaining-extended-feedback-off.png differ diff --git a/doc/img/review-step-no-attempts-remaining-extended-feedback-on.png b/doc/img/review-step-no-attempts-remaining-extended-feedback-on.png new file mode 100644 index 00000000..af823119 Binary files /dev/null and b/doc/img/review-step-no-attempts-remaining-extended-feedback-on.png differ diff --git a/doc/img/review-step-some-attempts-remaining.png b/doc/img/review-step-some-attempts-remaining.png new file mode 100644 index 00000000..8aaa4cf9 Binary files /dev/null and b/doc/img/review-step-some-attempts-remaining.png differ diff --git a/doc/img/review-step-unlimited-attempts-available.png b/doc/img/review-step-unlimited-attempts-available.png new file mode 100644 index 00000000..2ff45583 Binary files /dev/null and b/doc/img/review-step-unlimited-attempts-available.png differ diff --git a/doc/img/reviewing-performance-for-single-step.png b/doc/img/reviewing-performance-for-single-step.png new file mode 100644 index 00000000..acefb846 Binary files /dev/null and b/doc/img/reviewing-performance-for-single-step.png differ diff --git a/doc/img/step-with-multiple-questions-after-submit.png b/doc/img/step-with-multiple-questions-after-submit.png new file mode 100644 index 00000000..9d0c3dac Binary files /dev/null and b/doc/img/step-with-multiple-questions-after-submit.png differ diff --git a/doc/img/step-with-multiple-questions-before-submit.png b/doc/img/step-with-multiple-questions-before-submit.png new file mode 100644 index 00000000..51e8b20a Binary files /dev/null and b/doc/img/step-with-multiple-questions-before-submit.png differ diff --git a/problem_builder/answer.py b/problem_builder/answer.py index f09016d5..d2e15d9c 100644 --- a/problem_builder/answer.py +++ b/problem_builder/answer.py @@ -30,8 +30,9 @@ from xblock.fragment import Fragment from xblock.validation import ValidationMessage from xblockutils.resources import ResourceLoader -from xblockutils.studio_editable import StudioEditableXBlockMixin -from .step import StepMixin +from xblockutils.studio_editable import StudioEditableXBlockMixin, XBlockWithPreviewMixin +from problem_builder.sub_api import SubmittingXBlockMixin, sub_api +from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin import uuid @@ -48,7 +49,7 @@ def _(text): # Classes ########################################################### -class AnswerMixin(object): +class AnswerMixin(XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin): """ Mixin to give an XBlock the ability to read/write data to the Answers DB table. """ @@ -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. @@ -96,19 +114,19 @@ def add_error(msg): if not data.name: add_error(u"A Question ID is required.") - def _(self, text): - """ translate text """ - return self.runtime.service(self, "i18n").ugettext(text) - @XBlock.needs("i18n") -class AnswerBlock(AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock): +class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, QuestionMixin, StudioEditableXBlockMixin, XBlock): """ A field where the student enters an answer Must be included as a child of a mentoring block. Answers are persisted as django model instances to make them searchable and referenceable across xblocks. """ + CATEGORY = 'pb-answer' + STUDIO_LABEL = _(u"Long Answer") + answerable = True + name = String( display_name=_("Question ID (name)"), help=_("The ID of this block. Should be unique unless you want the answer to be used in multiple places."), @@ -131,14 +149,8 @@ class AnswerBlock(AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock): display_name=_("Question"), help=_("Question to ask the student"), scope=Scope.content, - default="" - ) - weight = Float( - display_name=_("Weight"), - help=_("Defines the maximum total grade of the answer block."), - default=1, - scope=Scope.settings, - enforce_type=True + default="", + multiline_editor=True, ) editable_fields = ('question', 'name', 'min_characters', 'weight', 'default_from', 'display_name', 'show_title') @@ -164,7 +176,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 +191,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 +210,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): @@ -239,6 +266,9 @@ class AnswerRecapBlock(AnswerMixin, StudioEditableXBlockMixin, XBlock): """ A block that displays an answer previously entered by the student (read-only). """ + CATEGORY = 'pb-answer-recap' + STUDIO_LABEL = _(u"Long Answer Recap") + name = String( display_name=_("Question ID"), help=_("The ID of the question for which to display the student's answer."), @@ -258,22 +288,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/choice.py b/problem_builder/choice.py index 69a3525e..f9ac3262 100644 --- a/problem_builder/choice.py +++ b/problem_builder/choice.py @@ -27,7 +27,9 @@ from xblock.fields import Scope, String from xblock.fragment import Fragment from xblock.validation import ValidationMessage -from xblockutils.studio_editable import StudioEditableXBlockMixin +from xblockutils.studio_editable import StudioEditableXBlockMixin, XBlockWithPreviewMixin + +from problem_builder.mixins import XBlockWithTranslationServiceMixin # Make '_' a no-op so we can scrape strings @@ -38,7 +40,7 @@ def _(text): @XBlock.needs("i18n") -class ChoiceBlock(StudioEditableXBlockMixin, XBlock): +class ChoiceBlock(StudioEditableXBlockMixin, XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin, XBlock): """ Custom choice of an answer for a MCQ/MRQ """ @@ -56,10 +58,6 @@ class ChoiceBlock(StudioEditableXBlockMixin, XBlock): ) editable_fields = ('content', 'value') - def _(self, text): - """ translate text """ - return self.runtime.service(self, "i18n").ugettext(text) - @property def display_name_with_default(self): try: 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..4b3cb255 --- /dev/null +++ b/problem_builder/instructor_tool.py @@ -0,0 +1,359 @@ +# -*- 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', + } + + flat_block_tree = self._build_course_tree() + + 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 + + def _build_course_tree(self): + """ + Return flat tree of blocks belonging to this block's parent course. + """ + 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 `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", + "eligible": False, + } + 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]) + + return flat_block_tree + + @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) + usernames = data.get('usernames', 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 usernames: + user_ids = None + else: + user_ids = [] + for username in usernames.split(','): + username = username.strip() + user_id = user_service.get_anonymous_user_id(username, unicode(self.runtime.course_id)) + if user_id: + user_ids.append(user_id) + if not user_ids: + self.raise_error(404, _("Could not find any of the specified usernames.")) + + 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_ids, + 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..c2f935cf 100644 --- a/problem_builder/mcq.py +++ b/problem_builder/mcq.py @@ -48,6 +48,9 @@ class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock): """ An XBlock used to ask multiple-choice questions """ + CATEGORY = 'pb-mcq' + STUDIO_LABEL = _(u"Multiple Choice Question") + student_choice = String( # {Last input submitted by the student default="", @@ -74,15 +77,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 +97,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): @@ -149,6 +161,9 @@ class RatingBlock(MCQBlock): """ An XBlock used to rate something on a five-point scale, e.g. Likert Scale """ + CATEGORY = 'pb-rating' + STUDIO_LABEL = _(u"Rating Question") + low = String( display_name=_("Low"), help=_("Label for low ratings"), @@ -170,7 +185,7 @@ class RatingBlock(MCQBlock): list_values_provider=QuestionnaireAbstractBlock.choice_values_provider, list_style='set', # Underered, unique items. Affects the UI editor. ) - editable_fields = MCQBlock.editable_fields + ('low', 'high') + editable_fields = MCQBlock.editable_fields + ('low', 'high', 'name') @property def all_choice_values(self): @@ -183,7 +198,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 +211,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 5c9e8d1f..8024d8a7 100644 --- a/problem_builder/mentoring.py +++ b/problem_builder/mentoring.py @@ -21,20 +21,31 @@ # Imports ########################################################### import logging +import json from collections import namedtuple +from itertools import chain + +from lazy.lazy import lazy 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 from .message import MentoringMessageBlock -from .step import StepParentMixin, StepMixin +from .mixins import ( + _normalize_id, QuestionMixin, MessageParentMixin, StepParentMixin, XBlockWithTranslationServiceMixin +) + +from xblockutils.helpers import child_isinstance from xblockutils.resources import ResourceLoader -from xblockutils.studio_editable import StudioEditableXBlockMixin, StudioContainerXBlockMixin +from xblockutils.studio_editable import ( + StudioEditableXBlockMixin, StudioContainerXBlockMixin, StudioContainerWithNestedXBlocksMixin +) + try: # Used to detect if we're in the workbench so we can add Font Awesome @@ -61,10 +72,150 @@ def _(text): Score = namedtuple("Score", ["raw", "percentage", "correct", "incorrect", "partially_correct"]) +CORRECT = 'correct' +INCORRECT = 'incorrect' +PARTIAL = 'partial' + @XBlock.needs("i18n") @XBlock.wants('settings') -class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioContainerXBlockMixin): +class BaseMentoringBlock( + XBlock, XBlockWithTranslationServiceMixin, StudioEditableXBlockMixin, MessageParentMixin +): + """ + An XBlock that defines functionality shared by mentoring blocks. + """ + # Content + show_title = Boolean( + display_name=_("Show title"), + help=_("Display the title?"), + default=True, + scope=Scope.content + ) + max_attempts = Integer( + display_name=_("Max. attempts allowed"), + help=_("Maximum number of times students are allowed to attempt the questions belonging to this block"), + default=0, + scope=Scope.content, + enforce_type=True + ) + + # User state + num_attempts = Integer( + # Number of attempts a user has answered for this questions + default=0, + scope=Scope.user_state, + enforce_type=True + ) + + has_children = True + + icon_class = 'problem' + block_settings_key = 'mentoring' + theme_key = 'theme' + + @property + def url_name(self): + """ + Get the url_name for this block. In Studio/LMS it is provided by a mixin, so we just + defer to super(). In the workbench or any other platform, we use the usage_id. + """ + try: + return super(BaseMentoringBlock, self).url_name + except AttributeError: + return unicode(self.scope_ids.usage_id) + + @property + def review_tips_json(self): + return json.dumps(self.review_tips) + + @property + def max_attempts_reached(self): + return self.max_attempts > 0 and self.num_attempts >= self.max_attempts + + 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 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?) + """ + has_explicitly_set_title = self.fields['display_name'].is_set_on(self) + if has_explicitly_set_title: + return [self.display_name] + return [] + + def get_theme(self): + """ + Gets theme settings from settings service. Falls back to default (LMS) theme + if settings service is not available, xblock theme settings are not set or does + contain mentoring theme settings. + """ + settings_service = self.runtime.service(self, "settings") + if settings_service: + xblock_settings = settings_service.get_settings_bucket(self) + if xblock_settings and self.theme_key in xblock_settings: + return xblock_settings[self.theme_key] + return _default_theme_config + + def include_theme_files(self, fragment): + theme = self.get_theme() + theme_package, theme_files = theme['package'], theme['locations'] + for theme_file in theme_files: + fragment.add_css(ResourceLoader(theme_package).load_unicode(theme_file)) + + 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 view(self, data, suffix=''): + """ + Current HTML view of the XBlock, for refresh by client + """ + frag = self.student_view({}) + return {'html': frag.content} + + @XBlock.json_handler + def publish_event(self, data, suffix=''): + """ + Publish data for analytics purposes + """ + event_type = data.pop('event_type') + self.runtime.publish(self, event_type, data) + + return {'result': 'ok'} + + def author_preview_view(self, context): + """ + Child blocks can override this to add a custom preview shown to + authors in Studio when not editing this block's children. + """ + fragment = self.student_view(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/problem-builder-edit.css')) + self.include_theme_files(fragment) + return fragment + + +class MentoringBlock(BaseMentoringBlock, StudioContainerXBlockMixin, StepParentMixin): """ An XBlock providing mentoring capabilities @@ -88,13 +239,6 @@ class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioC default=None, scope=Scope.content ) - max_attempts = Integer( - display_name=_("Max. Attempts Allowed"), - help=_("Number of max attempts allowed for this questions"), - default=0, - scope=Scope.content, - enforce_type=True - ) enforce_dependency = Boolean( display_name=_("Enforce Dependency"), help=_("Should the next step be the current block to complete?"), @@ -128,7 +272,7 @@ class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioC display_name = String( display_name=_("Title (Display name)"), help=_("Title to display"), - default=_("Mentoring Questions"), + default=_("Problem Builder"), scope=Scope.settings ) feedback_label = String( @@ -143,18 +287,13 @@ 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? default=False, scope=Scope.user_state ) - num_attempts = Integer( - # Number of attempts a user has answered for this questions - default=0, - scope=Scope.user_state, - enforce_type=True - ) step = Integer( # Keep track of the student assessment progress. default=0, @@ -166,6 +305,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( @@ -175,49 +319,61 @@ class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioC ) editable_fields = ( - 'display_name', 'mode', 'followed_by', 'max_attempts', 'enforce_dependency', - 'display_submit', 'feedback_label', 'weight', + 'display_name', 'followed_by', 'max_attempts', 'enforce_dependency', + 'display_submit', 'feedback_label', 'weight', 'extended_feedback' ) - icon_class = 'problem' - has_score = True - has_children = True - - block_settings_key = 'mentoring' - theme_key = 'theme' - def _(self, text): - """ translate text """ - return self.runtime.service(self, "i18n").ugettext(text) + has_score = True @property def is_assessment(self): """ Checks if mentoring XBlock is in assessment mode """ return self.mode == 'assessment' - def get_theme(self): + def get_question_number(self, question_id): """ - Gets theme settings from settings service. Falls back to default (LMS) theme - if settings service is not available, xblock theme settings are not set or does - contain mentoring theme settings. + Get the step number of the question id """ - settings_service = self.runtime.service(self, "settings") - if settings_service: - xblock_settings = settings_service.get_settings_bucket(self) - if xblock_settings and self.theme_key in xblock_settings: - return xblock_settings[self.theme_key] - return _default_theme_config + for child_id in self.children: + question = self.runtime.get_block(child_id) + if isinstance(question, QuestionMixin) 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.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) @@ -225,16 +381,15 @@ def max_score(self): """ Maximum score. We scale all scores to a maximum of 1.0 so this is always 1.0 """ return 1.0 - def include_theme_files(self, fragment): - theme = self.get_theme() - theme_package, theme_files = theme['package'], theme['locations'] - for theme_file in theme_files: - fragment.add_css(ResourceLoader(theme_package).load_unicode(theme_file)) - 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"" @@ -244,7 +399,7 @@ def student_view(self, context): child_content += u"

[{}]

".format(self._(u"Error: Unable to load child component.")) elif not isinstance(child, MentoringMessageBlock): try: - if self.is_assessment and isinstance(child, StepMixin): + if self.is_assessment and isinstance(child, QuestionMixin): child_fragment = child.render('assessment_step_view', context) else: child_fragment = child.render('mentoring_view', context) @@ -261,16 +416,21 @@ 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") + if self.is_assessment: + fragment.add_resource( + loader.load_unicode('templates/html/mentoring_assessment_templates.html'), "text/html" + ) self.include_theme_files(fragment) # Workbench doesn't have font awesome, so add it: @@ -318,47 +478,152 @@ def next_step_url(self): """ return '/jump_to_id/{}'.format(self.next_step) + 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_content('completed') + elif self.max_attempts_reached: + # Student has not achieved a perfect score and cannot try again + return self.get_message_content('max_attempts_reached') + else: + # Student did not achieve a perfect score but can try again: + return self.get_message_content('incomplete') + @property - def url_name(self): + def assessment_message(self): """ - Get the url_name for this block. In Studio/LMS it is provided by a mixin, so we just - defer to super(). In the workbench or any other platform, we use the usage_id. + Get the message to display to a student following a submission in assessment mode. """ - try: - return super(MentoringBlock, self).url_name - except AttributeError: - return unicode(self.scope_ids.usage_id) + if not self.max_attempts_reached: + return self.get_message_content('on-assessment-review', or_default=True) + else: + return None + + @property + def review_tips(self): + """ Get review tips, shown for wrong answers in assessment mode. """ + if not self.is_assessment or self.step != len(self.step_ids): + return [] # Review tips are only used in assessment mode, and only on the last step. + review_tips = [] + status_cache = dict(self.student_results) + for child in self.steps: + result = status_cache.get(child.name) + if result and result.get('status') != 'correct': + # The student got this wrong. Check if there is a review tip to show. + tip_html = child.get_review_tip() + if tip_html: + if hasattr(self.runtime, 'replace_jump_to_id_urls'): + tip_html = self.runtime.replace_jump_to_id_urls(tip_html) + review_tips.append(tip_html) + return review_tips + + def show_extended_feedback(self): + return self.extended_feedback and self.max_attempts_reached @XBlock.json_handler - def view(self, data, suffix=''): + def get_results(self, queries, suffix=''): """ - Current HTML view of the XBlock, for refresh by client + 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. """ - frag = self.student_view({}) - return {'html': frag.content} + 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.' + } - @XBlock.json_handler - def publish_event(self, data, suffix=''): + 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): """ - Publish data for analytics purposes + Gets previous submissions results as if submit was called with exactly the same values as last time. """ - event_type = data.pop('event_type') - self.runtime.publish(self, event_type, data) + results = [] + completed = True + show_message = bool(self.student_results) - return {'result': 'ok'} + # In standard mode, all children is visible simultaneously, so need collecting responses from all of them + for child in self.steps: + 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 in self.steps: + 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) + for child in self.steps: if child.name and child.name in submissions: submission = submissions[child.name] child_result = child.submit(submission) @@ -366,45 +631,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', { @@ -414,12 +666,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): @@ -427,7 +678,10 @@ def handle_assessment_submit(self, submissions, suffix): current_child = None 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 + # The following is faster than the self.step_ids property + steps = [child for child in children if isinstance(child, QuestionMixin)] + assessment_message = None + review_tips = [] for child in children: if child.name and child.name in submissions: @@ -456,13 +710,14 @@ 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 + review_tips = self.review_tips self.num_attempts += 1 self.completed = True @@ -475,14 +730,19 @@ 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, + 'assessment_review_tips': review_tips, } @XBlock.json_handler @@ -505,18 +765,6 @@ def try_again(self, data, suffix=''): 'result': 'success' } - @property - def max_attempts_reached(self): - return self.max_attempts > 0 and self.num_attempts >= self.max_attempts - - def get_message_html(self, message_type): - html = u"" - for child_id in self.children: - child = self.runtime.get_block(child_id) - if isinstance(child, MentoringMessageBlock) and child.type == message_type: - html += child.render('mentoring_view', {}).content # TODO: frament_text_rewriting ? - return html - def validate(self): """ Validates the state of this XBlock except for individual field values. @@ -545,45 +793,300 @@ def validate(self): )) return validation - def author_preview_view(self, context): - """ - Child blocks can override this to add a custom preview shown to authors in Studio when - not editing this block's children. - """ - fragment = self.student_view(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')) - self.include_theme_files(fragment) - return fragment - 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 - 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. - Otherwise things like "Choice (yes) (Correct)" will appear in the tooltip. - """ - return [self.display_name] - @staticmethod def workbench_scenarios(): """ Scenarios displayed by the workbench. Load them from external (private) repository """ return loader.load_scenarios_from_path('templates/xml') + + +class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNestedXBlocksMixin): + """ + An XBlock providing mentoring capabilities with explicit steps + """ + # Content + extended_feedback = Boolean( + display_name=_("Extended feedback"), + help=_("Show extended feedback when all attempts are used up?"), + default=False, + Scope=Scope.content + ) + + # Settings + display_name = String( + display_name=_("Title (display name)"), + help=_("Title to display"), + default=_("Step Builder"), + scope=Scope.settings + ) + + # User state + active_step = Integer( + # Keep track of the student progress. + default=0, + scope=Scope.user_state, + enforce_type=True + ) + + editable_fields = ('display_name', 'max_attempts', 'extended_feedback') + + @lazy + def question_ids(self): + """ + Get the usage_ids of all of this XBlock's children that are "Questions". + """ + return list(chain.from_iterable(self.runtime.get_block(step_id).step_ids for step_id in self.step_ids)) + + @lazy + def questions(self): + """ + Get all questions associated with this block. + """ + return [self.runtime.get_block(question_id) for question_id in self.question_ids] + + @lazy + def step_ids(self): + """ + Get the usage_ids of all of this XBlock's children that are steps. + """ + from .step import MentoringStepBlock # Import here to avoid circular dependency + return [ + _normalize_id(child_id) for child_id in self.children if + child_isinstance(self, child_id, MentoringStepBlock) + ] + + @lazy + def steps(self): + """ + Get the step children of this block. + """ + return [self.runtime.get_block(step_id) for step_id in self.step_ids] + + def get_question_number(self, question_name): + question_names = [q.name for q in self.questions] + return question_names.index(question_name) + 1 + + def answer_mapper(self, answer_status): + steps = self.steps + answer_map = [] + for step in steps: + for answer in step.student_results: + if answer[1]['status'] == answer_status: + answer_map.append({ + 'id': answer[0], + 'details': answer[1], + 'step': step.step_number, + 'number': self.get_question_number(answer[0]), + }) + return answer_map + + @property + def has_review_step(self): + from .step import ReviewStepBlock + return any(child_isinstance(self, child_id, ReviewStepBlock) for child_id in self.children) + + @property + def score(self): + questions = self.questions + total_child_weight = sum(float(question.weight) for question in questions) + if total_child_weight == 0: + return Score(0, 0, [], [], []) + steps = self.steps + questions_map = {question.name: question for question in questions} + points_earned = 0 + for step in steps: + for question_name, question_results in step.student_results: + question = questions_map.get(question_name) + if question: # Under what conditions would this evaluate to False? + points_earned += question_results['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) + + @property + def complete(self): + return not self.score.incorrect and not self.score.partially_correct + + @property + def review_tips(self): + """ Get review tips, shown for wrong answers. """ + review_tips = [] + status_cache = dict() + steps = self.steps + for step in steps: + status_cache.update(dict(step.student_results)) + for question in self.questions: + result = status_cache.get(question.name) + if result and result.get('status') != 'correct': + # The student got this wrong. Check if there is a review tip to show. + tip_html = question.get_review_tip() + if tip_html: + if hasattr(self.runtime, 'replace_jump_to_id_urls'): + tip_html = self.runtime.replace_jump_to_id_urls(tip_html) + review_tips.append(tip_html) + return review_tips + + def show_extended_feedback(self): + return self.extended_feedback + + def student_view(self, context): + fragment = Fragment() + children_contents = [] + + for child_id in self.children: + child = self.runtime.get_block(child_id) + if child is None: # child should not be None but it can happen due to bugs or permission issues + child_content = u"

[{}]

".format(self._(u"Error: Unable to load child component.")) + elif not isinstance(child, MentoringMessageBlock): + child_fragment = self._render_child_fragment(child, context, view='mentoring_view') + fragment.add_frag_resources(child_fragment) + child_content = child_fragment.content + children_contents.append(child_content) + + fragment.add_content(loader.render_template('templates/html/mentoring_with_steps.html', { + 'self': self, + 'title': self.display_name, + 'show_title': self.show_title, + 'children_contents': children_contents, + })) + 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/mentoring_with_steps.js')) + + fragment.add_resource(loader.load_unicode('templates/html/mentoring_attempts.html'), "text/html") + fragment.add_resource(loader.load_unicode('templates/html/mentoring_review_templates.html'), "text/html") + + self.include_theme_files(fragment) + + fragment.initialize_js('MentoringWithStepsBlock') + + return fragment + + @property + def allowed_nested_blocks(self): + """ + Returns a list of allowed nested XBlocks. Each item can be either + * An XBlock class + * A NestedXBlockSpec + + If XBlock class is used it is assumed that this XBlock is enabled and allows multiple instances. + NestedXBlockSpec allows explicitly setting disabled/enabled state, disabled reason (if any) and single/multiple + instances + """ + from .step import MentoringStepBlock, ReviewStepBlock # Import here to avoid circular dependency + return [ + MentoringStepBlock, + ReviewStepBlock, + ] + + @XBlock.json_handler + def update_active_step(self, new_value, suffix=''): + if new_value < len(self.step_ids): + self.active_step = new_value + elif new_value == len(self.step_ids): + if self.has_review_step: + self.active_step = -1 + return { + 'active_step': self.active_step + } + + @XBlock.json_handler + def update_num_attempts(self, data, suffix=''): + if self.num_attempts < self.max_attempts: + self.num_attempts += 1 + return { + 'num_attempts': self.num_attempts + } + + @XBlock.json_handler + def publish_attempt(self, data, suffix): + score = self.score + grade_data = { + 'value': score.raw, + 'max_value': 1, + } + self.runtime.publish(self, 'grade', grade_data) + return {} + + @XBlock.json_handler + def get_grade(self, data, suffix): + score = self.score + return { + 'score': score.percentage, + 'correct_answers': len(score.correct), + 'incorrect_answers': len(score.incorrect), + 'partially_correct_answers': len(score.partially_correct), + 'correct': self.correct_json(stringify=False), + 'incorrect': self.incorrect_json(stringify=False), + 'partial': self.partial_json(stringify=False), + 'complete': self.complete, + 'max_attempts_reached': self.max_attempts_reached, + 'assessment_review_tips': self.review_tips, + } + + @XBlock.json_handler + def get_num_attempts(self, data, suffix): + return { + 'num_attempts': self.num_attempts + } + + @XBlock.json_handler + def try_again(self, data, suffix=''): + self.active_step = 0 + + step_blocks = [self.runtime.get_block(child_id) for child_id in self.step_ids] + + for step in step_blocks: + step.reset() + + return { + 'active_step': self.active_step + } + + def author_preview_view(self, context): + context = context.copy() if context else {} + context['author_preview_view'] = True + return super(MentoringWithExplicitStepsBlock, self).author_preview_view(context) + + def author_edit_view(self, context): + """ + Add some HTML to the author view that allows authors to add child blocks. + """ + context['wrap_children'] = { + 'head': u'
', + 'tail': u'
' + } + fragment = super(MentoringWithExplicitStepsBlock, self).author_edit_view(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/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_with_steps_edit.js')) + fragment.initialize_js('MentoringWithStepsEdit') + return fragment diff --git a/problem_builder/message.py b/problem_builder/message.py index d57d52d2..4454c8be 100644 --- a/problem_builder/message.py +++ b/problem_builder/message.py @@ -26,6 +26,8 @@ from xblock.fragment import Fragment from xblockutils.studio_editable import StudioEditableXBlockMixin +from problem_builder.mixins import XBlockWithTranslationServiceMixin + # Make '_' a no-op so we can scrape strings def _(text): @@ -35,11 +37,83 @@ def _(text): @XBlock.needs("i18n") -class MentoringMessageBlock(XBlock, StudioEditableXBlockMixin): +class MentoringMessageBlock(XBlock, StudioEditableXBlockMixin, XBlockWithTranslationServiceMixin): """ 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"This message will be shown when the student achieves a perfect score. " + "Note that it is ignored in Problem Builder blocks using the legacy 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"This message will be shown when the student gets at least one question wrong, " + "but is allowed to try again. " + "Note that it is ignored in Problem Builder blocks using the legacy 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"This message will be shown when the student has used up " + "all of their allowed attempts without achieving a perfect score. " + "Note that it is ignored in Problem Builder blocks using the legacy 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"Note: if you retake this assessment, only your final score counts. " + "If you would like to keep this score, please continue to the next unit." + ), + "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." + ), + }, + "on-assessment-review-question": { + "display_name": _(u"Study tips if this question was wrong"), + "long_display_name": _(u"Study tips shown during assessment review if wrong"), + "default": _( + u"Review ____." + ), + "description": _( + u"In assessment mode, this message will be shown when the student is reviewing " + "their answers to the assessment, if the student got this specific question " + "wrong and 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." + ), + }, + "on-review": { + "display_name": _(u"Message shown when no attempts left"), + "long_display_name": _(u"Message shown during review when no attempts remain"), + "default": _( + u"Note: you have used all attempts. Continue to the next unit." + ), + "description": _( + u"This message will be shown when the student is reviewing their answers to the assessment, " + "if the student has used up all of their allowed attempts. " + "It is not shown if the student is allowed to try again." + ), + }, + } + content = String( display_name=_("Message"), help=_("Message to display upon completion"), @@ -53,45 +127,58 @@ 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"]}, + { + "value": "on-assessment-review-question", + "display_name": MESSAGE_TYPES["on-assessment-review-question"]["display_name"] + }, ), ) editable_fields = ("content", ) - def _(self, text): - """ translate text """ - return self.runtime.service(self, "i18n").ugettext(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,8 +187,24 @@ 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') return block + + +class CompletedMentoringMessageShim(object): + CATEGORY = 'pb-message' + STUDIO_LABEL = _("Message (Complete)") + + +class IncompleteMentoringMessageShim(object): + CATEGORY = 'pb-message' + STUDIO_LABEL = _("Message (Incomplete)") + + +class OnReviewMentoringMessageShim(object): + CATEGORY = 'pb-message' + STUDIO_LABEL = _("Message (Review)") 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/mixins.py b/problem_builder/mixins.py new file mode 100644 index 00000000..cdcafd85 --- /dev/null +++ b/problem_builder/mixins.py @@ -0,0 +1,169 @@ +from lazy import lazy +from xblock.fields import String, Boolean, Float, Scope, UNIQUE_ID +from xblockutils.helpers import child_isinstance +from xblockutils.resources import ResourceLoader + + +loader = ResourceLoader(__name__) + + +# Make '_' a no-op so we can scrape strings +def _(text): + return text + + +def _normalize_id(key): + """ + Helper method to normalize a key to avoid issues where some keys have version/branch and others don't. + e.g. self.scope_ids.usage_id != self.runtime.get_block(self.scope_ids.usage_id).scope_ids.usage_id + """ + if hasattr(key, "for_branch"): + key = key.for_branch(None) + if hasattr(key, "for_version"): + key = key.for_version(None) + return key + + +class XBlockWithTranslationServiceMixin(object): + """ + Mixin providing access to i18n service + """ + def _(self, text): + """ Translate text """ + return self.runtime.service(self, "i18n").ugettext(text) + + +class EnumerableChildMixin(XBlockWithTranslationServiceMixin): + CAPTION = _(u"Child") + + show_title = Boolean( + display_name=_("Show title"), + help=_("Display the title?"), + default=True, + scope=Scope.content + ) + + @lazy + def siblings(self): + # TODO: It might make sense to provide a default + # implementation here that just returns normalized ID's of the + # parent's children. + raise NotImplementedError("Should be overridden in child class") + + @lazy + def step_number(self): + return list(self.siblings).index(_normalize_id(self.scope_ids.usage_id)) + 1 + + @lazy + def lonely_child(self): + if _normalize_id(self.scope_ids.usage_id) not in self.siblings: + message = u"{child_caption}'s parent should contain {child_caption}".format(child_caption=self.CAPTION) + raise ValueError(message, self, self.siblings) + return len(self.siblings) == 1 + + @property + def display_name_with_default(self): + """ Get the title/display_name of this question. """ + if self.display_name: + return self.display_name + if not self.lonely_child: + return self._(u"{child_caption} {number}").format( + child_caption=self.CAPTION, number=self.step_number + ) + return self._(self.CAPTION) + + +class StepParentMixin(object): + """ + An XBlock mixin for a parent block containing Step children + """ + + @lazy + def step_ids(self): + """ + Get the usage_ids of all of this XBlock's children that are "Steps" + """ + return [ + _normalize_id(child_id) for child_id in self.children if child_isinstance(self, child_id, QuestionMixin) + ] + + @lazy + def steps(self): + """ Get the step children of this block, cached if possible. """ + return [self.runtime.get_block(child_id) for child_id in self.step_ids] + + +class MessageParentMixin(object): + """ + An XBlock mixin for a parent block containing MentoringMessageBlock children + """ + + def get_message_content(self, message_type, or_default=False): + from problem_builder.message import MentoringMessageBlock # Import here to avoid circular dependency + for child_id in self.children: + if child_isinstance(self, child_id, MentoringMessageBlock): + child = self.runtime.get_block(child_id) + if child.type == message_type: + content = child.content + if hasattr(self.runtime, 'replace_jump_to_id_urls'): + content = self.runtime.replace_jump_to_id_urls(content) + return content + if or_default: + # Return the default value since no custom message is set. + # Note the WYSIWYG editor usually wraps the .content HTML in a

tag so we do the same here. + return '

{}

'.format(MentoringMessageBlock.MESSAGE_TYPES[message_type]['default']) + + +class QuestionMixin(EnumerableChildMixin): + """ + An XBlock mixin for a child block that is a "Step". + + A step is a question that the user can answer (as opposed to a read-only child). + """ + CAPTION = _(u"Question") + + has_author_view = True + + # Fields: + name = String( + display_name=_("Question ID (name)"), + help=_("The ID of this question (required). Should be unique within this mentoring component."), + default=UNIQUE_ID, + scope=Scope.settings, # Must be scope.settings, or the unique ID will change every time this block is edited + ) + display_name = String( + display_name=_("Question title"), + help=_('Leave blank to use the default ("Question 1", "Question 2", etc.)'), + default="", # Blank will use 'Question x' - see display_name_with_default + scope=Scope.content + ) + weight = Float( + display_name=_("Weight"), + help=_("Defines the maximum total grade of this question."), + default=1, + scope=Scope.content, + enforce_type=True + ) + + @lazy + def siblings(self): + return self.get_parent().step_ids + + def author_view(self, context): + context = context.copy() if context else {} + context['hide_header'] = True + return self.mentoring_view(context) + + def author_preview_view(self, context): + context = context.copy() if context else {} + context['hide_header'] = True + return self.student_view(context) + + def assessment_step_view(self, context=None): + """ + assessment_step_view is the same as mentoring_view, except its DIV will have a different + class (.xblock-v1-assessment_step_view) that we use for assessments to hide all the + steps with CSS and to detect which children of mentoring are "Steps" and which are just + decorative elements/instructions. + """ + return self.mentoring_view(context) 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..d7461166 100644 --- a/problem_builder/mrq.py +++ b/problem_builder/mrq.py @@ -44,6 +44,9 @@ class MRQBlock(QuestionnaireAbstractBlock): """ An XBlock used to ask multiple-response questions """ + CATEGORY = 'pb-mrq' + STUDIO_LABEL = _(u"Multiple Response Question") + student_choices = List( # Last submissions by the student default=[], @@ -81,16 +84,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 +131,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 +153,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/plot.py b/problem_builder/plot.py new file mode 100644 index 00000000..d332c3a1 --- /dev/null +++ b/problem_builder/plot.py @@ -0,0 +1,268 @@ +# -*- 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 . +# + +import json +import logging + +from lazy.lazy import lazy + +from xblock.core import XBlock +from xblock.fields import String, Scope +from xblock.fragment import Fragment +from xblockutils.resources import ResourceLoader +from xblockutils.studio_editable import ( + StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin, XBlockWithPreviewMixin +) +from .sub_api import sub_api + + +loader = ResourceLoader(__name__) + +log = logging.getLogger(__name__) + + +# Make '_' a no-op so we can scrape strings +def _(text): + return text + + +def _normalize_id(key): + """ + Helper method to normalize a key to avoid issues where some keys have version/branch and others don't. + e.g. self.scope_ids.usage_id != self.runtime.get_block(self.scope_ids.usage_id).scope_ids.usage_id + """ + if hasattr(key, "for_branch"): + key = key.for_branch(None) + if hasattr(key, "for_version"): + key = key.for_version(None) + return key + + +@XBlock.needs('i18n') +@XBlock.wants('user') +class PlotBlock(StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin, XBlockWithPreviewMixin, XBlock): + """ + XBlock that displays plot that summarizes answers to scale questions. + """ + + CATEGORY = 'sb-plot' + STUDIO_LABEL = _(u"Plot") + + # Settings + display_name = String( + display_name=_("Plot title"), + default="Plot", + scope=Scope.content + ) + + plot_label = String( + display_name=_("Plot label"), + help=_("Label for default overlay that shows student's answers to scale questions"), + default="yours", + scope=Scope.content + ) + + point_color_default = String( + display_name=_("Point color (default overlay)"), + help=_("Point color to use for default overlay"), + default="green", + scope=Scope.content + ) + + point_color_average = String( + display_name=_("Point color (average overlay)"), + help=_("Point color to use for average overlay"), + default="blue", + scope=Scope.content + ) + + q1_label = String( + display_name=_("Quadrant I"), + help=_( + "Label for the first quadrant. " + "Plot uses counter-clockwise numbering starting in the top right quadrant." + ), + default="Q1", + scope=Scope.content + ) + + q2_label = String( + display_name=_("Quadrant II"), + help=_( + "Label for the second quadrant. " + "Plot uses counter-clockwise numbering starting in the top right quadrant." + ), + default="Q2", + scope=Scope.content + ) + + q3_label = String( + display_name=_("Quadrant III"), + help=_( + "Label for the third quadrant. " + "Plot uses counter-clockwise numbering starting in the top right quadrant." + ), + default="Q3", + scope=Scope.content + ) + + q4_label = String( + display_name=_("Quadrant IV"), + help=_( + "Label for the fourth quadrant. " + "Plot uses counter-clockwise numbering starting in the top right quadrant." + ), + default="Q4", + scope=Scope.content + ) + + claims = String( + display_name=_("Claims and associated questions"), + help=_( + 'Claims and questions that should be included in the plot. ' + 'Each line defines a triple of the form "claim, q1, q2", ' + 'where "claim" is arbitrary text that represents a claim, ' + 'and "q1" and "q2" are IDs of scale questions. ' + ), + default="", + multiline_editor=True, + resettable_editor=False + ) + + editable_fields = ( + 'display_name', 'plot_label', 'point_color_default', 'point_color_average', + 'q1_label', 'q2_label', 'q3_label', 'q4_label', 'claims' + ) + + @lazy + def course_key_str(self): + location = _normalize_id(self.location) + return unicode(location.course_key) + + @property + def default_claims(self): + return self._get_claims(self._get_default_response) + + @property + def average_claims(self): + return self._get_claims(self._get_average_response) + + def _get_claims(self, response_function): + if not self.claims: + return [] + + mentoring_block = self.get_parent().get_parent() + question_ids, questions = mentoring_block.question_ids, mentoring_block.questions + claims = [] + for line in self.claims.split('\n'): + claim, q1, q2 = line.split(', ') + r1, r2 = None, None + for question_id, question in zip(question_ids, questions): + if question.name == q1: + r1 = response_function(question, question_id) + if question.name == q2: + r2 = response_function(question, question_id) + if r1 is not None and r2 is not None: + break + claims.append([claim, r1, r2]) + + return claims + + def _get_default_response(self, question, question_id): + # 1. Obtain block_type for question + question_type = question.scope_ids.block_type + # 2. Obtain latest submission for question + student_dict = { + 'student_id': self.runtime.anonymous_student_id, + 'course_id': self.course_key_str, + 'item_id': question_id, + 'item_type': question_type, + } + submissions = sub_api.get_submissions(student_dict, limit=1) + # 3. Extract response from latest submission for question + answer_cache = {} + for submission in submissions: + answer = self._get_answer(question, submission, answer_cache) + return int(answer) + + def _get_average_response(self, question, question_id): + # 1. Obtain block_type for question + question_type = question.scope_ids.block_type + # 2. Obtain latest submissions for question + submissions = sub_api.get_all_submissions(self.course_key_str, question_id, question_type) + # 3. Extract responses from latest submissions for question and sum them up + answer_cache = {} + response_total = 0 + num_submissions = 0 # Can't use len(submissions) because submissions is a generator + for submission in submissions: + answer = self._get_answer(question, submission, answer_cache) + response_total += int(answer) + num_submissions += 1 + # 4. Calculate average response for question + if num_submissions: + return response_total / float(num_submissions) + + def _get_answer(self, block, submission, answer_cache): + """ + Return answer associated with `submission` to `block`. + + `answer_cache` is a dict that is reset for each block. + """ + answer = submission['answer'] + # Convert from answer ID to answer label + if answer not in answer_cache: + answer_cache[answer] = block.get_submission_display(answer) + return answer_cache[answer] + + def default_claims_json(self): + return json.dumps(self.default_claims) + + def average_claims_json(self): + return json.dumps(self.average_claims) + + @XBlock.json_handler + def get_data(self, data, suffix): + return { + 'default_claims': self.default_claims, + 'average_claims': self.average_claims, + } + + def author_preview_view(self, context): + return Fragment( + u"

{}

".format( + _(u"This block displays a plot that summarizes answers to scale questions.") + ) + ) + + def mentoring_view(self, context): + return self.student_view(context) + + def student_view(self, context=None): + """ Student View """ + context = context.copy() if context else {} + context['hide_header'] = True + context['self'] = self + fragment = Fragment() + fragment.add_content(loader.render_template('templates/html/plot.html', context)) + fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/plot.css')) + fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/d3.min.js')) + fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/plot.js')) + fragment.initialize_js('PlotBlock') + return fragment 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/mentoring_edit.css deleted file mode 100644 index 17d76e5c..00000000 --- a/problem_builder/public/css/mentoring_edit.css +++ /dev/null @@ -1,28 +0,0 @@ -/* Display of url_name below content */ -.xblock[data-block-type=problem-builder] .url-name-footer, -.xblock[data-block-type=mentoring] .url-name-footer { - font-style: italic; -} - -.xblock[data-block-type=problem-builder] .url-name-footer .url-name, -.xblock[data-block-type=mentoring] .url-name-footer .url-name { - margin: 0 10px; - font-family: monospace; -} - -/* Custom appearance for our "Add" buttons */ -.xblock[data-block-type=problem-builder] .add-xblock-component .new-component .new-component-type .add-xblock-component-button, -.xblock[data-block-type=mentoring] .add-xblock-component .new-component .new-component-type .add-xblock-component-button { - width: 200px; - height: 30px; - line-height: 30px; -} - -.xblock[data-block-type=problem-builder] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled, -.xblock[data-block-type=problem-builder] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled:hover, -.xblock[data-block-type=mentoring] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled, -.xblock[data-block-type=mentoring] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled:hover { - background-color: #ccc; - border-color: #888; - cursor: default; -} diff --git a/problem_builder/public/css/plot.css b/problem_builder/public/css/plot.css new file mode 100644 index 00000000..569d0c78 --- /dev/null +++ b/problem_builder/public/css/plot.css @@ -0,0 +1,11 @@ +.quadrants label { + font-weight: bold; +} + +.overlays { + float: right; +} + +.quadrants input, .overlays input { + background-color: rgb(204, 204, 204); +} diff --git a/problem_builder/public/css/problem-builder-edit.css b/problem_builder/public/css/problem-builder-edit.css new file mode 100644 index 00000000..c1a0b3a3 --- /dev/null +++ b/problem_builder/public/css/problem-builder-edit.css @@ -0,0 +1,55 @@ +/* Display of url_name below content */ +.xblock[data-block-type=sb-step] .url-name-footer, +.xblock[data-block-type=step-builder] .url-name-footer, +.xblock[data-block-type=problem-builder] .url-name-footer, +.xblock[data-block-type=mentoring] .url-name-footer { + font-style: italic; +} + +.xblock[data-block-type=sb-step] .url-name-footer .url-name, +.xblock[data-block-type=step-builder] .url-name-footer .url-name, +.xblock[data-block-type=problem-builder] .url-name-footer .url-name, +.xblock[data-block-type=mentoring] .url-name-footer .url-name { + margin: 0 10px; + font-family: monospace; +} + +/* Custom appearance for our "Add" buttons */ +.xblock[data-block-type=sb-review-step] .add-xblock-component .new-component .new-component-type .add-xblock-component-button, +.xblock[data-block-type=sb-step] .add-xblock-component .new-component .new-component-type .add-xblock-component-button, +.xblock[data-block-type=step-builder] .add-xblock-component .new-component .new-component-type .add-xblock-component-button, +.xblock[data-block-type=problem-builder] .add-xblock-component .new-component .new-component-type .add-xblock-component-button, +.xblock[data-block-type=mentoring] .add-xblock-component .new-component .new-component-type .add-xblock-component-button { + width: 200px; + height: 30px; + line-height: 30px; +} + +.xblock[data-block-type=sb-review-step] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled, +.xblock[data-block-type=sb-review-step] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled:hover, +.xblock[data-block-type=sb-step] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled, +.xblock[data-block-type=sb-step] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled:hover, +.xblock[data-block-type=step-builder] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled, +.xblock[data-block-type=step-builder] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled:hover, +.xblock[data-block-type=problem-builder] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled, +.xblock[data-block-type=problem-builder] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled:hover, +.xblock[data-block-type=mentoring] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled, +.xblock[data-block-type=mentoring] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled:hover { + background-color: #ccc; + border-color: #888; + cursor: default; +} + +.xblock[data-block-type=sb-review-step] .submission-message-help p, +.xblock[data-block-type=step-builder] .submission-message-help p, +.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; +} + +.xblock-author_view-pb-slider .url-name-footer { + margin: 0 -20px -20px -20px; /* Counteract spacing from xblock-render wrapper. */ +} 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 63% rename from problem_builder/public/css/mentoring.css rename to problem_builder/public/css/problem-builder.css index 4b97c44a..8417767a 100644 --- a/problem_builder/public/css/mentoring.css +++ b/problem_builder/public/css/problem-builder.css @@ -4,9 +4,6 @@ .mentoring .messages { display: none; - margin-top: 10px; - border-top: 2px solid #eaeaea; - padding: 12px 0px 20px; } .mentoring .messages .title1 { @@ -67,12 +64,38 @@ margin-bottom: 0; } +.mentoring .xblock-pb-slider p label { + font-size: inherit; +} + +.mentoring .pb-slider-box { + max-width: 400px; +} + +.mentoring .pb-slider-range { + width: 100%; +} + +.mentoring .pb-slider-min-label { + float: left; +} + +.mentoring .pb-slider-max-label { + float: right; +} + +.mentoring .clearfix::after { + clear: both; + display: block; + content: " "; +} + .mentoring .attempts { margin-left: 10px; display: inline-block; vertical-align: middle; font-size: 13px; - font-weight: bold; + font-weight: 600; } .mentoring .attempts > span { @@ -119,9 +142,16 @@ margin-right: 10px; } +.mentoring .assessment-checkmark.checkmark-clickable { + cursor: pointer; +} + +.mentoring .grade .grade-result { + margin: 20px; +} + .mentoring .grade .checkmark-incorrect { margin-left: 10px; - margin-right: 20px; } .mentoring input[type=button], @@ -139,3 +169,63 @@ .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 { + margin-left: 40px; +} + +.mentoring .results-section p { + margin-bottom: 4px; + padding-top: 4px; +} + +.mentoring .clear { + display: block; + clear: both; +} + +.mentoring .review-link { + float: right; + display: none; +} + +.mentoring .assessment-review-tips p.review-tips-intro { + margin-top: 1.2em; + margin-bottom: 0; + font-weight: bold; +} + +.mentoring .assessment-review-tips .review-tips-list { + margin-top: 0; + padding-top: 0; +} +.mentoring .assessment-review-tips .review-tips-list li { + margin-left: 0.5em; + padding-left: 0; +} +.mentoring .assessment-review-tips .review-tips-list li p { + display: inline; + margin: 0; +} + + +.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-edit.css b/problem_builder/public/css/questionnaire-edit.css index e3e5b91f..5b4aefaa 100644 --- a/problem_builder/public/css/questionnaire-edit.css +++ b/problem_builder/public/css/questionnaire-edit.css @@ -4,3 +4,20 @@ height: 30px; line-height: 30px; } + +.xblock .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled, +.xblock .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled:hover { + background-color: #ccc; + border-color: #888; + cursor: default; +} + +.xblock[data-block-type=pb-mcq] .submission-message-help p, +.xblock[data-block-type=pb-mrq] .submission-message-help p, +.xblock[data-block-type=pb-rating] .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/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..1a4dc74c --- /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 $usernames = $element.find("input[name='usernames']"); + 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(), + usernames: $usernames.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..b802fec8 100644 --- a/problem_builder/public/js/mentoring_assessment_view.js +++ b/problem_builder/public/js/mentoring_assessment_view.js @@ -1,6 +1,8 @@ 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()); // Detailed list of which questions the user got wrong + var reviewTipsTemplate = _.template($('#xblock-review-tips-template').html()); // Tips about specific questions the user got wrong + var submitDOM, nextDOM, reviewDOM, tryAgainDOM, assessmentMessageDOM, reviewLinkDOM, reviewTipsDOM; var submitXHR; var checkmark; var active_child; @@ -12,6 +14,8 @@ function MentoringAssessmentView(runtime, element, mentoring) { checkmark.removeClass('checkmark-correct icon-ok fa-check'); checkmark.removeClass('checkmark-partially-correct icon-ok fa-check'); checkmark.removeClass('checkmark-incorrect icon-exclamation fa-exclamation'); + checkmark.removeClass('checkmark-clickable'); + checkmark.off('click'); // Clear all selections $('input[type=radio], input[type=checkbox]', element).prop('checked', false); @@ -21,14 +25,37 @@ function MentoringAssessmentView(runtime, element, mentoring) { $('.grade').html(''); $('.attempts').html(''); + assessmentMessageDOM.html(''); + reviewTipsDOM.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 +67,23 @@ function MentoringAssessmentView(runtime, element, mentoring) { } mentoring.renderAttempts(); + if (data.max_attempts === 0 || data.num_attempts < data.max_attempts) { + if (data.assessment_message) { + // Overall on-assessment-review message: + assessmentMessageDOM.html(data.assessment_message); + } + if (data.assessment_review_tips.length > 0) { + // on-assessment-review-question messages specific to questions the student got wrong: + reviewTipsDOM.html(reviewTipsTemplate({ + tips: data.assessment_review_tips + })); + reviewTipsDOM.show(); + } + } else { + var msg = gettext("Note: you have used all attempts. Continue to the next unit."); + assessmentMessageDOM.html('').append($('

').html(msg)); + } + $('a.question-link', element).click(reviewJump); } function handleTryAgain(result) { @@ -47,6 +91,7 @@ function MentoringAssessmentView(runtime, element, mentoring) { return; active_child = -1; + notify('navigation', {state: 'lock'}) displayNextChild(); tryAgainDOM.hide(); submitDOM.show(); @@ -56,7 +101,6 @@ function MentoringAssessmentView(runtime, element, mentoring) { } function tryAgain() { - var success = true; var handlerUrl = runtime.handlerUrl(element, 'try_again'); if (submitXHR) { submitXHR.abort(); @@ -65,21 +109,31 @@ 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); + assessmentMessageDOM = $('.assessment-message', element); + reviewTipsDOM = $('.assessment-review-tips', 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 +156,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 +253,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 +274,59 @@ 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, + checkmark: checkmark + }; + 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) + } + + function get_results() { + calculate_results('get_results', handleReviewResults) + } - // if ((data.max_attempts > 0) && (data.num_attempts >= data.max_attempts)) { - // is_valid = false; - // } + 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 +343,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..a484cb63 100644 --- a/problem_builder/public/js/mentoring_standard_view.js +++ b/problem_builder/public/js/mentoring_standard_view.js @@ -4,49 +4,85 @@ 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]; + var all_have_results = response.results.length > 0; + $.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); + all_have_results = all_have_results && !$.isEmptyObject(result); }); - $('.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'); + // Disable the submit button if we have just submitted new answers, + // or if we have just [re]loaded the page and are showing a complete set + // of old answers. + if (disable_submit || all_have_results) { + 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 +110,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/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js new file mode 100644 index 00000000..3c59b502 --- /dev/null +++ b/problem_builder/public/js/mentoring_with_steps.js @@ -0,0 +1,513 @@ +function MentoringWithStepsBlock(runtime, element) { + + // 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 children = runtime.children(element); + var steps = []; + var reviewStep; + for (var i = 0; i < children.length; i++) { + var child = children[i]; + var blockType = $(child.element).data('block-type'); + if (blockType === 'sb-step') { + steps.push(child); + } else if (blockType === 'sb-review-step') { + reviewStep = child; + } + } + + var activeStep = $('.mentoring', element).data('active-step'); + var reviewTipsTemplate = _.template($('#xblock-review-tips-template').html()); // Tips about specific questions the user got wrong + var attemptsTemplate = _.template($('#xblock-attempts-template').html()); + var checkmark, submitDOM, nextDOM, reviewDOM, tryAgainDOM, + gradeDOM, attemptsDOM, reviewTipsDOM, reviewLinkDOM, submitXHR; + + function isLastStep() { + return (activeStep === steps.length-1); + } + + function atReviewStep() { + return (activeStep === -1); + } + + function someAttemptsLeft() { + var data = attemptsDOM.data(); + if (data.max_attempts === 0) { // Unlimited number of attempts available + return true; + } + return (data.num_attempts < data.max_attempts); + } + + function extendedFeedbackEnabled() { + var data = gradeDOM.data(); + return data.extended_feedback === "True"; + } + + function showFeedback(response) { + if (response.step_status === 'correct') { + checkmark.addClass('checkmark-correct icon-ok fa-check'); + } else if (response.step_status === 'partial') { + checkmark.addClass('checkmark-partially-correct icon-ok fa-check'); + } else { + checkmark.addClass('checkmark-incorrect icon-exclamation fa-exclamation'); + } + } + + function postUpdateStep(response) { + activeStep = response.active_step; + if (activeStep === -1) { + updateNumAttempts(); + } else { + updateControls(); + } + } + + function handleResults(response) { + showFeedback(response); + + // Update active step: + // If we end up at the review step, proceed with updating the number of attempts used. + // Otherwise, get UI ready for showing next step. + var handlerUrl = runtime.handlerUrl(element, 'update_active_step'); + $.post(handlerUrl, JSON.stringify(activeStep+1)) + .success(postUpdateStep); + } + + function updateNumAttempts() { + var handlerUrl = runtime.handlerUrl(element, 'update_num_attempts'); + $.post(handlerUrl, JSON.stringify({})) + .success(function(response) { + attemptsDOM.data('num_attempts', response.num_attempts); + publishAttempt(); + }); + } + + function publishAttempt() { + var handlerUrl = runtime.handlerUrl(element, 'publish_attempt'); + $.post(handlerUrl, JSON.stringify({})) + .success(function(response) { + // Now that relevant info is up-to-date and attempt has been published, get the latest grade + updateGrade(); + }); + } + + function updateGrade() { + var handlerUrl = runtime.handlerUrl(element, 'get_grade'); + $.post(handlerUrl, JSON.stringify({})) + .success(function(response) { + gradeDOM.data('score', response.score); + gradeDOM.data('correct_answer', response.correct_answers); + gradeDOM.data('incorrect_answer', response.incorrect_answers); + gradeDOM.data('partially_correct_answer', response.partially_correct_answers); + gradeDOM.data('correct', response.correct); + gradeDOM.data('incorrect', response.incorrect); + gradeDOM.data('partial', response.partial); + gradeDOM.data('assessment_review_tips', response.assessment_review_tips); + updateReviewStep(response); + }); + } + + function updateReviewStep(response) { + reviewStep.updateAssessmentMessage(response, updateControls); + } + + function updateControls() { + submitDOM.attr('disabled', 'disabled'); + + nextDOM.removeAttr("disabled"); + if (nextDOM.is(':visible')) { nextDOM.focus(); } + + if (atReviewStep()) { + if (reviewStep) { + reviewDOM.removeAttr('disabled'); + } else { + if (someAttemptsLeft()) { + tryAgainDOM.removeAttr('disabled'); + tryAgainDOM.show(); + } else { + showAttempts(); + } + } + } + } + + function submit() { + // We do not handle submissions at this level, so just forward to "submit" method of active step + var step = steps[activeStep]; + step.submit(handleResults); + } + + function markRead() { + var handlerUrl = runtime.handlerUrl(element, 'update_active_step'); + $.post(handlerUrl, JSON.stringify(activeStep+1)).success(function (response) { + postUpdateStep(response); + updateDisplay(); + }); + } + + function getResults() { + var step = steps[activeStep]; + step.getResults(handleReviewResults); + } + + function handleReviewResults(response) { + // Show step-level feedback + showFeedback(response); + // Forward to active step to show answer level feedback + var step = steps[activeStep]; + var results = response.results; + var options = { + checkmark: checkmark + }; + step.handleReview(results, options); + } + + function hideAllSteps() { + for (var i=0; i < steps.length; i++) { + $(steps[i].element).hide(); + } + } + + function clearSelections() { + $('input[type=radio], input[type=checkbox]', element).prop('checked', false); + } + + function cleanAll() { + checkmark.removeClass('checkmark-correct icon-ok fa-check'); + checkmark.removeClass('checkmark-partially-correct icon-ok fa-check'); + checkmark.removeClass('checkmark-incorrect icon-exclamation fa-exclamation'); + hideAllSteps(); + hideReviewStep(); + attemptsDOM.html(''); + reviewTipsDOM.empty().hide(); + } + + function updateNextLabel() { + var step = steps[activeStep]; + nextDOM.attr('value', step.getStepLabel()); + } + + function updateDisplay() { + cleanAll(); + + if (atReviewStep()) { + // Tell supporting runtimes to enable navigation between units; + // user is currently not in the middle of an attempt + // so it makes sense for them to be able to leave the current unit by clicking arrow buttons + notify('navigation', {state: 'unlock'}); + + showReviewStep(); + showAttempts(); + } else { + showActiveStep(); + validateXBlock(); + updateNextLabel(); + var step = steps[activeStep]; + if (step.hasQuestion()) { + nextDOM.attr('disabled', 'disabled'); + } else { + nextDOM.removeAttr('disabled'); + } + if (isLastStep() && reviewStep) { + if (step.hasQuestion()) { + reviewDOM.attr('disabled', 'disabled'); + } else { + reviewDOM.removeAttr('disabled') + } + reviewDOM.show(); + } + } + } + + function showReviewStep() { + // Forward to review step to show assessment message + reviewStep.showAssessmentMessage(); + + // Forward to review step to render grade data + var showExtendedFeedback = (!someAttemptsLeft() && extendedFeedbackEnabled()); + reviewStep.renderGrade(gradeDOM, showExtendedFeedback); + + // Add click handler that takes care of showing associated step to step links + $('a.step-link', element).on('click', getStepToReview); + + if (someAttemptsLeft()) { + + tryAgainDOM.removeAttr('disabled'); + + // Review tips + var data = gradeDOM.data(); + if (data.assessment_review_tips.length > 0) { + // on-assessment-review-question messages specific to questions the student got wrong: + reviewTipsDOM.html(reviewTipsTemplate({ + tips: data.assessment_review_tips + })); + reviewTipsDOM.show(); + } + } + + submitDOM.hide(); + nextDOM.hide(); + reviewDOM.hide(); + tryAgainDOM.show(); + } + + function hideReviewStep() { + reviewStep.hideAssessmentMessage(); + gradeDOM.html(''); + } + + function getStepToReview(event) { + event.preventDefault(); + var stepIndex = parseInt($(event.target).data('step')) - 1; + jumpToReview(stepIndex); + } + + function jumpToReview(stepIndex) { + activeStep = stepIndex; + cleanAll(); + showActiveStep(); + updateNextLabel(); + + if (isLastStep()) { + reviewDOM.show(); + reviewDOM.removeAttr('disabled'); + nextDOM.hide(); + nextDOM.attr('disabled', 'disabled'); + } else { + nextDOM.show(); + nextDOM.removeAttr('disabled'); + } + var step = steps[activeStep]; + + tryAgainDOM.hide(); + if (step.hasQuestion()) { + submitDOM.show(); + } else { + submitDOM.hide(); + } + submitDOM.attr('disabled', 'disabled'); + reviewLinkDOM.show(); + + getResults(); + } + + function showAttempts() { + var data = attemptsDOM.data(); + if (data.max_attempts > 0) { + attemptsDOM.html(attemptsTemplate(data)); + } // Don't show attempts if unlimited attempts available (max_attempts === 0) + } + + function showActiveStep() { + var step = steps[activeStep]; + step.updatePlots(); + $(step.element).show(); + } + + function onChange() { + // We do not allow users to modify answers belonging to a step after submitting them: + // Once an answer has been submitted ("Next Step" button is enabled), + // start ignoring changes to the answer. + if (nextDOM.attr('disabled')) { + validateXBlock(); + } + } + + function validateXBlock() { + var isValid = true; + var step = steps[activeStep]; + if (step) { + isValid = step.validate(); + } + if (!isValid) { + submitDOM.attr('disabled', 'disabled'); + } else { + submitDOM.removeAttr('disabled'); + } + if (isLastStep() && step.hasQuestion()) { + nextDOM.hide(); + } else if (isLastStep()) { + reviewDOM.one('click', markRead); + reviewDOM.removeAttr('disabled'); + nextDOM.hide() + } else if (!step.hasQuestion()) { + nextDOM.one('click', markRead); + } + if (step.hasQuestion()) { + submitDOM.show(); + } else { + submitDOM.hide(); + } + } + + function initSteps(options) { + for (var i=0; i < steps.length; i++) { + var step = steps[i]; + var mentoring = { + setContent: setContent, + publish_event: publishEvent + }; + options.mentoring = mentoring; + step.initChildren(options); + } + } + + function setContent(dom, content) { + dom.html(''); + dom.append(content); + var template = $('#light-child-template', dom).html(); + if (template) { + dom.append(template); + } + } + + function publishEvent(data) { + $.ajax({ + type: "POST", + url: runtime.handlerUrl(element, 'publish_event'), + data: JSON.stringify(data) + }); + } + + function showGrade() { + // Tell supporting runtimes to enable navigation between units; + // user is currently not in the middle of an attempt + // so it makes sense for them to be able to leave the current unit by clicking arrow buttons + notify('navigation', {state: 'unlock'}); + + cleanAll(); + showReviewStep(); + showAttempts(); + + // Disable "Try again" button if no attempts left + if (!someAttemptsLeft()) { + tryAgainDOM.attr("disabled", "disabled"); + } + + nextDOM.off(); + nextDOM.on('click', reviewNextStep); + reviewLinkDOM.hide(); + } + + function reviewNextStep() { + jumpToReview(activeStep+1); + } + + function handleTryAgain(result) { + // Tell supporting runtimes to disable navigation between units; + // this keeps users from accidentally clicking arrow buttons + // and interrupting their experience with the current unit + notify('navigation', {state: 'lock'}); + + activeStep = result.active_step; + clearSelections(); + updateDisplay(); + tryAgainDOM.hide(); + submitDOM.show(); + if (! isLastStep()) { + nextDOM.off(); + nextDOM.on('click', updateDisplay); + nextDOM.show(); + reviewDOM.hide(); + } + } + + function tryAgain() { + var handlerUrl = runtime.handlerUrl(element, 'try_again'); + if (submitXHR) { + submitXHR.abort(); + } + submitXHR = $.post(handlerUrl, JSON.stringify({})).success(handleTryAgain); + } + + function notify(name, data){ + // Notification interface does not exist in the workbench. + if (runtime.notify) { + runtime.notify(name, data); + } + } + + function initClickHandlers() { + $(document).on("click", function(event, ui) { + var target = $(event.target); + var itemFeedbackParentSelector = '.choice'; + var itemFeedbackSelector = ".choice .choice-tips"; + + function clickedInside(selector, parent_selector){ + return target.is(selector) || target.parents(parent_selector).length>0; + } + + if (!clickedInside(itemFeedbackSelector, itemFeedbackParentSelector)) { + $(itemFeedbackSelector).not(':hidden').hide(); + $('.choice-tips-container').removeClass('with-tips'); + } + }); + } + + function initXBlockView() { + // Tell supporting runtimes to disable navigation between units; + // this keeps users from accidentally clicking arrow buttons + // and interrupting their experience with the current unit + notify('navigation', {state: 'lock'}); + + // Hide steps until we're ready + hideAllSteps(); + + // Initialize references to relevant DOM elements and set up event handlers + checkmark = $('.assessment-checkmark', element); + + submitDOM = $(element).find('.submit .input-main'); + submitDOM.on('click', submit); + + nextDOM = $(element).find('.submit .input-next'); + if (atReviewStep()) { + nextDOM.on('click', reviewNextStep); + } else { + nextDOM.on('click', updateDisplay); + } + + reviewDOM = $(element).find('.submit .input-review'); + reviewDOM.on('click', showGrade); + + tryAgainDOM = $(element).find('.submit .input-try-again'); + tryAgainDOM.on('click', tryAgain); + + gradeDOM = $('.grade', element); + attemptsDOM = $('.attempts', element); + reviewTipsDOM = $('.assessment-review-tips', element); + + reviewLinkDOM = $(element).find('.review-link'); + reviewLinkDOM.on('click', showGrade); + + // Initialize individual steps + // (sets up click handlers for questions and makes sure answer data is up-to-date) + var options = { + onChange: onChange + }; + initSteps(options); + + // Refresh info about number of attempts used: + // In the LMS, the HTML of multiple units can be loaded at once, + // and the user can flip among them. If that happens, information about + // number of attempts student has used up may be out of date. + var handlerUrl = runtime.handlerUrl(element, 'get_num_attempts'); + $.post(handlerUrl, JSON.stringify({})) + .success(function(response) { + attemptsDOM.data('num_attempts', response.num_attempts); + + // Finally, show controls and content + submitDOM.show(); + nextDOM.show(); + + updateDisplay(); + }); + + } + + initClickHandlers(); + initXBlockView(); + +} diff --git a/problem_builder/public/js/mentoring_with_steps_edit.js b/problem_builder/public/js/mentoring_with_steps_edit.js new file mode 100644 index 00000000..bcbf3ee9 --- /dev/null +++ b/problem_builder/public/js/mentoring_with_steps_edit.js @@ -0,0 +1,45 @@ +function MentoringWithStepsEdit(runtime, element) { + "use strict"; + + var $buttons = $('.add-xblock-component-button[data-category=sb-review-step]', element); + + var blockIsPresent = function(klass) { + return $('.xblock ' + klass).length > 0; + }; + + var updateButton = function(button, condition) { + button.toggleClass('disabled', condition); + }; + + var disableButton = function(ev) { + if ($(this).is('.disabled')) { + ev.preventDefault(); + ev.stopPropagation(); + } else { + $(this).addClass('disabled'); + } + }; + + var updateButtons = function(buttons) { + buttons.each(function() { + var button = $(this); + updateButton(button, blockIsPresent('.xblock-header-sb-review-step')); + }); + }; + + var initButtons = function() { + updateButtons($buttons); + $buttons.on('click', disableButton); + }; + + var resetButtons = function() { + var $disabledButtons = $buttons.filter('.disabled'); + updateButtons($disabledButtons); + }; + + ProblemBuilderUtil.transformClarifications(element); + + initButtons(); + runtime.listenTo('deleted-child', resetButtons); + +} diff --git a/problem_builder/public/js/plot.js b/problem_builder/public/js/plot.js new file mode 100644 index 00000000..0ee66e18 --- /dev/null +++ b/problem_builder/public/js/plot.js @@ -0,0 +1,218 @@ +function PlotBlock(runtime, element) { + + // Plot + + // Define margins + var margins = {top: 20, right: 20, bottom: 20, left: 20}; + + // Define width and height of SVG viewport + var width = 440, + height = 440; + + // Define dimensions of plot area + var plotWidth = width - margins.left - margins.right, + plotHeight = height - margins.top - margins.bottom; + + // Preselect target DOM element for plot. + // This is necessary because when using a CSS selector, + // d3.select will select the *first* element that matches the selector (in document traversal order), + // which leads to unintended consequences when multiple plot blocks are present. + var plotTarget = $(element).find('.sb-plot').get(0); + + // Create SVG viewport with nested group for plot area + var svgContainer = d3.select(plotTarget) + .append("svg") + .attr("width", width) + .attr("height", height) + .append("g") + .attr("transform", "translate(" + margins.left + ", " + margins.right + ")"); + + // Create scales to use for axes and data + var xScale = d3.scale.linear() + .domain([0, 100]) + .range([0, plotWidth]); + + var yScale = d3.scale.linear() + .domain([100, 0]) + .range([0, plotHeight]); + + // Create axes + var xAxis = d3.svg.axis() + .scale(xScale) + .orient("bottom"); + + var yAxis = d3.svg.axis() + .scale(yScale) + .orient("left"); + + // Create SVG group elements for axes and call the xAxis and yAxis functions + var xAxisGroup = svgContainer.append("g") + .attr("transform", "translate(0, " + plotHeight / 2 + ")") + .call(xAxis); + + var yAxisGroup = svgContainer.append("g") + .attr("transform", "translate(" + plotWidth / 2 + ", 0)") + .call(yAxis); + + // Buttons + + var defaultButton = $('.plot-default', element), + averageButton = $('.plot-average', element), + quadrantsButton = $('.plot-quadrants', element); + + // Claims + + var defaultClaims = defaultButton.data('claims'), + averageClaims = averageButton.data('claims'); + + // Colors + + var defaultColor = defaultButton.data('point-color'), + averageColor = averageButton.data('point-color'); + + // Quadrant labels + + var q1Label = quadrantsButton.data('q1-label'), + q2Label = quadrantsButton.data('q2-label'), + q3Label = quadrantsButton.data('q3-label'), + q4Label = quadrantsButton.data('q4-label'); + + // Event handlers + + function toggleOverlay(claims, color, klass, refresh) { + var selector = "." + klass, + selection = svgContainer.selectAll(selector); + if (selection.empty()) { + showOverlay(selection, claims, color, klass); + } else { + hideOverlay(selection); + if (refresh) { + toggleOverlay(claims, color, klass); + } + } + } + + function showOverlay(selection, claims, color, klass) { + selection + .data(claims) + .enter() + .append("circle") + .attr("class", klass) + .attr("title", function(d) { + return d[0] + ": " + d[1] + ", " + d[2]; + }) + .attr("cx", function(d) { + return xScale(d[1]); + }) + .attr("cy", function(d) { + return yScale(d[2]); + }) + .attr("r", 5) + .style("fill", color); + } + + function hideOverlay(selection) { + selection.remove(); + } + + function toggleBorderColor(button, color, refresh) { + var $button = $(button), + overlayOn = $button.data("overlay-on"); + if (overlayOn && !refresh) { + $button.css("border-color", "rgb(237, 237, 237)"); // Default color: grey + $button.data("overlay-on", false); + } else { + $button.css("border-color", color); + $button.data("overlay-on", true); + } + } + + function toggleQuadrantLabels() { + var selection = svgContainer.selectAll(".quadrant-label"), + quadrantLabelsOn = quadrantsButton.val() === 'On'; + if (quadrantLabelsOn) { + selection.remove(); + quadrantsButton.val("Off"); + quadrantsButton.css("border-color", "red"); + } else { + var labels = [ + [0.75 * plotWidth, 0, q1Label], + [0.25 * plotWidth, 0, q2Label], + [0.25 * plotWidth, plotHeight, q3Label], + [0.75 * plotWidth, plotHeight, q4Label] + ]; + selection.data(labels) + .enter() + .append("text") + .attr("class", 'quadrant-label') + .attr("x", function(d) { + return d[0]; + }) + .attr("y", function(d) { + return d[1]; + }) + .text(function(d) { + return d[2]; + }) + .attr("text-anchor", "middle") + .attr("font-family", "sans-serif") + .attr("font-size", "16px") + .attr("fill", "black"); + quadrantsButton.val("On"); + quadrantsButton.css("border-color", "green"); + } + } + + defaultButton.on('click', function(event, refresh) { + toggleOverlay(defaultClaims, defaultColor, 'claim-default', refresh); + toggleBorderColor(this, defaultColor, refresh); + }); + + averageButton.on('click', function(event) { + toggleOverlay(averageClaims, averageColor, 'claim-average'); + toggleBorderColor(this, averageColor); + }); + + quadrantsButton.on('click', function() { + toggleQuadrantLabels(); + }); + + // Quadrant labels are off initially; color of button for toggling them should reflect this + quadrantsButton.css("border-color", "red"); + + // API + + var dataXHR; + + return { + + update: function() { + var handlerUrl = runtime.handlerUrl(element, 'get_data'); + if (dataXHR) { + dataXHR.abort(); + } + dataXHR = $.post(handlerUrl, JSON.stringify({})) + .success(function(response) { + defaultClaims = response.default_claims; + averageClaims = response.average_claims; + + // Default overlay should be visible initially. + // Might still be visible from a previous attempt; + // in that case, we refresh it: + defaultButton.trigger('click', 'refresh'); + + // Average overlay should be hidden initially. + // This is the default when (re-)loading the page from scratch. + // However, the overlay might still be visible from a previous attempt; + // in that case, we hide it: + var selection = svgContainer.selectAll('.claim-average'); + if (!selection.empty()) { + hideOverlay(selection); + toggleBorderColor(averageButton, averageColor); + } + }); + } + + }; + +} diff --git a/problem_builder/public/js/questionnaire.js b/problem_builder/public/js/questionnaire.js index 033a2b3d..297a1c16 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,29 +178,41 @@ 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; var messageView = MessageView(element, mentoring); if (result.message) { - messageView.showMessage('
' + result.message + '
'+ - '
'); + var msg = '
' + result.message + '
' + + '
'; + messageView.showMessage(msg); + if (options.checkmark) { + options.checkmark.addClass('checkmark-clickable'); + options.checkmark.on('click', function(ev) { + ev.stopPropagation(); + messageView.showMessage(msg); + }) + } } 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 +227,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/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/review_step.js b/problem_builder/public/js/review_step.js new file mode 100644 index 00000000..ff0d35fc --- /dev/null +++ b/problem_builder/public/js/review_step.js @@ -0,0 +1,49 @@ +function ReviewStepBlock(runtime, element) { + + var gradeTemplate = _.template($('#xblock-feedback-template').html()); + var reviewStepsTemplate = _.template($('#xblock-step-links-template').html()); + + var assessmentMessageDOM = $('.assessment-message', element); + + return { + + 'showAssessmentMessage': function() { + var assessmentMessage = assessmentMessageDOM.data('assessment_message'); + assessmentMessageDOM.html(assessmentMessage); + assessmentMessageDOM.show(); + }, + + 'hideAssessmentMessage': function() { + assessmentMessageDOM.html(''); + assessmentMessageDOM.hide(); + }, + + 'updateAssessmentMessage': function(grade, callback) { + var handlerUrl = runtime.handlerUrl(element, 'get_assessment_message'); + $.post(handlerUrl, JSON.stringify(grade)).success(function(response) { + assessmentMessageDOM.data('assessment_message', response.assessment_message); + callback(); + }); + }, + + 'renderGrade': function(gradeDOM, showExtendedFeedback) { + + var data = gradeDOM.data(); + + _.extend(data, { + 'runDetails': function(correctness) { + if (!showExtendedFeedback) { + return ''; + } + var self = this; + return reviewStepsTemplate({'questions': self[correctness], 'correctness': correctness}); + } + }); + + gradeDOM.html(gradeTemplate(data)); + + } + + }; + +} diff --git a/problem_builder/public/js/review_step_edit.js b/problem_builder/public/js/review_step_edit.js new file mode 100644 index 00000000..f753cee3 --- /dev/null +++ b/problem_builder/public/js/review_step_edit.js @@ -0,0 +1,46 @@ +function ReviewStepEdit(runtime, element) { + "use strict"; + + var $buttons = $('.add-xblock-component-button[data-category=pb-message]', element); + + var blockIsPresent = function(klass) { + return $('.xblock ' + klass).length > 0; + }; + + var updateButton = function(button, condition) { + button.toggleClass('disabled', condition); + }; + + var disableButton = function(ev) { + if ($(this).is('.disabled')) { + ev.preventDefault(); + ev.stopPropagation(); + } else { + $(this).addClass('disabled'); + } + }; + + var updateButtons = function(buttons) { + buttons.each(function() { + var button = $(this); + var msgType = button.data('boilerplate'); + updateButton(button, blockIsPresent('.submission-message.'+msgType)); + }); + }; + + var initButtons = function() { + updateButtons($buttons); + $buttons.on('click', disableButton); + }; + + var resetButtons = function() { + var $disabledButtons = $buttons.filter('.disabled'); + updateButtons($disabledButtons); + }; + + ProblemBuilderUtil.transformClarifications(element); + + initButtons(); + runtime.listenTo('deleted-child', resetButtons); + +} diff --git a/problem_builder/public/js/slider.js b/problem_builder/public/js/slider.js new file mode 100644 index 00000000..ea1d46fe --- /dev/null +++ b/problem_builder/public/js/slider.js @@ -0,0 +1,41 @@ +function SliderBlock(runtime, element) { + var $slider = $('.pb-slider-range', element); + return { + mode: null, + mentoring: null, + + value: function() { + return parseInt($slider.val()); + }, + + init: function(options) { + this.mentoring = options.mentoring; + this.mode = options.mode; + $slider.on('change', options.onChange); + }, + + submit: function() { + return this.value() / 100.0; + }, + + handleReview: function(result){ + $slider.val(result.submission * 100.0); + $slider.prop('disabled', true); + }, + + handleSubmit: function(result) { + // Show a green check if the user has submitted a valid value: + if (typeof result.submission !== "undefined") { + $('.submit-result', element).css('visibility', 'visible'); + } + }, + + clearResult: function() { + $('.submit-result', element).css('visibility', 'hidden'); + }, + + validate: function(){ + return Boolean(this.value() >= 0 && this.value() <= 100); + } + }; +} diff --git a/problem_builder/public/js/step.js b/problem_builder/public/js/step.js new file mode 100644 index 00000000..06c2ffb8 --- /dev/null +++ b/problem_builder/public/js/step.js @@ -0,0 +1,114 @@ +function MentoringStepBlock(runtime, element) { + + var children = runtime.children(element); + var plots = []; + for (var i in children) { + var child = children[i]; + var blockType = $(child.element).data('block-type'); + if (blockType === 'sb-plot') { + plots.push(child); + } + } + + var submitXHR, resultsXHR; + + function callIfExists(obj, fn) { + if (typeof obj !== 'undefined' && typeof obj[fn] == 'function') { + return obj[fn].apply(obj, Array.prototype.slice.call(arguments, 2)); + } else { + return null; + } + } + + return { + + initChildren: function(options) { + for (var i=0; i < children.length; i++) { + var child = children[i]; + callIfExists(child, 'init', options); + } + }, + + validate: function() { + var is_valid = true; + for (var i = 0; i < children.length; i++) { + var child = children[i]; + if (child && child.name !== undefined) { + var child_validation = callIfExists(child, 'validate'); + if (_.isBoolean(child_validation)) { + is_valid = is_valid && child_validation; + } + } + } + return is_valid; + }, + + submit: function(resultHandler) { + var handler_name = 'submit'; + var data = {}; + for (var i = 0; i < children.length; i++) { + var child = children[i]; + if (child && child.name !== undefined) { + data[child.name.toString()] = callIfExists(child, handler_name); + } + } + var handlerUrl = runtime.handlerUrl(element, handler_name); + if (submitXHR) { + submitXHR.abort(); + } + submitXHR = $.post(handlerUrl, JSON.stringify(data)) + .success(function(response) { + resultHandler(response); + }); + }, + + getResults: function(resultHandler) { + var handler_name = 'get_results'; + var data = []; + for (var i = 0; i < children.length; i++) { + var child = children[i]; + if (child && child.name !== undefined) { // Check if we are dealing with a question + data[i] = child.name; + } + } + var handlerUrl = runtime.handlerUrl(element, handler_name); + if (resultsXHR) { + resultsXHR.abort(); + } + resultsXHR = $.post(handlerUrl, JSON.stringify(data)) + .success(function(response) { + resultHandler(response); + }); + }, + + handleReview: function(results, options) { + for (var i = 0; i < children.length; i++) { + var child = children[i]; + if (child && child.name !== undefined) { // Check if we are dealing with a question + var result = results[child.name]; + callIfExists(child, 'handleSubmit', result, options); + callIfExists(child, 'handleReview', result); + } + } + }, + + getStepLabel: function() { + return $('.sb-step', element).data('next-button-label'); + }, + + hasQuestion: function() { + return $('.sb-step', element).data('has-question') + }, + + updatePlots: function() { + if (plots) { + for (var i in plots) { + var plot = plots[i]; + plot.update(); + } + } + } + + }; + +} diff --git a/problem_builder/public/js/step_edit.js b/problem_builder/public/js/step_edit.js new file mode 100644 index 00000000..b0f815dc --- /dev/null +++ b/problem_builder/public/js/step_edit.js @@ -0,0 +1,5 @@ +function StepEdit(runtime, element) { + 'use strict'; + StudioContainerXBlockWithNestedXBlocksMixin(runtime, element); + ProblemBuilderUtil.transformClarifications(element); +} 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.$('