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/.pep8 b/.pep8 new file mode 100644 index 00000000..f8931cdd --- /dev/null +++ b/.pep8 @@ -0,0 +1,2 @@ +[pep8] +exclude=problem_builder/migrations diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4e8f2514..00000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -language: python -python: - - "2.7" -before_install: - - "export DISPLAY=:99" - - "sh -e /etc/init.d/xvfb start" -install: - - "pip install -e git://github.com/edx/xblock-sdk.git@22c1b2f173919bef22f2d9d9295ec5396d02dffd#egg=xblock-sdk" - - "pip install -r requirements.txt" - - "pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements/base.txt" - - "pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements/test.txt" - - "pip uninstall -y xblock-problem-builder && python setup.py sdist && pip install dist/xblock-problem-builder-2.0.4.tar.gz" - - "pip install -r test_requirements.txt" - - "mkdir var" -script: - - pep8 problem_builder --max-line-length=120 - - pylint problem_builder --disable=all --enable=function-redefined,undefined-variable,unused-variable - - python run_tests.py --with-coverage --cover-package=problem_builder -notifications: - email: false -addons: - firefox: "36.0" 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..521196c7 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) +[![Circle CI](https://circleci.com/gh/open-craft/problem-builder.svg?style=svg)](https://circleci.com/gh/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: @@ -42,18 +45,6 @@ root folder: $ pip install -r requirements.txt ``` -Enabling in Studio ------------------- - -You can enable the Problem Builder XBlock in studio through the advanced -settings. - -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. - Usage ----- diff --git a/circle.yml b/circle.yml new file mode 100644 index 00000000..20c87de2 --- /dev/null +++ b/circle.yml @@ -0,0 +1,27 @@ +machine: + python: + version: 2.7.10 +dependencies: + override: + - "pip install -U pip wheel" + # Temporarily pin setuptools to a specific version. + # See commit message of https://github.com/open-craft/problem-builder/commit/51277a34fb426724616618c1afdb893ab2de4c6b for more info: + - "pip install setuptools==24.3.1" + - "pip install -e git://github.com/edx/xblock-sdk.git@bddf9f4a2c6e4df28a411c8f632cc2250170ae9d#egg=xblock-sdk" + - "pip install -r requirements.txt" + - "pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements/base.txt" + - "pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements/test.txt" + - "pip uninstall -y xblock-problem-builder && python setup.py sdist && pip install dist/xblock-problem-builder-2.6.0.tar.gz" + - "pip install -r test_requirements.txt" + - "mkdir var" +test: + override: + - "if [ $CIRCLE_NODE_INDEX = '0' ]; then pep8 problem_builder --max-line-length=120; fi": + parallel: true + - "if [ $CIRCLE_NODE_INDEX = '1' ]; then pylint problem_builder --disable=all --enable=function-redefined,undefined-variable,unused-import,unused-variable; fi": + parallel: true + - "python run_tests.py": + parallel: true + files: + - "problem_builder/v1/tests/**/*.py" + - "problem_builder/tests/**/*.py" diff --git a/doc/Dashboard.md b/doc/Dashboard.md index 9984d02b..9497b3df 100644 --- a/doc/Dashboard.md +++ b/doc/Dashboard.md @@ -80,3 +80,16 @@ must be in JSON format. The supported entries are: * **`"width"`**: (Important) The width of the images, in pixels (all images should be the same size). * **`"height"`**: (Important) The height of the images, in pixels + + +Enabling in Studio +------------------ + +You can enable the Dashboard XBlock 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. Add `"pb-dashboard"` to the modules listed there. +4. Click the **Save changes** button. diff --git a/doc/Problem Builder.md b/doc/Problem Builder.md new file mode 100644 index 00000000..ca9a8bad --- /dev/null +++ b/doc/Problem Builder.md @@ -0,0 +1,32 @@ +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. + +See [Question Types](Questions.md) to learn about the various types of question +that can be added to a Problem Builder block. + + +Configuration Options +--------------------- + +### Maximum Attempts + +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: + +![Max Attempts Before](img/max-attempts-before.png) + +After submitting a wrong answer two times: + +![Max Attempts Reached](img/max-attempts-reached.png) + +### Custom Window Size for Tip Popups + +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/Questions.md b/doc/Questions.md new file mode 100644 index 00000000..8672f448 --- /dev/null +++ b/doc/Questions.md @@ -0,0 +1,138 @@ +Questions and Other Components +============================== + +These are the types of questions that can be added to Problem Builder and Step +Builder: + + +### Free-form Questions + +Free-form questions are represented by a **Long Answer** component. + +Example screenshot before answering the question: + +![Answer Initial](img/answer-1.png) + +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. + +The read-only answer is rendered as a quote in the LMS: + +![Answer Read-Only](img/answer-3.png) + +### 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 have + made their choice. +* **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[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 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 selects a choice, the tip will be +displayed. + +**Screenshots** + +Before attempting to answer the questions: + +![MCQ Initial](img/mcq-1.png) + +While attempting to complete the questions: + +![MCQ Attempting](img/mcq-2.png) + +After successfully completing the questions: + +![MCQ Success](img/mcq-3.png) + +#### 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 question type, which +includes built-in numbered choices from 1 to 5. 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 if you want additional +choices to be available such as "I don't know". + +### 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 be selected, +MRQs allow multiple answers to be selected at the same time. + +MRQ questions have these configurable settings: + +* **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 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 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). + +The **Custom Choice** and **Tip** components work the same way as they do when +used with MCQs (see above). + +**Screenshots** + +Before attempting to answer the questions: + +![MRQ Initial](img/mrq-1.png) + +While attempting to answer the questions: + +![MRQ Attempt](img/mrq-2.png) + +After clicking on the feedback icon next to the "Its bugs" answer: + +![MRQ Attempt](img/mrq-3.png) + +After successfully completing the questions: + +![MRQ Success](img/mrq-4.png) + +Other Components +================ + +### Tables + +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) diff --git a/doc/Step Builder.md b/doc/Step Builder.md new file mode 100644 index 00000000..5393f41c --- /dev/null +++ b/doc/Step Builder.md @@ -0,0 +1,85 @@ +Step Builder Usage +================== + +The Step Builder is similar to Problem Builder, but it allows authors to group +questions into explict steps, and provide more detailed feedback to students. + +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 can also contain a **Review Step** +component which: + +* allows students to review their performance + +* allows students to jump back to individual steps to review their + answers (if **Extended feedback** setting is enabled on the Step Builder block + and the maximum number of attempts has been reached.) + +* supports "conditional messages" that will can shown during the review step + based on certain conditions such as: + + * the student achieved a perfect score, or not + * the student is allowed to try again, or has used up all attempts + +**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, and a conditional message +that says "Great job!" configured to appear if the student gets a perfect score: + +![Unlimited attempts available](img/review-step-unlimited-attempts-available.png) + +Limited attempts, some attempts remaining, some answers incorrect, and a custom +review/study tip. + +![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) + + +Configuration Options +--------------------- + +### Maximum Attempts + +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: + +![Max Attempts Before](img/max-attempts-before.png) + +After submitting a wrong answer two times: + +![Max Attempts Reached](img/max-attempts-reached.png) diff --git a/doc/Usage.md b/doc/Usage.md index 3bf75868..f555444c 100644 --- a/doc/Usage.md +++ b/doc/Usage.md @@ -1,184 +1,40 @@ -Mentoring Block 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. - -### Problem Builder modes - -There are 2 mentoring modes available: - -* *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 - 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. - -Question before submitting an answer: - -![Assessment Step 1](img/assessment-1.png) - -Question after submitting the correct answer: - -![Assessment Step 2](img/assessment-2.png) - -Question after submitting a wrong answer: - -![Assessment Step 3](img/assessment-3.png) - -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. - -Example screenshot before answering the question: - -![Answer Initial](img/answer-1.png) - -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. - -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 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 - have made their choice. -* 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 - 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. - -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 - - -Screenshot: Before attempting to answer the questions: - -![MCQ Initial](img/mcq-1.png) - -While attempting to complete the questions: - -![MCQ Attempting](img/mcq-2.png) - -After successfully completing the questions: - -![MCQ Success](img/mcq-3.png) - -#### Rating MCQ - -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 -question type, which includes built-in numbered choices from 1 to 5 -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 -if you want additional choices to be available such as "I don't know". - - -### Self-assessment Multiple Response Questions (MRQ) - -Multiple Response Questions are set up similarly to MCQs. The answers -are rendered as checkboxes. Unlike MCQs where only a single answer can -be selected, MRQs allow multiple answers to be selected at the same -time. - -MRQ questions have these configurable settings: - -* 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 - 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 - 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). - -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: - -![MRQ Initial](img/mrq-1.png) - -While attempting to answer the questions: - -![MRQ Attempt](img/mrq-2.png) - -After clicking on the feedback icon next to the "Its bugs" answer: - -![MRQ Attempt](img/mrq-3.png) - -After successfully completing the questions: - -![MRQ Success](img/mrq-4.png) - -### 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. - -Screenshot: - -![Table Screenshot](img/mentoring-table.png) - -### Maximum Attempts - -You can set the number of maximum attempts for the unit completion by -setting the Max. Attempts option of the Mentoring component. - -Before submitting an answer for the first time: - -![Max Attempts Before](img/max-attempts-before.png) - -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`). - -### "Dashboard" Self-Assessment Summary Block - -[Instructions for using the "Dashboard" Self-Assessment Summary Block](Dashboard.md) +Using Problem Builder and Step Builder +====================================== + +First, enable the blocks in Studio (see "Enabling in Studio", below). + +Next, decide whether you want to use **Problem Builder** or **Step Builder** to +create your exercise. Select the name of the block below for detailed usage +instructions. + +* [Problem Builder](Problem Builder.md) is simply a group of one or more + question[s]. +* [Step Builder](Step Builder.md) lets authors build more complex exercises + where questions are grouped into "steps" and students answer the questions in + each step at a time. An optional "review step" can be added to the end of the + exercise, which can summarize the student's results and provide tailored + feedback and study suggestions. + +Once you add a Problem Builder or Step Builder component to a course, you can +then click on the "View" link (seen at the top right of the component) to open +the component for editing. You can then add [any of the supported question and +content types](Questions.md). + + +Enabling in Studio +------------------ + +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. + +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. 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 4ccd2ec3..a9631130 100644 --- a/problem_builder/answer.py +++ b/problem_builder/answer.py @@ -26,12 +26,13 @@ from .models import Answer from xblock.core import XBlock -from xblock.fields import Scope, Float, Integer, String +from xblock.fields import Scope, Integer, String 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') @@ -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.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..116460fb 100644 --- a/problem_builder/mcq.py +++ b/problem_builder/mcq.py @@ -48,6 +48,19 @@ class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock): """ An XBlock used to ask multiple-choice questions """ + CATEGORY = 'pb-mcq' + STUDIO_LABEL = _(u"Multiple Choice Question") + + message = String( + display_name=_("Message"), + help=_( + "General feedback provided when submitting. " + "(This is not shown if there is a more specific feedback tip for the choice selected by the learner.)" + ), + scope=Scope.content, + default="" + ) + student_choice = String( # {Last input submitted by the student default="", @@ -61,7 +74,7 @@ class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock): list_values_provider=QuestionnaireAbstractBlock.choice_values_provider, list_style='set', # Underered, unique items. Affects the UI editor. ) - editable_fields = QuestionnaireAbstractBlock.editable_fields + ('correct_choices', ) + editable_fields = QuestionnaireAbstractBlock.editable_fields + ('message', 'correct_choices', ) def describe_choice_correctness(self, choice_value): if choice_value in self.correct_choices: @@ -74,15 +87,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 +107,35 @@ def submit(self, submission): # Also send to the submissions API: sub_api.create_submission(self.student_item_key, submission) - result = { + return { 'submission': submission, + 'message': self.message_formatted, '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 +172,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"), @@ -183,7 +209,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 +222,26 @@ 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 + + @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 name. + """ + try: + return super(RatingBlock, self).url_name + except AttributeError: + return self.name + + def student_view(self, context): + fragment = super(RatingBlock, self).student_view(context) + rendering_for_studio = None + if context: # Workbench does not provide context + rendering_for_studio = context.get('author_edit_view') + if rendering_for_studio: + fragment.add_content(loader.render_template('templates/html/rating_edit_footer.html', { + "url_name": self.url_name + })) return fragment diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py index 5c9e8d1f..ae7f7cf3 100644 --- a/problem_builder/mentoring.py +++ b/problem_builder/mentoring.py @@ -21,20 +21,39 @@ # 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 .message import MentoringMessageBlock, get_message_label +from .mixins import ( + _normalize_id, QuestionMixin, MessageParentMixin, StepParentMixin, XBlockWithTranslationServiceMixin +) +from .step_review import ReviewStepBlock +from xblockutils.helpers import child_isinstance from xblockutils.resources import ResourceLoader -from xblockutils.studio_editable import StudioEditableXBlockMixin, StudioContainerXBlockMixin +from xblockutils.settings import XBlockWithSettingsMixin, ThemableXBlockMixin +from xblockutils.studio_editable import ( + NestedXBlockSpec, StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin, +) + +from problem_builder.answer import AnswerBlock, AnswerRecapBlock +from problem_builder.mcq import MCQBlock, RatingBlock +from problem_builder.mrq import MRQBlock +from problem_builder.plot import PlotBlock +from problem_builder.slider import SliderBlock +from problem_builder.table import MentoringTableBlock + try: # Used to detect if we're in the workbench so we can add Font Awesome @@ -47,9 +66,9 @@ log = logging.getLogger(__name__) loader = ResourceLoader(__name__) -_default_theme_config = { - 'package': 'problem_builder', - 'locations': ['public/themes/lms.css'] +_default_options_config = { + 'pb_mcq_hide_previous_answer': False, # this works for both MCQs and MRQs. + 'pb_hide_feedback_if_attempts_remain': False, } @@ -61,10 +80,154 @@ 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, XBlockWithSettingsMixin, + StudioEditableXBlockMixin, ThemableXBlockMixin, 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 + ) + weight = Float( + display_name=_("Weight"), + help=_("Defines the maximum total grade of the block."), + default=1, + scope=Scope.settings, + 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 + has_score = True # The Problem/Step Builder XBlocks produce scores. (Their children do not send scores to the LMS.) + + icon_class = 'problem' + block_settings_key = 'mentoring' + options_key = 'options' + + default_theme_config = { + 'package': 'problem_builder', + 'locations': ['public/themes/lms.css'] + } + + @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_options(self): + """ + Get options settings for this block from settings service. + + Fall back on default options if xblock settings have not been customized at all + or no customizations for options available. + """ + xblock_settings = self.get_xblock_settings(default={}) + if xblock_settings and self.options_key in xblock_settings: + return xblock_settings[self.options_key] + return _default_options_config + + def get_option(self, option): + """ + Get value of a specific instance-wide `option`. + """ + return self.get_options().get(option) + + @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') + if (event_type == 'grade'): + # This handler can be called from the browser. Don't allow the browser to submit arbitrary grades ;-) + raise JsonHandlerError(403, "Posting grade events from the browser is forbidden.") + + 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 + + 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 + + +class MentoringBlock(BaseMentoringBlock, StudioContainerWithNestedXBlocksMixin, StepParentMixin): """ An XBlock providing mentoring capabilities @@ -88,13 +251,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?"), @@ -118,17 +274,10 @@ class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioC ) # Settings - weight = Float( - display_name=_("Weight"), - help=_("Defines the maximum total grade of the block."), - default=1, - scope=Scope.settings, - enforce_type=True - ) 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 +292,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 +310,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,78 +324,149 @@ 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' + @property + def allowed_nested_blocks(self): + """ + Returns a list of allowed nested XBlocks. Each item can be either + * An XBlock class + * A NestedXBlockSpec - def _(self, text): - """ translate text """ - return self.runtime.service(self, "i18n").ugettext(text) + 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 + """ + additional_blocks = [] + try: + from xmodule.video_module.video_module import VideoDescriptor + additional_blocks.append(NestedXBlockSpec( + VideoDescriptor, category='video', label=_(u"Video") + )) + except ImportError: + pass + try: + from imagemodal import ImageModal + additional_blocks.append(NestedXBlockSpec( + ImageModal, category='imagemodal', label=_(u"Image Modal") + )) + except ImportError: + pass + + message_block_shims = [ + NestedXBlockSpec( + MentoringMessageBlock, + category='pb-message', + boilerplate=message_type, + label=get_message_label(message_type), + ) + for message_type in ( + 'completed', + 'incomplete', + 'max_attempts_reached', + ) + ] + + if self.is_assessment: + message_block_shims.append( + NestedXBlockSpec( + MentoringMessageBlock, + category='pb-message', + boilerplate='on-assessment-review', + label=get_message_label('on-assessment-review'), + ) + ) + + return [ + NestedXBlockSpec(AnswerBlock, boilerplate='studio_default'), + MCQBlock, RatingBlock, MRQBlock, + NestedXBlockSpec(None, category="html", label=self._("HTML")), + AnswerRecapBlock, MentoringTableBlock, PlotBlock, SliderBlock + ] + additional_blocks + message_block_shims @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) - 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): + from .questionnaire import QuestionnaireAbstractBlock # Import here to avoid circular dependency + # 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"" + mcq_hide_previous_answer = self.get_option('pb_mcq_hide_previous_answer') + 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): 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: + if mcq_hide_previous_answer and isinstance(child, QuestionnaireAbstractBlock): + context['hide_prev_answer'] = True + else: + context['hide_prev_answer'] = False child_fragment = child.render('mentoring_view', context) except NoSuchViewError: if child.scope_ids.block_type == 'html' and getattr(self.runtime, 'is_author_mode', False): @@ -261,16 +481,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: @@ -319,46 +544,155 @@ def next_step_url(self): return '/jump_to_id/{}'.format(self.next_step) @property - def url_name(self): + def hide_feedback(self): + return self.get_option("pb_hide_feedback_if_attempts_remain") and not self.max_attempts_reached + + def get_message(self, completed): """ - 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 normal mode. """ - try: - return super(MentoringBlock, self).url_name - except AttributeError: - return unicode(self.scope_ids.usage_id) + 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') - @XBlock.json_handler - def view(self, data, suffix=''): + @property + def assessment_message(self): """ - Current HTML view of the XBlock, for refresh by client + Get the message to display to a student following a submission in assessment mode. """ - frag = self.student_view({}) - return {'html': frag.content} + 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 getattr(self.runtime, 'replace_jump_to_id_urls', None) is not None: + 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 publish_event(self, data, suffix=''): + def get_results(self, queries, suffix=''): """ - Publish data for analytics purposes + 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. """ - event_type = data.pop('event_type') - self.runtime.publish(self, event_type, data) + 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.' + } - return {'result': 'ok'} + results, completed, show_message = self._get_assessment_results(queries) + mentoring_completed = True + + result = { + 'results': results, + 'completed': completed, + 'step': self.step, + 'max_attempts': self.max_attempts, + 'num_attempts': self.num_attempts, + } + + if show_message: + result['message'] = self.get_message(mentoring_completed) + + return result + + def _get_standard_results(self): + """ + Gets previous submissions results as if submit was called with exactly the same values as last time. + """ + results = [] + completed = True + show_message = (not self.hide_feedback) and bool(self.student_results) + + # In standard mode, all children are visible simultaneously, so need to collect results for 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 +700,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, + 'max_value': self.max_score(), }) - 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,20 +735,38 @@ 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 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) + def handle_assessment_submit(self, submissions, suffix): completed = False 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 +795,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': self.max_score(), + '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 +815,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 +850,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 +878,344 @@ def validate(self): )) return validation - def author_preview_view(self, context): + def author_edit_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. + Add some HTML to the author view that allows authors to add child blocks. """ - fragment = self.student_view(context) + local_context = context.copy() + local_context['author_edit_view'] = True + fragment = super(MentoringBlock, self).author_edit_view(local_context) fragment.add_content(loader.render_template('templates/html/mentoring_url_name.html', { - "url_name": self.url_name + '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) + 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/container_edit.js')) + fragment.initialize_js('ProblemBuilderContainerEdit') + return fragment - def author_edit_view(self, context): + @staticmethod + def workbench_scenarios(): """ - Add some HTML to the author view that allows authors to add child blocks. + Scenarios displayed by the workbench. Load them from external (private) repository """ - fragment = super(MentoringBlock, self).author_edit_view(context) - 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 + 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', 'weight') + + @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] + + @property + def active_step_safe(self): + """ + Get self.active_step and double-check that it is a valid value. + The stored value could be invalid if this block has been edited and new steps were + added/deleted. + """ + active_step = self.active_step + if active_step >= 0 and active_step < len(self.step_ids): + return active_step + if active_step == -1 and self.has_review_step: + return active_step # -1 indicates the review step + return 0 + + def get_active_step(self): + """ Get the active step as an instantiated XBlock """ + block = self.runtime.get_block(self.step_ids[self.active_step_safe]) + if block is None: + log.error("Unable to load step builder step child %s", self.step_ids[self.active_step_safe]) + return block + + @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): + return any(child_isinstance(self, child_id, ReviewStepBlock) for child_id in self.children) + + @property + def review_step(self): + """ Get the Review Step XBlock child, if any. Otherwise returns None """ + for step_id in self.children: + if child_isinstance(self, step_id, ReviewStepBlock): + return self.runtime.get_block(step_id) + + @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. """ + if self.max_attempts > 0 and self.num_attempts >= self.max_attempts: + # Review tips are only shown if the student is allowed to try again. + return [] + 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 getattr(self.runtime, 'replace_jump_to_id_urls', None) is not None: + 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 + + def student_view(self, context): + fragment = Fragment() + children_contents = [] + + context = context or {} + context['hide_prev_answer'] = True # For Step Builder, we don't show the users' old answers when they try again + context['score_summary'] = self.get_score_summary() + 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.")) + else: + 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/mentoring_edit.css')) - fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/mentoring_edit.js')) - fragment.initialize_js('MentoringEditComponents') + 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/step_util.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") + + self.include_theme_files(fragment) + + fragment.initialize_js('MentoringWithStepsBlock', { + 'show_extended_feedback': self.show_extended_feedback(), + }) + return fragment - def get_content_titles(self): + @property + def allowed_nested_blocks(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. + 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 """ - return [self.display_name] + # Import here to avoid circular dependency + from .step import MentoringStepBlock + return [ + MentoringStepBlock, + NestedXBlockSpec(ReviewStepBlock, single_instance=True), + ] - @staticmethod - def workbench_scenarios(): + @XBlock.json_handler + def submit(self, data, suffix=None): """ - Scenarios displayed by the workbench. Load them from external (private) repository + Called when the user has submitted the answer[s] for the current step. """ - return loader.load_scenarios_from_path('templates/xml') + # First verify that active_step is correct: + if data.get("active_step") != self.active_step_safe: + raise JsonHandlerError(400, "Invalid Step. Refresh the page and try again.") + + # The step child will process the data: + step_block = self.get_active_step() + if not step_block: + raise JsonHandlerError(500, "Unable to load the current step block.") + response_data = step_block.submit(data) + + # Update the active step: + new_value = self.active_step_safe + 1 + if new_value < len(self.step_ids): + self.active_step = new_value + elif new_value == len(self.step_ids): + # The user just completed the final step. + # Update the number of attempts: + self.num_attempts += 1 + # Do we need to render a review (summary of the user's score): + if self.has_review_step: + self.active_step = -1 + response_data['review_html'] = self.runtime.render(self.review_step, "mentoring_view", { + 'score_summary': self.get_score_summary(), + }).content + response_data['num_attempts'] = self.num_attempts + # And publish the score: + score = self.score + grade_data = { + 'value': score.raw, + 'max_value': self.max_score(), + } + self.runtime.publish(self, 'grade', grade_data) + + response_data['active_step'] = self.active_step + return response_data + + def get_score_summary(self): + if self.num_attempts == 0: + return {} + 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': score.correct, + 'incorrect': score.incorrect, + 'partial': score.partially_correct, + 'complete': self.complete, + 'max_attempts_reached': self.max_attempts_reached, + 'show_extended_review': self.show_extended_feedback(), + '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): + return self.student_view(context) + + def author_edit_view(self, context): + """ + Add some HTML to the author view that allows authors to add child blocks. + """ + 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/container_edit.js')) + fragment.initialize_js('ProblemBuilderContainerEdit') + return fragment diff --git a/problem_builder/message.py b/problem_builder/message.py index d57d52d2..a1bf3942 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,75 @@ 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"), + "studio_label": _(u'Message (Complete)'), + "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"), + "studio_label": _(u'Message (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"), + "studio_label": _(u'Message (Max # 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"), + "studio_label": _(u'Message (Assessment Review)'), + "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 if question was answered incorrectly"), + "default": _( + u"Review ____." + ), + "description": _( + u"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." + ), + }, + } + + has_author_view = True + content = String( display_name=_("Message"), help=_("Message to display upon completion"), @@ -53,45 +119,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 +179,23 @@ 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)") + + +def get_message_label(type): + return MentoringMessageBlock.MESSAGE_TYPES[type]['studio_label'] diff --git a/problem_builder/migrations/0002_auto_20160121_1525.py b/problem_builder/migrations/0002_auto_20160121_1525.py new file mode 100644 index 00000000..65c7e0ed --- /dev/null +++ b/problem_builder/migrations/0002_auto_20160121_1525.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('problem_builder', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Share', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('submission_uid', models.CharField(max_length=32)), + ('block_id', models.CharField(max_length=255, db_index=True)), + ('notified', models.BooleanField(default=False, db_index=True)), + ('shared_by', models.ForeignKey(related_name='problem_builder_shared_by', to=settings.AUTH_USER_MODEL)), + ('shared_with', models.ForeignKey(related_name='problem_builder_shared_with', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AlterUniqueTogether( + name='share', + unique_together=set([('shared_by', 'shared_with', 'block_id')]), + ), + ] diff --git a/problem_builder/mixins.py b/problem_builder/mixins.py new file mode 100644 index 00000000..79df1df1 --- /dev/null +++ b/problem_builder/mixins.py @@ -0,0 +1,178 @@ +from lazy import lazy +from xblock.fields import String, Boolean, Float, Scope, UNIQUE_ID +from xblock.fragment import Fragment +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 getattr(self.runtime, 'replace_jump_to_id_urls', None) is not None: + 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) + + +class NoSettingsMixin(object): + """ Mixin for an XBlock that has no settings """ + + def studio_view(self, _context=None): + """ Studio View """ + return Fragment(u'

{}

'.format(self._("This XBlock does not have any settings."))) 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..ceb44930 100644 --- a/problem_builder/mrq.py +++ b/problem_builder/mrq.py @@ -22,7 +22,7 @@ import logging -from xblock.fields import List, Scope, Boolean +from xblock.fields import List, Scope, Boolean, String from xblock.validation import ValidationMessage from .questionnaire import QuestionnaireAbstractBlock from xblockutils.resources import ResourceLoader @@ -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=[], @@ -68,6 +71,12 @@ class MRQBlock(QuestionnaireAbstractBlock): list_style='set', # Underered, unique items. Affects the UI editor. default=[], ) + message = String( + display_name=_("Message"), + help=_("General feedback provided when submitting"), + scope=Scope.content, + default="" + ) hide_results = Boolean(display_name="Hide results", scope=Scope.content, default=False) editable_fields = ( 'question', 'required_choices', 'ignored_choices', 'message', 'display_name', @@ -81,16 +90,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,33 +137,28 @@ 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, - 'message': self.message, + 'message': self.message_formatted, 'weight': self.weight, '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..69df555e --- /dev/null +++ b/problem_builder/plot.py @@ -0,0 +1,455 @@ +# -*- 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 xblock.validation import ValidationMessage +from xblockutils.helpers import child_isinstance +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 and/or rating 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 or rating 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.url_name == q1: + r1 = response_function(question, question_id) + if question.url_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, + } + + @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. + """ + return [PlotOverlayBlock] + + @lazy + def overlay_ids(self): + """ + Get the usage_ids of all of this XBlock's children that are overlays. + """ + return [ + _normalize_id(child_id) for child_id in self.children if + child_isinstance(self, child_id, PlotOverlayBlock) + ] + + @lazy + def overlays(self): + """ + Get the overlay children of this block. + """ + return [self.runtime.get_block(overlay_id) for overlay_id in self.overlay_ids] + + @lazy + def overlay_data(self): + if not self.claims: + return [] + + overlay_data = [] + claims = self.claims.split('\n') + for index, overlay in enumerate(self.overlays): + claims_json = [] + if overlay.claim_data: + claim_data = overlay.claim_data.split('\n') + for claim, data in zip(claims, claim_data): + claim = claim.split(', ')[0] + r1, r2 = data.split(', ') + claims_json.append([claim, int(r1), int(r2)]) + claims_json = json.dumps(claims_json) + overlay_data.append({ + 'plot_label': overlay.plot_label, + 'point_color': overlay.point_color, + 'description': overlay.description, + 'citation': overlay.citation, + 'claims_json': claims_json, + 'position': index, + }) + return overlay_data + + @lazy + def claims_display(self): + if not self.claims: + return [] + + claims = [] + for claim in self.claims.split('\n'): + claim, q1, q2 = claim.split(', ') + claims.append([claim, q1, q2]) + return claims + + def author_preview_view(self, context): + context['self'] = self + fragment = Fragment() + fragment.add_content(loader.render_template('templates/html/plot_preview.html', context)) + fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/plot-preview.css')) + if self.overlay_ids: + fragment.add_content( + u"

{}

".format( + _(u"In addition to the default and average overlays the plot includes the following overlays:") + )) + for overlay in self.overlays: + overlay_fragment = self._render_child_fragment(overlay, context, view='mentoring_view') + fragment.add_frag_resources(overlay_fragment) + fragment.add_content(overlay_fragment.content) + return fragment + + 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 + + def author_edit_view(self, context): + """ + Add some HTML to the author view that allows authors to add child blocks. + """ + fragment = super(PlotBlock, self).author_edit_view(context) + fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/problem-builder-edit.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/container_edit.js')) + fragment.initialize_js('ProblemBuilderContainerEdit') + return fragment + + +@XBlock.needs('i18n') +class PlotOverlayBlock(StudioEditableXBlockMixin, XBlockWithPreviewMixin, XBlock): + """ + XBlock that represents a user-defined overlay for a plot block. + """ + + CATEGORY = 'sb-plot-overlay' + STUDIO_LABEL = _(u"Plot Overlay") + + # Settings + display_name = String( + display_name=_("Overlay title"), + default="Overlay", + scope=Scope.content + ) + + plot_label = String( + display_name=_("Plot label"), + help=_("Label for button that allows to toggle visibility of this overlay"), + default="", + scope=Scope.content + ) + + point_color = String( + display_name=_("Point color"), + help=_("Point color to use for this overlay"), + default="", + scope=Scope.content + ) + + description = String( + display_name=_("Description"), + help=_("Description of this overlay (optional)"), + default="", + scope=Scope.content + ) + + citation = String( + display_name=_("Citation"), + help=_("Source of data belonging to this overlay (optional)"), + default="", + scope=Scope.content + ) + + claim_data = String( + display_name=_("Claim data"), + help=_( + 'Claim data to include in this overlay. ' + 'Each line defines a tuple of the form "q1, q2", ' + 'where "q1" is the value associated with the first scale or rating question, ' + 'and "q2" is the value associated with the second scale or rating question. ' + 'Note that data will be associated with claims in the order that they are defined in the parent plot.' + ), + default="", + multiline_editor=True, + resettable_editor=False + ) + + editable_fields = ( + "plot_label", "point_color", "description", "citation", "claim_data" + ) + + def validate_field_data(self, validation, data): + """ + Validate this block's field data. + """ + super(PlotOverlayBlock, self).validate_field_data(validation, data) + + def add_error(msg): + validation.add(ValidationMessage(ValidationMessage.ERROR, msg)) + + if not data.plot_label.strip(): + add_error(_(u"No plot label set. Button for toggling visibility of this overlay will not have a label.")) + if not data.point_color.strip(): + add_error(_(u"No point color set. This overlay will not work correctly.")) + + # If parent plot is associated with one or more claims, prompt user to add claim data + parent = self.get_parent() + if parent.claims.strip() and not data.claim_data.strip(): + add_error(_(u"No claim data provided. This overlay will not work correctly.")) + + def author_preview_view(self, context): + return self.student_view(context) + + def mentoring_view(self, context): + context = context.copy() if context else {} + context['hide_header'] = True + return self.author_preview_view(context) + + def student_view(self, context): + context['self'] = self + fragment = Fragment() + fragment.add_content(loader.render_template('templates/html/overlay.html', context)) + fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/overlay.css')) + 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..dc80f035 --- /dev/null +++ b/problem_builder/public/css/instructor_tool.css @@ -0,0 +1,76 @@ +.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; + white-space: nowrap; +} +.data-export-results th, +.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/overlay.css b/problem_builder/public/css/overlay.css new file mode 100644 index 00000000..5e584848 --- /dev/null +++ b/problem_builder/public/css/overlay.css @@ -0,0 +1,7 @@ +.sb-plot-overlay { + margin-bottom: 10px; +} + +.italic { + font-style: italic; +} diff --git a/problem_builder/public/css/plot-preview.css b/problem_builder/public/css/plot-preview.css new file mode 100644 index 00000000..15c25669 --- /dev/null +++ b/problem_builder/public/css/plot-preview.css @@ -0,0 +1,23 @@ +.sb-plot table { + width: 100%; + margin-top: 1em; + margin-bottom: 1em; + border: 2px solid #999; +} + +.sb-plot thead { + border-bottom: 2px solid #999; + background-color: #ddd; + font-weight: bold; + white-space: nowrap; +} + +.sb-plot tr:nth-child(even) { + background-color: #eee; +} + +.sb-plot th, +.sb-plot td { + border-left: 1px solid #999; + padding: 5px; +} diff --git a/problem_builder/public/css/plot.css b/problem_builder/public/css/plot.css new file mode 100644 index 00000000..674d1d5b --- /dev/null +++ b/problem_builder/public/css/plot.css @@ -0,0 +1,29 @@ +.sb-plot { + overflow: auto; +} + +.quadrants label { + font-weight: bold; +} + +.overlays { + float: right; + width: 40%; +} + +.overlays input { + margin-top: 10px; + margin-right: 5px; +} + +.quadrants input, .overlays input { + background-color: rgb(204, 204, 204); +} + +.plot-info { + margin-top: 15px; +} + +.plot-info-header { + font-weight: bold; +} 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..a910faaa --- /dev/null +++ b/problem_builder/public/css/problem-builder-edit.css @@ -0,0 +1,78 @@ +/* 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] .author-preview-view, +.xblock[data-block-type=step-builder] .author-preview-view, +.xblock[data-block-type=problem-builder] .author-preview-view, +.xblock[data-block-type=mentoring] .author-preview-view { + margin: 10px; +} + +.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-plot] .add-xblock-component .new-component .new-component-type .add-xblock-component-button, +.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: auto; + height: auto; + line-height: 30px; + padding-left: 1em; + padding-right: 1em; +} + +.xblock[data-block-type=sb-plot] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled, +.xblock[data-block-type=sb-plot] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled:hover, +.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=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[data-block-type=sb-review-step] .conditional-message-help p { + font-size: 0.8em; + font-style: italic; + margin-bottom: 0.4em; +} + +.xblock-preview_view-sb-conditional-message { + border-top: 1px solid #ddd; + margin-top: 1.3em; + padding-top: 0.2em; +} + +.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 55% rename from problem_builder/public/css/mentoring.css rename to problem_builder/public/css/problem-builder.css index 4b97c44a..c77de78a 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 { @@ -44,7 +41,7 @@ margin-top: 10px; } -.mentoring h3 { +.xblock .mentoring h3 { margin-top: 0px; margin-bottom: 7px; } @@ -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,94 @@ .mentoring input[type="radio"] { margin: 0; } + +.mentoring .review-list { + list-style: none; + padding-left: 0 !important; + margin-left: 0; + margin-bottom: 0.4em; +} +.mentoring .review-list li { + display: inline; +} + +.mentoring .review-list li a{ + font-weight: bold; +} + +.mentoring .results-section { + margin-left: 50px; +} + +.mentoring .results-section p { + margin-bottom: 4px; + padding-top: 4px; +} + +.mentoring .clear { + display: block; + clear: both; +} + +.mentoring .review-link { + float: right; + display: none; +} + +.mentoring p.review-tips-intro { + margin-top: 1.2em; + margin-bottom: 0; + font-weight: bold; +} + +.mentoring .review-tips-list { + margin-top: 0; + padding-top: 0; +} +.mentoring .review-tips-list li { + margin-left: 0.5em; + padding-left: 0; +} +.mentoring .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); +} + +.mentoring .sb-step { + position: relative; +} + +.assessment-question-block div[data-block-type=sb-step], +.assessment-question-block div[data-block-type=sb-review-step] { + display: none; /* Hidden until revealed by JS */ +} + +.mentoring .sb-step .sb-step-message { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 1.5em; + background-color: white; + box-shadow: 0 10px 20px #5C5C5C; +} + +.mentoring .copyright { + color: #AAA; + clear: both; + font-size: 10px; + margin: 3em 0; +} + +.mentoring .copyright a { + color: #69C0E8; +} diff --git a/problem_builder/public/css/questionnaire-edit.css b/problem_builder/public/css/questionnaire-edit.css index e3e5b91f..d46b143c 100644 --- a/problem_builder/public/css/questionnaire-edit.css +++ b/problem_builder/public/css/questionnaire-edit.css @@ -1,6 +1,25 @@ /* Custom appearance for our "Add" buttons */ .xblock .add-xblock-component .new-component .new-component-type .add-xblock-component-button { - width: 200px; - height: 30px; + width: auto; + height: auto; line-height: 30px; + padding-left: 1em; + padding-right: 1em; +} + +.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..65d0c278 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; - width: 40px; - vertical-align: middle; + width: 34px; + 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,12 @@ } .mentoring .choices-list .choice-selector { - margin-right: 5px; -} - -.mentoring .choice-label { display: inline-block; - margin-top: 8px; - margin-bottom: 5px; - 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; +.mentoring .choice-tips-container, +.mentoring .choice-label { + display: table-cell; + vertical-align: top; + width: 50%; } diff --git a/problem_builder/public/js/answer.js b/problem_builder/public/js/answer.js index 8d6c48d9..0929bbe8 100644 --- a/problem_builder/public/js/answer.js +++ b/problem_builder/public/js/answer.js @@ -2,35 +2,45 @@ function AnswerBlock(runtime, element) { return { mode: null, init: function(options) { - // register the child validator + // Clear results and validate block when answer changes $(':input', element).on('keyup', options.onChange); this.mode = options.mode; - var checkmark = $('.answer-checkmark', element); - var completed = $('.xblock-answer', element).data('completed'); - if (completed === 'True' && this.mode === 'standard') { - checkmark.addClass('checkmark-correct icon-ok fa-check'); - } + this.validateXBlock = options.validateXBlock; + + // 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(); }, - handleSubmit: function(result) { - if (this.mode === 'assessment') - return; + handleReview: function(result) { + $('textarea', element).prop('disabled', true); + }, + + handleSubmit: function(result, options) { 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 (options.hide_results || this.mode === 'assessment') { + // In assessment mode, 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'); + checkmark.attr('aria-label', checkmark.data('label_correct')); + } + else { + checkmark.addClass('checkmark-incorrect icon-exclamation fa-exclamation'); + checkmark.attr('aria-label', checkmark.data('label_incorrect')); + } } }, @@ -54,7 +64,7 @@ function AnswerBlock(runtime, element) { var answer_length = input_value.length; var data = input.data(); - // an answer cannot be empty event if min_characters is 0 + // An answer cannot be empty even if min_characters is 0 if (_.isNumber(data.min_characters)) { var min_characters = _.max([data.min_characters, 1]); if (answer_length < min_characters) { @@ -62,6 +72,29 @@ function AnswerBlock(runtime, element) { } } return true; + }, + + refreshAnswer: function() { + var self = this; + $.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); + } + if (self.validateXBlock) { + self.validateXBlock(); + } + }, + }); } }; } 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/container_edit.js b/problem_builder/public/js/container_edit.js new file mode 100644 index 00000000..7814f7e7 --- /dev/null +++ b/problem_builder/public/js/container_edit.js @@ -0,0 +1,17 @@ +function ProblemBuilderContainerEdit(runtime, element) { + "use strict"; + + // Standard initialization for any Problem Builder / Step Builder container XBlocks + // that are instances of StudioContainerXBlockWithNestedXBlocksMixin + + StudioContainerXBlockWithNestedXBlocksMixin(runtime, element); + + if (window.ProblemBuilderUtil) { + ProblemBuilderUtil.transformClarifications(element); + } + + // Add a "mentoring" class to the root XBlock so we can use it as a + // selector. We cannot just add a div.mentoring wrapper around our children + // since it breaks jQuery drag-and-drop re-ordering of children. + $(".wrapper-xblock.level-page > .xblock-render > .xblock").addClass("mentoring"); +} diff --git a/problem_builder/public/js/dashboard.js b/problem_builder/public/js/dashboard.js deleted file mode 100644 index bb7e879a..00000000 --- a/problem_builder/public/js/dashboard.js +++ /dev/null @@ -1,63 +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) - - // Expand relative urls and urls without an explicit protocol into absolute urls - var a = document.createElement('a'); - a.href = imgURL; - imgURL = a.href; - - // If the image is from another domain, just return its URL. We can't - // create a data URL from cross-domain images: - // https://html.spec.whatwg.org/multipage/scripting.html#dom-canvas-todataurl - if (a.origin !== window.location.origin) - return imgURL; - - 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..bd6fe71c 100644 --- a/problem_builder/public/js/mentoring.js +++ b/problem_builder/public/js/mentoring.js @@ -8,7 +8,9 @@ function MentoringBlock(runtime, element) { var attemptsTemplate = _.template($('#xblock-attempts-template').html()); var data = $('.mentoring', element).data(); var children = runtime.children(element); - var steps = runtime.children(element).filter(function(c) { return c.element.className.indexOf('assessment_step_view') > -1; }); + var steps = runtime.children(element).filter(function(c) { + return $(c.element).attr("class").indexOf('assessment_step_view') > -1; + }); var step = data.step; var mentoring = { @@ -61,17 +63,13 @@ 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; } } function setContent(dom, content) { dom.html(''); dom.append(content); - var template = $('#light-child-template', dom).html(); - if (template) { - dom.append(template); - } } function renderAttempts() { @@ -107,12 +105,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..c1c3009d 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,9 @@ 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.attr('aria-label', ''); + checkmark.off('click'); // Clear all selections $('input[type=radio], input[type=checkbox]', element).prop('checked', false); @@ -21,14 +26,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 +68,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 +92,7 @@ function MentoringAssessmentView(runtime, element, mentoring) { return; active_child = -1; + notify('navigation', {state: 'lock'}) displayNextChild(); tryAgainDOM.hide(); submitDOM.show(); @@ -56,7 +102,6 @@ function MentoringAssessmentView(runtime, element, mentoring) { } function tryAgain() { - var success = true; var handlerUrl = runtime.handlerUrl(element, 'try_again'); if (submitXHR) { submitXHR.abort(); @@ -65,21 +110,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 +157,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,60 +254,83 @@ 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') { + checkmark.attr('aria-label', checkmark.data('label_partial')); + } else if (response.completed === 'correct') { checkmark.addClass('checkmark-correct icon-ok fa-check'); + checkmark.attr('aria-label', checkmark.data('label_correct')); } else { checkmark.addClass('checkmark-incorrect icon-exclamation fa-exclamation'); + checkmark.attr('aria-label', checkmark.data('label_incorrect')); } 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 +347,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..93a374cd 100644 --- a/problem_builder/public/js/mentoring_standard_view.js +++ b/problem_builder/public/js/mentoring_standard_view.js @@ -4,49 +4,92 @@ 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 hide_results = response.message === undefined; + + 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, + hide_results: hide_results, }; 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); - if (messagesDOM.html().trim()) { - messagesDOM.prepend('
' + mentoring.data.feedback_label + '
'); + if (!hide_results) { + mentoring.setContent(messagesDOM, response.message); + if (messagesDOM.html().trim()) { + messagesDOM.prepend('
' + mentoring.data.feedback_label + '
'); + messagesDOM.show(); + } + } + + // Data may have changed, we have to re-validate. + validateXBlock(); + + // 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 && mentoring.data.hide_feedback !== 'True')) { + submitDOM.attr('disabled', 'disabled'); + } + } + + 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(); } - submitDOM.attr('disabled', 'disabled'); + if (disable_submit) { + submitDOM.attr('disabled', 'disabled'); + } } - function submit() { - var success = true; + 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() { @@ -70,15 +113,20 @@ function MentoringStandardView(runtime, element, mentoring) { submitDOM.show(); var options = { - onChange: onChange + onChange: onChange, + validateXBlock: validateXBlock }; 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..4f76ff27 --- /dev/null +++ b/problem_builder/public/js/mentoring_with_steps.js @@ -0,0 +1,523 @@ +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 = []; + + 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); + } + } + + var activeStepIndex = $('.mentoring', element).data('active-step'); + var attemptsTemplate = _.template($('#xblock-attempts-template').html()); + var message = $('.sb-step-message', element); + var checkmark, submitDOM, nextDOM, reviewButtonDOM, tryAgainDOM, + gradeDOM, attemptsDOM, reviewLinkDOM, submitXHR; + var reviewStepDOM = $("div.xblock[data-block-type=sb-review-step], div.xblock-v1[data-block-type=sb-review-step]", element); + var reviewStepAnchor = $("").addClass("review-anchor").insertBefore(reviewStepDOM); + var hasAReviewStep = reviewStepDOM.length == 1; + + /** + * Returns the active step + * @returns MentoringStepBlock + */ + function getActiveStep() { + return steps[activeStepIndex]; + } + + /** + * Calls a function for each registered step. The object passed to this function is a MentoringStepBlock. + * + * @param func single arg function. + */ + function forEachStep(func) { + for (var idx=0; idx < steps.length; idx++) { + func(steps[idx]); + } + } + + /** + * Displays the active step + */ + function showActiveStep() { + var step = getActiveStep(); + step.showStep(); + } + + /** + * Hides all steps + */ + function hideAllSteps() { + forEachStep(function(step) { + step.hideStep(); + }); + } + + function isLastStep() { + return (activeStepIndex === steps.length-1); + } + + function atReviewStep() { + return (activeStepIndex === -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 showFeedback(response) { + if (response.step_status === 'correct') { + checkmark.addClass('checkmark-correct icon-ok fa-check'); + checkmark.attr('aria-label', checkmark.data('label_correct')); + } else if (response.step_status === 'partial') { + checkmark.addClass('checkmark-partially-correct icon-ok fa-check'); + checkmark.attr('aria-label', checkmark.data('label_partial')); + } else { + checkmark.addClass('checkmark-incorrect icon-exclamation fa-exclamation'); + checkmark.attr('aria-label', checkmark.data('label_incorrect')); + } + var step = getActiveStep(); + if (typeof step.showFeedback == 'function') { + step.showFeedback(response); + } + } + + function updateControls() { + submitDOM.attr('disabled', 'disabled'); + + nextDOM.removeAttr("disabled"); + if (nextDOM.is(':visible')) { nextDOM.focus(); } + + if (atReviewStep()) { + if (hasAReviewStep) { + reviewButtonDOM.removeAttr('disabled'); + } else { + if (someAttemptsLeft()) { + tryAgainDOM.removeAttr('disabled'); + tryAgainDOM.show(); + } else { + showAttempts(); + } + } + } + } + + function submit() { + submitDOM.attr('disabled', 'disabled'); // Disable the button until the results load. + var submitUrl = runtime.handlerUrl(element, 'submit'); + var activeStep = getActiveStep(); + var hasQuestion = activeStep.hasQuestion(); + var data = activeStep.getSubmitData(); + data["active_step"] = activeStepIndex; + $.post(submitUrl, JSON.stringify(data)).success(function(response) { + showFeedback(response); + activeStepIndex = response.active_step; + if (activeStepIndex === -1) { + // We are now showing the review step / end + // Update the number of attempts. + attemptsDOM.data('num_attempts', response.num_attempts); + reviewStepDOM.html($(response.review_html).html()); + updateControls(); + } else if (!hasQuestion) { + // This was a step with no questions, so proceed to the next step / review: + updateDisplay(); + } else { + // Enable the Next button so users can proceed. + updateControls(); + } + }); + } + + function getResults() { + getActiveStep().getResults(handleReviewResults); + } + + function handleReviewResults(response) { + // Show step-level feedback + showFeedback(response); + // Forward to active step to show answer level feedback + var step = getActiveStep(); + var results = response.results; + var options = { + checkmark: checkmark + }; + step.handleReview(results, options); + } + + + function clearSelections() { + forEachStep(function (step) { + $('input[type=radio], input[type=checkbox]', step.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'); + checkmark.attr('aria-label', ''); + hideAllSteps(); + hideReviewStep(); + attemptsDOM.html(''); + message.hide(); + } + + function updateNextLabel() { + var step = getActiveStep(); + 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(); + + // Reinstate default event handlers + nextDOM.off('click'); + nextDOM.on('click', updateDisplay); + reviewButtonDOM.on('click', showGrade); + + var step = getActiveStep(); + if (step.hasQuestion()) { // Step includes one or more questions + nextDOM.attr('disabled', 'disabled'); + submitDOM.show(); + if (isLastStep()) { // Step is last step + nextDOM.hide(); + if (hasAReviewStep) { // Step Builder includes review step + reviewButtonDOM.attr('disabled', 'disabled'); + reviewButtonDOM.show(); + } + } + } else { // Step does not include any questions + nextDOM.removeAttr('disabled'); + submitDOM.hide(); + if (isLastStep()) { // Step is last step + // Remove default event handler from button that displays review. + // This is necessary to make sure updateDisplay is not called twice + // when user clicks this button next; + // "submit" already does the right thing with respect to updating the display, + // and calling updateDisplay twice causes issues with scrolling behavior: + reviewButtonDOM.off(); + reviewButtonDOM.one('click', submit); + reviewButtonDOM.removeAttr('disabled'); + nextDOM.hide(); + if (hasAReviewStep) { // Step Builder includes review step + reviewButtonDOM.show(); + } + } else { // Step is not last step + // Remove default event handler from button that displays next step. + // This is necessary to make sure updateDisplay is not called twice + // when user clicks this button next; + // "submit" already does the right thing with respect to updating the display, + // and calling updateDisplay twice causes issues with scrolling behavior: + nextDOM.off(); + nextDOM.one('click', submit); + } + } + } + + // Scroll to top of this block + scrollIntoView(); + } + + function showReviewStep() { + if (someAttemptsLeft()) { + tryAgainDOM.removeAttr('disabled'); + } + + submitDOM.hide(); + nextDOM.hide(); + reviewButtonDOM.hide(); + tryAgainDOM.show(); + + // reviewStepDOM is detached in hideReviewStep + reviewStepDOM.insertBefore(reviewStepAnchor); + reviewStepDOM.show(); + } + + /** + * We detach review step from DOM, this is required to handle HTML + * blocks with embedded videos, that can be added to that step. + * + * NOTE: Review steps are handled differently than "normal" steps: + * the HTML contents of a review step are replaced with fresh + * contents in submit function. + */ + function hideReviewStep() { + reviewStepDOM.hide(); + reviewStepDOM.detach(); + } + + function getStepToReview(event) { + event.preventDefault(); + var stepIndex = parseInt($(event.target).data('step')) - 1; + jumpToReview(stepIndex); + } + + function jumpToReview(stepIndex) { + activeStepIndex = stepIndex; + cleanAll(); + showActiveStep(); + updateNextLabel(); + + if (isLastStep()) { + reviewButtonDOM.show(); + reviewButtonDOM.removeAttr('disabled'); + nextDOM.hide(); + nextDOM.attr('disabled', 'disabled'); + } else { + nextDOM.show(); + nextDOM.removeAttr('disabled'); + } + var step = getActiveStep(); + + tryAgainDOM.hide(); + if (step.hasQuestion()) { + submitDOM.show(); + } else { + submitDOM.hide(); + } + submitDOM.attr('disabled', 'disabled'); + reviewLinkDOM.show(); + + getResults(); + + // Scroll to top of this block + scrollIntoView(); + } + + 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 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 = getActiveStep(); + if (step) { + isValid = step.validate(); + } + if (!isValid) { + submitDOM.attr('disabled', 'disabled'); + } else { + submitDOM.removeAttr('disabled'); + } + } + + function initSteps(options) { + forEachStep(function (step) { + options.mentoring = { + setContent: setContent, + publish_event: publishEvent, + is_step_builder: true + }; + step.initChildren(options); + }); + } + + function setContent(dom, content) { + dom.html(''); + dom.append(content); + } + + 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(); + + // Scroll to top of this block + scrollIntoView(); + } + + function reviewNextStep() { + jumpToReview(activeStepIndex+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'}); + + activeStepIndex = result.active_step; + clearSelections(); + updateDisplay(); + tryAgainDOM.hide(); + submitDOM.show(); + if (! isLastStep()) { + nextDOM.off(); + nextDOM.on('click', updateDisplay); + nextDOM.show(); + reviewButtonDOM.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 scrollIntoView() { + // This function can be called multiple times per step initialization. + // We must make sure that only one animation is queued or running at any given time, + // that's why we use a special animation queue and make sure to .stop() any running/queued + // animations before enqueueing a new one. + var rootBlock = $(element), + rootBlockOffset = rootBlock.offset().top, + queue = 'sb-scroll', + props = {scrollTop: rootBlockOffset}, + opts = {duration: 500, queue: queue}; + $('html, body').stop(queue, true).animate(props, opts).dequeue(queue); + } + + 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 = $('.step-overall-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); + } + + reviewButtonDOM = $(element).find('.submit .input-review'); + reviewButtonDOM.on('click', showGrade); + + tryAgainDOM = $(element).find('.submit .input-try-again'); + tryAgainDOM.on('click', tryAgain); + + gradeDOM = $('.grade', element); + attemptsDOM = $('.attempts', element); + + reviewLinkDOM = $(element).find('.review-link'); + reviewLinkDOM.on('click', showGrade); + + // Add click handler that takes care of links to steps on the extended review: + $(element).on('click', 'a.step-link', getStepToReview); + + // 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/plot.js b/problem_builder/public/js/plot.js new file mode 100644 index 00000000..98a14ea4 --- /dev/null +++ b/problem_builder/public/js/plot.js @@ -0,0 +1,291 @@ +function PlotBlock(runtime, element) { + + // jQuery helpers + + jQuery.fn.isEmpty = function() { + return !$.trim($(this).html()); + }; + + jQuery.fn.isHidden = function() { + // Don't use jQuery :hidden selector here; + // this is necessary to ensure that result is independent of parent visibility + return $(this).css('display') === 'none'; + }; + + jQuery.fn.isVisible = function() { + // Don't use jQuery :visible selector here; + // this is necessary to ensure that result is independent of parent visibility + return $(this).css('display') !== 'none'; + }; + + // 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") + .tickValues([]); + + var yAxis = d3.svg.axis() + .scale(yScale) + .orient("left") + .tickValues([]); + + // 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), + overlayButtons = $('input.plot-overlay', 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 = buildSelector(klass), + selection = svgContainer.selectAll(selector); + if (selection.empty()) { + showOverlay(selection, claims, color, klass); + } else { + hideOverlay(selection); + if (refresh) { + toggleOverlay(claims, color, klass); + } + } + } + + function buildSelector(klass) { + var classes = klass.split(' '); + if (classes.length === 1) { + return "." + klass; + } + return '.' + classes.join('.'); + } + + function showOverlay(selection, claims, color, klass) { + selection + .data(claims) + .enter() + .append("circle") + .attr("class", klass) + .attr("data-tooltip", 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 toggleOverlayInfo(klass, hide) { + var plotInfo = $('.plot-info', element), + selector = buildSelector(klass), + overlayInfo = plotInfo.children(selector); + if (hide || overlayInfo.isVisible()) { + overlayInfo.hide(); + var overlayInfos = plotInfo.children('.plot-overlay'), + hidePlotInfo = true; + overlayInfos.each(function() { + var overlayInfo = $(this); + hidePlotInfo = hidePlotInfo && (overlayInfo.isHidden() || overlayInfo.isEmpty()); + }); + if (hidePlotInfo) { + plotInfo.hide(); + } + } else { + overlayInfo.show(); + if (!overlayInfo.isEmpty() && !plotInfo.isVisible()) { + plotInfo.show(); + } + } + } + + 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() { + toggleOverlay(averageClaims, averageColor, 'claim-average'); + toggleBorderColor(this, averageColor); + }); + + quadrantsButton.on('click', function() { + toggleQuadrantLabels(); + }); + + overlayButtons.each(function(index) { + + var overlayButton = $(this), + claims = overlayButton.data('claims'), + color = overlayButton.data('point-color'), + klass = overlayButton.attr('class'); + + overlayButton.on('click', function() { + toggleOverlay(claims, color, klass); + toggleBorderColor(this, color); + toggleOverlayInfo(klass); + }); + + // Hide overlay info initially + toggleOverlayInfo(klass, 'hide'); + + }); + + // Quadrant labels are off initially; color of button for toggling them should reflect this + quadrantsButton.css("border-color", "red"); + + // Hide plot info initially + $('.plot-info', element).hide(); + + // 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..703da5a0 100644 --- a/problem_builder/public/js/questionnaire.js +++ b/problem_builder/public/js/questionnaire.js @@ -1,5 +1,20 @@ // TODO: Split in two files +function display_message(message, messageView, checkmark){ + if (message) { + var msg = '
' + message + '
' + + '
'; + messageView.showMessage(msg); + if (checkmark) { + checkmark.addClass('checkmark-clickable'); + checkmark.on('click', function(ev) { + ev.stopPropagation(); + messageView.showMessage(msg); + }); + } + } +} + function MessageView(element, mentoring) { return { messageDOM: $('.feedback', element), @@ -18,16 +33,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'); @@ -66,6 +88,7 @@ function MessageView(element, mentoring) { this.allResultsDOM.removeClass( 'checkmark-incorrect icon-exclamation fa-exclamation checkmark-correct icon-ok fa-check' ); + this.allResultsDOM.attr('aria-label', ''); } }; } @@ -90,45 +113,47 @@ 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, options) { - mentoring = this.mentoring; + var mentoring = this.mentoring; var messageView = MessageView(element, mentoring); + messageView.clearResult(); - var choiceInputs = $('.choice 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; + var choiceInputDOM = $('.choice-selector input[value="'+ result.submission +'"]'); + var choiceDOM = choiceInputDOM.closest('.choice'); + var choiceResultDOM = $('.choice-result', choiceDOM); + var choiceTipsDOM = $('.choice-tips', choiceDOM); - if (result.status === "correct" && choiceInputDOM.val() === result.submission) { - choiceDOM.addClass('correct'); + // We're showing previous answers, so go ahead and display results as well + if (choiceInputDOM.prop('checked')) { + display_message(result.message, messageView, options.checkmark); + if (result.status === "correct") { + choiceInputDOM.addClass('correct'); choiceResultDOM.addClass('checkmark-correct icon-ok fa-check'); - } - else if (choiceInputDOM.val() === result.submission || _.isNull(result.submission)) { + choiceResultDOM.attr('aria-label', choiceResultDOM.data('label_correct')); + } else { choiceDOM.addClass('incorrect'); choiceResultDOM.addClass('checkmark-incorrect icon-exclamation fa-exclamation'); + choiceResultDOM.attr('aria-label', choiceResultDOM.data('label_incorrect')); } - - if (result.tips && choiceInputDOM.val() === result.submission) { - mentoring.setContent(choiceTipsDOM, result.tips); - messageView.showMessage(choiceTipsDOM); - } - - choiceTipsCloseDOM = $('.close', choiceTipsDOM); choiceResultDOM.off('click').on('click', function() { if (choiceTipsDOM.html() !== '') { messageView.showMessage(choiceTipsDOM); } }); - }); + if (result.tips) { + mentoring.setContent(choiceTipsDOM, result.tips); + messageView.showMessage(choiceTipsDOM); + } + } + if (_.isNull(result.submission)) { messageView.showMessage('
You have not provided an answer.
' + @@ -171,29 +196,36 @@ 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 mentoring = this.mentoring; var messageView = MessageView(element, mentoring); - if (result.message) { - messageView.showMessage('
' + result.message + '
'+ - '
'); - } - 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' || + (data.hide_prev_answer === 'True' && !mentoring.is_step_builder)); + // hide_prev_answer should only take effect when we initially render (previous) results, + // so set hide_prev_answer to False after initial render. + questionnaireDOM.data('hide_prev_answer', 'False'); + + if (!hide_results) { + display_message(result.message, messageView, options.checkmark); + } $.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 && @@ -201,14 +233,15 @@ function MRQBlock(runtime, element) { if (choice.completed) { choiceDOM.addClass('correct'); choiceResultDOM.addClass('checkmark-correct icon-ok fa-check'); + choiceResultDOM.attr('aria-label', choiceResultDOM.data('label_correct')); } else if (!choice.completed) { choiceDOM.addClass('incorrect'); choiceResultDOM.addClass('checkmark-incorrect icon-exclamation fa-exclamation'); + choiceResultDOM.attr('aria-label', choiceResultDOM.data('label_incorrect')); } 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..0684003a --- /dev/null +++ b/problem_builder/public/js/review_blocks.js @@ -0,0 +1,233 @@ +// 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) + + // Expand relative urls and urls without an explicit protocol into absolute urls + var a = document.createElement('a'); + a.href = imgURL; + imgURL = a.href; + + // If the image is from another domain, just return its URL. We can't + // create a data URL from cross-domain images: + // https://html.spec.whatwg.org/multipage/scripting.html#dom-canvas-todataurl + if (a.origin !== window.location.origin) + return imgURL; + + 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/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..a53ff032 --- /dev/null +++ b/problem_builder/public/js/step.js @@ -0,0 +1,125 @@ +function MentoringStepBlock(runtime, element) { + + var children = runtime.children(element); + + var submitXHR, resultsXHR, + message = $(element).find('.sb-step-message'); + + var childManager = new ProblemBuilderStepUtil.ChildManager(element, runtime); + + 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; + }, + + getSubmitData: function() { + 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, "submit"); + } + } + return data; + }, + + showFeedback: function(response) { + // Called when user has just submitted an answer or is reviewing their answer during extended feedback. + if (message.length) { + message.fadeIn(); + $(document).click(function() { + message.fadeOut(); + }); + } + }, + + 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]; + // Call handleReview first to ensure that choice-level feedback for MCQs is displayed: + // Before displaying feedback for a given choice, handleSubmit checks if it is selected. + // (If it isn't, we don't want to display feedback for it.) + // handleReview is responsible for setting the "checked" property to true + // for each choice that the student selected as part of their most recent submission. + // If it is called after handleSubmit, the check mentioned above will fail, + // and no feedback will be displayed. + callIfExists(child, 'handleReview', result); + callIfExists(child, 'handleSubmit', result, options); + } + } + }, + + getStepLabel: function() { + return $('.sb-step', element).data('next-button-label'); + }, + + hasQuestion: function() { + return $('.sb-step', element).data('has-question'); + }, + + /** + * Shows a step, updating all children. + */ + showStep: function () { + $(element).show(); + childManager.show(); + }, + + /** + * Hides a step, updating all children. + */ + hideStep: function () { + $(element).hide(); + childManager.hide(); + } + }; + +} diff --git a/problem_builder/public/js/step_util.js b/problem_builder/public/js/step_util.js new file mode 100644 index 00000000..50c5f58e --- /dev/null +++ b/problem_builder/public/js/step_util.js @@ -0,0 +1,120 @@ +(function () { + + /** + * Manager for HTML XBlocks. These blocks are hidden by detaching and shown + * by re-attaching them to the DOM. This is only way to generically + * handle things like video players (they should stop playing when removed from DOM). + * + * @param html an html xblock + */ + function HtmlManager(html) { + var $element = $(html.element); + var $anchor = $("").addClass("sb-video-anchor").insertBefore($element); + this.show = function () { + $element.insertAfter($anchor); + }; + this.hide = function () { + $element.detach() + }; + } + + /** + * + * Manager for HTML Video child. Videos are re-sized when showing them. + * @param video an video xblock + * + */ + function VideoManager(video) { + this.show = function () { + if (typeof video.resizer === 'undefined') { + // This one is tricky: but it looks like resizer is undefined only if the video is on the + // step that is initially visible (and then no resizing is necessary) + return; + } + video.resizer.align(); + }; + /** + * Videos should be paused when user leaves a step containing a video. There is was a proposed implementation + * but since it didn't work on every system we decided to drop it (it was out of scope for current task + * nevertheless). See OC-1441 for details. + */ + this.hide = function () {}; + } + + /** + * Manager for Plot Xblocks. Handles updating a plot before displaying it. + * @param plot + */ + function PlotManager(plot) { + this.show = function () { + plot.update(); + }; + this.hide = function () {}; + } + + + function ChildManager(xblock_element, runtime) { + + var Managers = { + 'video': VideoManager, + 'sb-plot': PlotManager + }; + + var children = runtime.children(xblock_element); + + /** + * A list of managers for children that need special care when showing or hiding. + * + * @type {show, hide}[] + */ + var managedChildren = []; + + /*** + * This is a workaround for issue where jquery.xblock.Runtime doesn't return HTML blocks when querying + * for children. + * + * This can be removed when: + * + * * We allow inclusion of Ooyala blocks inside StepBuilder and our clients migrate to Ooyala, in this case + * we may drop special handling of HTML blocks. See discussions in OC-1441. + * * We include HTML blocks in runtime.children for runtime of jquery.xblock, then just add + * `html: HtmlManager` to `Managers`, and remove this block. + */ + $("div.xblock.xblock-student_view.xmodule_HtmlModule", xblock_element).each(function(idx, element) { + managedChildren.push(new HtmlManager({ element: element })); + }); + + for (var idx = 0; idx < children.length; idx++) { + var child = children[idx]; + // NOTE: While the following assertion is true for e.g Video blocks: + // child.type == $(child.element).data('block-type') it is invalid for all sb-* blocks + var type = $(child.element).data('block-type'); + var constructor = Managers[type]; + if (typeof constructor === 'undefined') { + // This block does not requires special care, moving on + continue; + } + managedChildren.push(new constructor(child)); + } + + this.show = function () { + for (var idx = 0; idx < managedChildren.length; idx++) { + managedChildren[idx].show(); + } + }; + + this.hide = function () { + for (var idx = 0; idx < managedChildren.length; idx++) { + managedChildren[idx].hide(); + } + }; + + } + + window.ProblemBuilderStepUtil = { + + ChildManager: ChildManager + + }; +})(); + 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.$('