Commit a4657bdd by Jillian Vogel Committed by GitHub

Merge pull request #119 from open-craft/master

Problem Builder - Review for release on edx.org
parents 7467e278 2be61714
...@@ -5,3 +5,8 @@ ...@@ -5,3 +5,8 @@
/workbench.* /workbench.*
/dist /dist
/templates /templates
/var
*.iml
.idea/*
dump.rdb
problem_builder.tests.*
[pep8]
exclude=problem_builder/migrations
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"
...@@ -9,3 +9,4 @@ Alan Boudreault <boudreault.alan@gmail.com> ...@@ -9,3 +9,4 @@ Alan Boudreault <boudreault.alan@gmail.com>
Eugeny Kolpakov <eugeny@opencraft.com> Eugeny Kolpakov <eugeny@opencraft.com>
Braden MacDonald <braden@opencraft.com> Braden MacDonald <braden@opencraft.com>
Jonathan Piacenti <jonathan@opencraft.com> Jonathan Piacenti <jonathan@opencraft.com>
Tim Krones <tim@opencraft.com>
------------------------------------------------------------------------------
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.
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 This repository provides two XBlocks: Problem Builder and Step Builder.
workflow of real-life mentoring, within an edX course.
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 * **Free-form answers** (textarea) which can be shared accross
different XBlock instances (for example, to allow a student to different XBlock instances (for example, to allow a student to
review and edit an answer he gave before). review and edit an answer they gave before).
* **Self-assessment MCQs** (multiple choice), to display predetermined * **Self-assessment MCQs** (multiple choice questions), to display
feedback to a student based on his choices in the predetermined feedback to a student based on his choices in the
self-assessment. Supports rating scales and arbitrary answers. self-assessment. Supports rating scales and arbitrary answers.
* **MRQs (Multiple Response Questions)**, a type of multiple choice * **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 recaps** that display a read-only summary of a user's
answer to a free-form question asked earlier in the course. answer to a free-form question asked earlier in the course.
* **Progression tracking**, to require that the student has * **Progression tracking**, to require that the student has
...@@ -26,15 +29,15 @@ It supports: ...@@ -26,15 +29,15 @@ It supports:
* **Dashboards**, for displaying a summary of the student's answers * **Dashboards**, for displaying a summary of the student's answers
to multiple choice questions. [Details](doc/Dashboard.md) to multiple choice questions. [Details](doc/Dashboard.md)
The screenshot shows an example of a problem builder block containing a The following screenshot shows an example of a Problem Builder block
free-form question, two MCQs and one MRQ. containing a free-form question, two MCQs and one MRQ:
![Problem Builder Example](doc/img/mentoring-example.png) ![Problem Builder Example](doc/img/mentoring-example.png)
Installation 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 `edx-platform` installation by running the following command from the
root folder: root folder:
...@@ -42,18 +45,6 @@ root folder: ...@@ -42,18 +45,6 @@ root folder:
$ pip install -r requirements.txt $ 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 Usage
----- -----
......
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"
...@@ -80,3 +80,16 @@ must be in JSON format. The supported entries are: ...@@ -80,3 +80,16 @@ must be in JSON format. The supported entries are:
* **`"width"`**: (Important) The width of the images, in pixels (all images * **`"width"`**: (Important) The width of the images, in pixels (all images
should be the same size). should be the same size).
* **`"height"`**: (Important) The height of the images, in pixels * **`"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.
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`).
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)
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)
Mentoring Block Usage Using Problem Builder and Step Builder
===================== ======================================
When you add the `Problem Builder` component to a course in the studio, the First, enable the blocks in Studio (see "Enabling in Studio", below).
built-in editing tools guide you through the process of configuring the
block and adding individual questions. 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
### Problem Builder modes instructions.
There are 2 mentoring modes available: * [Problem Builder](Problem Builder.md) is simply a group of one or more
question[s].
* *standard*: Traditional mentoring. All questions are displayed on the * [Step Builder](Step Builder.md) lets authors build more complex exercises
page and submitted at the same time. The students get some tips and where questions are grouped into "steps" and students answer the questions in
feedback about their answers. This is the default mode. 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
* *assessment*: Questions are displayed and submitted one by one. The feedback and study suggestions.
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`. 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
Below are some LMS screenshots of a problem builder block in assessment mode. the component for editing. You can then add [any of the supported question and
content types](Questions.md).
Question before submitting an answer:
![Assessment Step 1](img/assessment-1.png) Enabling in Studio
------------------
Question after submitting the correct answer:
You can enable the Problem Builder and Step Builder XBlocks in Studio by
![Assessment Step 2](img/assessment-2.png) modifying the advanced settings for your course:
Question after submitting a wrong answer: 1. From the main page of a specific course, navigate to **Settings** ->
**Advanced Settings** from the top menu.
![Assessment Step 3](img/assessment-3.png) 2. Find the **Advanced Module List** setting.
3. To enable Problem Builder for your course, add `"problem-builder"` to the
Score review and the "Try Again" button: modules listed there.
4. To enable Step Builder for your course, add `"step-builder"` to the modules
![Assessment Step 4](img/assessment-4.png) listed there.
5. Click the **Save changes** button.
### Free-form Question
Note that it is perfectly fine to enable both Problem Builder and Step Builder
Free-form questions are represented by a "Long Answer" component. for your course -- the blocks do not interfere with each other.
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)
doc/img/mrq-3.png

45.6 KB | W: | H:

doc/img/mrq-3.png

112 KB | W: | H:

doc/img/mrq-3.png
doc/img/mrq-3.png
doc/img/mrq-3.png
doc/img/mrq-3.png
  • 2-up
  • Swipe
  • Onion skin
...@@ -26,12 +26,13 @@ from lazy import lazy ...@@ -26,12 +26,13 @@ from lazy import lazy
from .models import Answer from .models import Answer
from xblock.core import XBlock 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.fragment import Fragment
from xblock.validation import ValidationMessage from xblock.validation import ValidationMessage
from xblockutils.resources import ResourceLoader from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import StudioEditableXBlockMixin from xblockutils.studio_editable import StudioEditableXBlockMixin, XBlockWithPreviewMixin
from .step import StepMixin from problem_builder.sub_api import SubmittingXBlockMixin, sub_api
from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin
import uuid import uuid
...@@ -48,7 +49,7 @@ def _(text): ...@@ -48,7 +49,7 @@ def _(text):
# Classes ########################################################### # Classes ###########################################################
class AnswerMixin(object): class AnswerMixin(XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin):
""" """
Mixin to give an XBlock the ability to read/write data to the Answers DB table. Mixin to give an XBlock the ability to read/write data to the Answers DB table.
""" """
...@@ -84,6 +85,23 @@ class AnswerMixin(object): ...@@ -84,6 +85,23 @@ class AnswerMixin(object):
) )
return answer_data 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): def validate_field_data(self, validation, data):
""" """
Validate this block's field data. Validate this block's field data.
...@@ -96,19 +114,19 @@ class AnswerMixin(object): ...@@ -96,19 +114,19 @@ class AnswerMixin(object):
if not data.name: if not data.name:
add_error(u"A Question ID is required.") add_error(u"A Question ID is required.")
def _(self, text):
""" translate text """
return self.runtime.service(self, "i18n").ugettext(text)
@XBlock.needs("i18n") @XBlock.needs("i18n")
class AnswerBlock(AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock): class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, QuestionMixin, StudioEditableXBlockMixin, XBlock):
""" """
A field where the student enters an answer A field where the student enters an answer
Must be included as a child of a mentoring block. Answers are persisted as django model instances Must be included as a child of a mentoring block. Answers are persisted as django model instances
to make them searchable and referenceable across xblocks. to make them searchable and referenceable across xblocks.
""" """
CATEGORY = 'pb-answer'
STUDIO_LABEL = _(u"Long Answer")
answerable = True
name = String( name = String(
display_name=_("Question ID (name)"), 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."), 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): ...@@ -131,14 +149,8 @@ class AnswerBlock(AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock):
display_name=_("Question"), display_name=_("Question"),
help=_("Question to ask the student"), help=_("Question to ask the student"),
scope=Scope.content, scope=Scope.content,
default="" default="",
) multiline_editor=True,
weight = Float(
display_name=_("Weight"),
help=_("Defines the maximum total grade of the answer block."),
default=1,
scope=Scope.settings,
enforce_type=True
) )
editable_fields = ('question', 'name', 'min_characters', 'weight', 'default_from', 'display_name', 'show_title') editable_fields = ('question', 'name', 'min_characters', 'weight', 'default_from', 'display_name', 'show_title')
...@@ -179,6 +191,18 @@ class AnswerBlock(AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock): ...@@ -179,6 +191,18 @@ class AnswerBlock(AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock):
""" Normal view of this XBlock, identical to mentoring_view """ """ Normal view of this XBlock, identical to mentoring_view """
return self.mentoring_view(context) 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): def submit(self, submission):
""" """
The parent block is handling a student submission, including a new answer for this The parent block is handling a student submission, including a new answer for this
...@@ -186,13 +210,16 @@ class AnswerBlock(AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock): ...@@ -186,13 +210,16 @@ class AnswerBlock(AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock):
""" """
self.student_input = submission[0]['value'].strip() self.student_input = submission[0]['value'].strip()
self.save() 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)) log.info(u'Answer submitted for`{}`: "{}"'.format(self.name, self.student_input))
return { return self.get_results()
'student_input': self.student_input,
'status': self.status,
'weight': self.weight,
'score': 1 if self.status == 'correct' else 0,
}
@property @property
def status(self): def status(self):
...@@ -239,6 +266,9 @@ class AnswerRecapBlock(AnswerMixin, StudioEditableXBlockMixin, XBlock): ...@@ -239,6 +266,9 @@ class AnswerRecapBlock(AnswerMixin, StudioEditableXBlockMixin, XBlock):
""" """
A block that displays an answer previously entered by the student (read-only). 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( name = String(
display_name=_("Question ID"), display_name=_("Question ID"),
help=_("The ID of the question for which to display the student's answer."), help=_("The ID of the question for which to display the student's answer."),
...@@ -258,22 +288,35 @@ class AnswerRecapBlock(AnswerMixin, StudioEditableXBlockMixin, XBlock): ...@@ -258,22 +288,35 @@ class AnswerRecapBlock(AnswerMixin, StudioEditableXBlockMixin, XBlock):
) )
editable_fields = ('name', 'display_name', 'description') editable_fields = ('name', 'display_name', 'description')
@property css_path = 'public/css/answer.css'
def student_input(self):
if self.name:
return self.get_model_object().student_input
return ''
def mentoring_view(self, context=None): def mentoring_view(self, context=None):
""" Render this XBlock within a mentoring block. """ """ Render this XBlock within a mentoring block. """
context = context.copy() if context else {} context = context.copy() if context else {}
student_submissions_key = context.get('student_submissions_key')
context['title'] = self.display_name context['title'] = self.display_name
context['description'] = self.description context['description'] = self.description
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 context['student_input'] = self.student_input
html = loader.render_template('templates/html/answer_read_only.html', context) html = loader.render_template('templates/html/answer_read_only.html', context)
fragment = Fragment(html) 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 return fragment
def student_view(self, context=None): def student_view(self, context=None):
......
...@@ -27,7 +27,9 @@ from xblock.core import XBlock ...@@ -27,7 +27,9 @@ from xblock.core import XBlock
from xblock.fields import Scope, String from xblock.fields import Scope, String
from xblock.fragment import Fragment from xblock.fragment import Fragment
from xblock.validation import ValidationMessage 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 # Make '_' a no-op so we can scrape strings
...@@ -38,7 +40,7 @@ def _(text): ...@@ -38,7 +40,7 @@ def _(text):
@XBlock.needs("i18n") @XBlock.needs("i18n")
class ChoiceBlock(StudioEditableXBlockMixin, XBlock): class ChoiceBlock(StudioEditableXBlockMixin, XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin, XBlock):
""" """
Custom choice of an answer for a MCQ/MRQ Custom choice of an answer for a MCQ/MRQ
""" """
...@@ -56,10 +58,6 @@ class ChoiceBlock(StudioEditableXBlockMixin, XBlock): ...@@ -56,10 +58,6 @@ class ChoiceBlock(StudioEditableXBlockMixin, XBlock):
) )
editable_fields = ('content', 'value') editable_fields = ('content', 'value')
def _(self, text):
""" translate text """
return self.runtime.service(self, "i18n").ugettext(text)
@property @property
def display_name_with_default(self): def display_name_with_default(self):
try: try:
......
...@@ -58,6 +58,39 @@ def _(text): ...@@ -58,6 +58,39 @@ def _(text):
# Classes ########################################################### # 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): class ColorRule(object):
""" """
A rule used to conditionally set colors A rule used to conditionally set colors
...@@ -155,7 +188,7 @@ class InvalidUrlName(ValueError): ...@@ -155,7 +188,7 @@ class InvalidUrlName(ValueError):
@XBlock.needs("i18n") @XBlock.needs("i18n")
@XBlock.wants("user") @XBlock.wants("user")
class DashboardBlock(StudioEditableXBlockMixin, XBlock): class DashboardBlock(StudioEditableXBlockMixin, ExportMixin, XBlock):
""" """
A block to summarize self-assessment results. A block to summarize self-assessment results.
""" """
...@@ -260,7 +293,7 @@ class DashboardBlock(StudioEditableXBlockMixin, XBlock): ...@@ -260,7 +293,7 @@ class DashboardBlock(StudioEditableXBlockMixin, XBlock):
'color_rules', 'visual_rules', 'visual_title', 'visual_desc', 'header_html', 'footer_html', 'color_rules', 'visual_rules', 'visual_title', 'visual_desc', 'header_html', 'footer_html',
) )
css_path = 'public/css/dashboard.css' 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): def get_mentoring_blocks(self, mentoring_ids, ignore_errors=True):
""" """
...@@ -343,34 +376,6 @@ class DashboardBlock(StudioEditableXBlockMixin, XBlock): ...@@ -343,34 +376,6 @@ class DashboardBlock(StudioEditableXBlockMixin, XBlock):
return rule.color_str return rule.color_str
return None 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): def _get_problem_questions(self, mentoring_block):
""" Generator returning only children of specified block that are MCQs """ """ Generator returning only children of specified block that are MCQs """
for child_id in mentoring_block.children: for child_id in mentoring_block.children:
...@@ -469,7 +474,11 @@ class DashboardBlock(StudioEditableXBlockMixin, XBlock): ...@@ -469,7 +474,11 @@ class DashboardBlock(StudioEditableXBlockMixin, XBlock):
fragment = Fragment(html) fragment = Fragment(html)
fragment.add_css_url(self.runtime.local_resource_url(self, self.css_path)) 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.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 return fragment
def validate_field_data(self, validation, data): def validate_field_data(self, validation, data):
......
...@@ -48,6 +48,19 @@ class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock): ...@@ -48,6 +48,19 @@ class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock):
""" """
An XBlock used to ask multiple-choice questions 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( student_choice = String(
# {Last input submitted by the student # {Last input submitted by the student
default="", default="",
...@@ -61,7 +74,7 @@ class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock): ...@@ -61,7 +74,7 @@ class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock):
list_values_provider=QuestionnaireAbstractBlock.choice_values_provider, list_values_provider=QuestionnaireAbstractBlock.choice_values_provider,
list_style='set', # Underered, unique items. Affects the UI editor. 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): def describe_choice_correctness(self, choice_value):
if choice_value in self.correct_choices: if choice_value in self.correct_choices:
...@@ -74,15 +87,15 @@ class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock): ...@@ -74,15 +87,15 @@ class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock):
return self._(u"Wrong") return self._(u"Wrong")
return self._(u"Not Acceptable") return self._(u"Not Acceptable")
def submit(self, submission): def calculate_results(self, submission):
log.debug(u'Received MCQ submission: "%s"', submission)
correct = submission in self.correct_choices correct = submission in self.correct_choices
tips_html = [] tips_html = []
for tip in self.get_tips(): for tip in self.get_tips():
if submission in tip.values: if submission in tip.values:
tips_html.append(tip.render('mentoring_view').content) tips_html.append(tip.render('mentoring_view').content)
formatted_tips = None
if tips_html: if tips_html:
formatted_tips = loader.render_template('templates/html/tip_choice_group.html', { formatted_tips = loader.render_template('templates/html/tip_choice_group.html', {
'tips_html': tips_html, 'tips_html': tips_html,
...@@ -94,25 +107,35 @@ class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock): ...@@ -94,25 +107,35 @@ class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock):
# Also send to the submissions API: # Also send to the submissions API:
sub_api.create_submission(self.student_item_key, submission) sub_api.create_submission(self.student_item_key, submission)
result = { return {
'submission': submission, 'submission': submission,
'message': self.message_formatted,
'status': 'correct' if correct else 'incorrect', 'status': 'correct' if correct else 'incorrect',
'tips': formatted_tips if tips_html else None, 'tips': formatted_tips,
'weight': self.weight, 'weight': self.weight,
'score': 1 if correct else 0, '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) log.debug(u'MCQ submission result: %s', result)
return 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 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. show them in the author edit view, for clarity.
""" """
fragment = Fragment(u"<p>{}</p>".format(self.question)) fragment = Fragment(u"<p>{}</p>".format(self.question))
self.render_children(context, fragment, can_reorder=True, can_add=False) 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 return fragment
def validate_field_data(self, validation, data): def validate_field_data(self, validation, data):
...@@ -149,6 +172,9 @@ class RatingBlock(MCQBlock): ...@@ -149,6 +172,9 @@ class RatingBlock(MCQBlock):
""" """
An XBlock used to rate something on a five-point scale, e.g. Likert Scale 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( low = String(
display_name=_("Low"), display_name=_("Low"),
help=_("Label for low ratings"), help=_("Label for low ratings"),
...@@ -183,7 +209,7 @@ class RatingBlock(MCQBlock): ...@@ -183,7 +209,7 @@ class RatingBlock(MCQBlock):
{"display_name": dn, "value": val} for val, dn in zip(self.FIXED_VALUES, display_names) {"display_name": dn, "value": val} for val, dn in zip(self.FIXED_VALUES, display_names)
] + super(RatingBlock, self).human_readable_choices ] + 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 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. show them in the author edit view, for clarity.
...@@ -196,6 +222,26 @@ class RatingBlock(MCQBlock): ...@@ -196,6 +222,26 @@ class RatingBlock(MCQBlock):
'accepted_statuses': [None] + [self.describe_choice_correctness(c) for c in "12345"], 'accepted_statuses': [None] + [self.describe_choice_correctness(c) for c in "12345"],
})) }))
self.render_children(context, fragment, can_reorder=True, can_add=False) self.render_children(context, fragment, can_reorder=True, can_add=False)
fragment.add_content(loader.render_template('templates/html/questionnaire_add_buttons.html', {})) return fragment
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/questionnaire-edit.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 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 return fragment
...@@ -26,6 +26,8 @@ from xblock.fields import Scope, String ...@@ -26,6 +26,8 @@ from xblock.fields import Scope, String
from xblock.fragment import Fragment from xblock.fragment import Fragment
from xblockutils.studio_editable import StudioEditableXBlockMixin from xblockutils.studio_editable import StudioEditableXBlockMixin
from problem_builder.mixins import XBlockWithTranslationServiceMixin
# Make '_' a no-op so we can scrape strings # Make '_' a no-op so we can scrape strings
def _(text): def _(text):
...@@ -35,11 +37,75 @@ def _(text): ...@@ -35,11 +37,75 @@ def _(text):
@XBlock.needs("i18n") @XBlock.needs("i18n")
class MentoringMessageBlock(XBlock, StudioEditableXBlockMixin): class MentoringMessageBlock(XBlock, StudioEditableXBlockMixin, XBlockWithTranslationServiceMixin):
""" """
A message which can be conditionally displayed at the mentoring block level, A message which can be conditionally displayed at the mentoring block level,
for example upon completion of the block for example upon completion of the block
""" """
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( content = String(
display_name=_("Message"), display_name=_("Message"),
help=_("Message to display upon completion"), help=_("Message to display upon completion"),
...@@ -53,45 +119,58 @@ class MentoringMessageBlock(XBlock, StudioEditableXBlockMixin): ...@@ -53,45 +119,58 @@ class MentoringMessageBlock(XBlock, StudioEditableXBlockMixin):
scope=Scope.content, scope=Scope.content,
default="completed", default="completed",
values=( values=(
{"display_name": "Completed", "value": "completed"}, {"value": "completed", "display_name": MESSAGE_TYPES["completed"]["display_name"]},
{"display_name": "Incompleted", "value": "incomplete"}, {"value": "incomplete", "display_name": MESSAGE_TYPES["incomplete"]["display_name"]},
{"display_name": "Reached max. # of attemps", "value": "max_attempts_reached"}, {"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", ) editable_fields = ("content", )
def _(self, text):
""" translate text """
return self.runtime.service(self, "i18n").ugettext(text)
def mentoring_view(self, context=None): def mentoring_view(self, context=None):
""" Render this message for use by a mentoring block. """ """ Render this message for use by a mentoring block. """
html = u'<div class="message {msg_type}">{content}</div>'.format(msg_type=self.type, content=self.content) html = u'<div class="submission-message {msg_type}">{content}</div>'.format(
msg_type=self.type,
content=self.content
)
return Fragment(html) return Fragment(html)
def student_view(self, context=None): def student_view(self, context=None):
""" Normal view of this XBlock, identical to mentoring_view """ """ Normal view of this XBlock, identical to mentoring_view """
return self.mentoring_view(context) return self.mentoring_view(context)
def author_view(self, context=None):
fragment = self.mentoring_view(context)
fragment.content += u'<div class="submission-message-help"><p>{}</p></div>'.format(self.help_text)
return fragment
@property @property
def display_name_with_default(self): def display_name_with_default(self):
if self.type == 'max_attempts_reached': try:
max_attempts = self.get_parent().max_attempts return self._(self.MESSAGE_TYPES[self.type]["long_display_name"])
return self._(u"Message when student reaches max. # of attempts ({limit})").format( except KeyError:
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" 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 @classmethod
def get_template(cls, template_id): def get_template(cls, template_id):
""" """
Used to interact with Studio's create_xblock method to instantiate pre-defined templates. 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 @classmethod
def parse_xml(cls, node, runtime, keys, id_generator): def parse_xml(cls, node, runtime, keys, id_generator):
...@@ -100,8 +179,23 @@ class MentoringMessageBlock(XBlock, StudioEditableXBlockMixin): ...@@ -100,8 +179,23 @@ class MentoringMessageBlock(XBlock, StudioEditableXBlockMixin):
""" """
block = runtime.construct_xblock_from_class(cls, keys) block = runtime.construct_xblock_from_class(cls, keys)
block.content = unicode(node.text or u"") block.content = unicode(node.text or u"")
if 'type' in node.attrib: # 'type' is optional - default is 'completed'
block.type = node.attrib['type'] block.type = node.attrib['type']
for child in node: for child in node:
block.content += etree.tostring(child, encoding='unicode') block.content += etree.tostring(child, encoding='unicode')
return block 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']
# -*- 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')]),
),
]
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 <p> tag so we do the same here.
return '<p>{}</p>'.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'<p>{}</p>'.format(self._("This XBlock does not have any settings.")))
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
# Imports ########################################################### # Imports ###########################################################
from django.db import models from django.db import models
from django.contrib.auth.models import User
# Classes ########################################################### # Classes ###########################################################
...@@ -47,3 +48,19 @@ class Answer(models.Model): ...@@ -47,3 +48,19 @@ class Answer(models.Model):
# Force validation of max_length # Force validation of max_length
self.full_clean() self.full_clean()
super(Answer, self).save(*args, **kwargs) 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'),)
...@@ -22,7 +22,7 @@ ...@@ -22,7 +22,7 @@
import logging import logging
from xblock.fields import List, Scope, Boolean from xblock.fields import List, Scope, Boolean, String
from xblock.validation import ValidationMessage from xblock.validation import ValidationMessage
from .questionnaire import QuestionnaireAbstractBlock from .questionnaire import QuestionnaireAbstractBlock
from xblockutils.resources import ResourceLoader from xblockutils.resources import ResourceLoader
...@@ -44,6 +44,9 @@ class MRQBlock(QuestionnaireAbstractBlock): ...@@ -44,6 +44,9 @@ class MRQBlock(QuestionnaireAbstractBlock):
""" """
An XBlock used to ask multiple-response questions An XBlock used to ask multiple-response questions
""" """
CATEGORY = 'pb-mrq'
STUDIO_LABEL = _(u"Multiple Response Question")
student_choices = List( student_choices = List(
# Last submissions by the student # Last submissions by the student
default=[], default=[],
...@@ -68,6 +71,12 @@ class MRQBlock(QuestionnaireAbstractBlock): ...@@ -68,6 +71,12 @@ class MRQBlock(QuestionnaireAbstractBlock):
list_style='set', # Underered, unique items. Affects the UI editor. list_style='set', # Underered, unique items. Affects the UI editor.
default=[], 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) hide_results = Boolean(display_name="Hide results", scope=Scope.content, default=False)
editable_fields = ( editable_fields = (
'question', 'required_choices', 'ignored_choices', 'message', 'display_name', 'question', 'required_choices', 'ignored_choices', 'message', 'display_name',
...@@ -81,16 +90,38 @@ class MRQBlock(QuestionnaireAbstractBlock): ...@@ -81,16 +90,38 @@ class MRQBlock(QuestionnaireAbstractBlock):
return self._(u"Ignored") return self._(u"Ignored")
return self._(u"Not Acceptable") 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): def submit(self, submissions):
log.debug(u'Received MRQ submissions: "%s"', 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 = [] results = []
for choice in self.custom_choices: for choice in self.custom_choices:
choice_completed = True choice_completed = True
choice_tips_html = [] choice_tips_html = []
choice_selected = choice.value in submissions choice_selected = choice.value in submissions
if choice.value in self.required_choices: if choice.value in self.required_choices:
if not choice_selected: if not choice_selected:
choice_completed = False choice_completed = False
...@@ -117,22 +148,17 @@ class MRQBlock(QuestionnaireAbstractBlock): ...@@ -117,22 +148,17 @@ class MRQBlock(QuestionnaireAbstractBlock):
results.append(choice_result) results.append(choice_result)
self.student_choices = submissions
status = 'incorrect' if score <= 0 else 'correct' if score >= len(results) else 'partial' status = 'incorrect' if score <= 0 else 'correct' if score >= len(results) else 'partial'
result = { return {
'submissions': submissions, 'submissions': submissions,
'status': status, 'status': status,
'choices': results, 'choices': results,
'message': self.message, 'message': self.message_formatted,
'weight': self.weight, 'weight': self.weight,
'score': (float(score) / len(results)) if results else 0, '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): def validate_field_data(self, validation, data):
""" """
Validate this block's field data. Validate this block's field data.
......
.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;
}
...@@ -52,3 +52,117 @@ ...@@ -52,3 +52,117 @@
position: absolute; position: absolute;
width: 1px; 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
/* 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;
}
.sb-plot-overlay {
margin-bottom: 10px;
}
.italic {
font-style: italic;
}
.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;
}
.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;
}
/* 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. */
}
/* 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: "]"
}
...@@ -4,9 +4,6 @@ ...@@ -4,9 +4,6 @@
.mentoring .messages { .mentoring .messages {
display: none; display: none;
margin-top: 10px;
border-top: 2px solid #eaeaea;
padding: 12px 0px 20px;
} }
.mentoring .messages .title1 { .mentoring .messages .title1 {
...@@ -44,7 +41,7 @@ ...@@ -44,7 +41,7 @@
margin-top: 10px; margin-top: 10px;
} }
.mentoring h3 { .xblock .mentoring h3 {
margin-top: 0px; margin-top: 0px;
margin-bottom: 7px; margin-bottom: 7px;
} }
...@@ -67,12 +64,38 @@ ...@@ -67,12 +64,38 @@
margin-bottom: 0; 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 { .mentoring .attempts {
margin-left: 10px; margin-left: 10px;
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
font-size: 13px; font-size: 13px;
font-weight: bold; font-weight: 600;
} }
.mentoring .attempts > span { .mentoring .attempts > span {
...@@ -119,9 +142,16 @@ ...@@ -119,9 +142,16 @@
margin-right: 10px; margin-right: 10px;
} }
.mentoring .assessment-checkmark.checkmark-clickable {
cursor: pointer;
}
.mentoring .grade .grade-result {
margin: 20px;
}
.mentoring .grade .checkmark-incorrect { .mentoring .grade .checkmark-incorrect {
margin-left: 10px; margin-left: 10px;
margin-right: 20px;
} }
.mentoring input[type=button], .mentoring input[type=button],
...@@ -139,3 +169,94 @@ ...@@ -139,3 +169,94 @@
.mentoring input[type="radio"] { .mentoring input[type="radio"] {
margin: 0; 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;
}
/* Custom appearance for our "Add" buttons */ /* Custom appearance for our "Add" buttons */
.xblock .add-xblock-component .new-component .new-component-type .add-xblock-component-button { .xblock .add-xblock-component .new-component .new-component-type .add-xblock-component-button {
width: 200px; width: auto;
height: 30px; height: auto;
line-height: 30px; 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;
} }
.mentoring .questionnaire .choices-list { .mentoring .questionnaire .choices-list {
display: table;
position: relative; position: relative;
width: 100%;
border-spacing: 0 6px;
padding-top: 10px; padding-top: 10px;
margin-bottom: 10px; margin-bottom: 10px;
} }
.mentoring .questionnaire .choice-result { .mentoring .questionnaire .choice-result {
display: inline-block; display: inline-block;
width: 40px; width: 34px;
vertical-align: middle; vertical-align: top;
cursor: pointer; cursor: pointer;
float: none; float: none;
} }
.mentoring .questionnaire .choice { .mentoring .questionnaire .choice {
overflow-y: hidden; overflow-y: hidden;
display: table-row;
} }
.mentoring .questionnaire .choice-result.checkmark-correct, .mentoring .questionnaire .choice-result.checkmark-correct,
...@@ -32,10 +36,12 @@ ...@@ -32,10 +36,12 @@
background: none repeat scroll 0 0 #66A5B5; background: none repeat scroll 0 0 #66A5B5;
font-family: arial; font-family: arial;
font-size: 14px; font-size: 14px;
overflow-y: auto;
opacity: 0.9; opacity: 0.9;
padding: 10px; padding: 22px 10px 10px 10px;
width: 300px; width: 300px;
min-height: 40px;
max-height: 180px;
z-index: 10000;
} }
.mentoring .questionnaire .choice-tips .title { .mentoring .questionnaire .choice-tips .title {
...@@ -48,6 +54,9 @@ ...@@ -48,6 +54,9 @@
.mentoring .questionnaire .feedback .tip-choice-group, .mentoring .questionnaire .feedback .tip-choice-group,
.mentoring .questionnaire .feedback .message-content { .mentoring .questionnaire .feedback .message-content {
position: relative; position: relative;
overflow-y: auto;
line-height: normal;
max-height: 180px;
} }
.mentoring .questionnaire .choice-tips .close, .mentoring .questionnaire .choice-tips .close,
...@@ -69,26 +78,12 @@ ...@@ -69,26 +78,12 @@
} }
.mentoring .choices-list .choice-selector { .mentoring .choices-list .choice-selector {
margin-right: 5px;
}
.mentoring .choice-label {
display: inline-block; display: inline-block;
margin-top: 8px;
margin-bottom: 5px;
line-height: 1.3;
} }
.mentoring .choices-list .choice-text > .xblock-light-child * { .mentoring .choice-tips-container,
vertical-align: middle; .mentoring .choice-label {
} display: table-cell;
vertical-align: top;
.mentoring .choices-list .choice-text > .xblock-light-child, width: 50%;
.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;
} }
...@@ -2,35 +2,45 @@ function AnswerBlock(runtime, element) { ...@@ -2,35 +2,45 @@ function AnswerBlock(runtime, element) {
return { return {
mode: null, mode: null,
init: function(options) { init: function(options) {
// register the child validator // Clear results and validate block when answer changes
$(':input', element).on('keyup', options.onChange); $(':input', element).on('keyup', options.onChange);
this.mode = options.mode; this.mode = options.mode;
var checkmark = $('.answer-checkmark', element); this.validateXBlock = options.validateXBlock;
var completed = $('.xblock-answer', element).data('completed');
if (completed === 'True' && this.mode === 'standard') { // In the LMS, the HTML of multiple units can be loaded at once,
checkmark.addClass('checkmark-correct icon-ok fa-check'); // and the user can flip among them. If that happens, the answer in
} // our HTML may be out of date.
this.refreshAnswer();
}, },
submit: function() { submit: function() {
return $(':input', element).serializeArray(); return $(':input', element).serializeArray();
}, },
handleSubmit: function(result) { handleReview: function(result) {
if (this.mode === 'assessment') $('textarea', element).prop('disabled', true);
return; },
handleSubmit: function(result, options) {
var checkmark = $('.answer-checkmark', element); var checkmark = $('.answer-checkmark', element);
$(element).find('.message').text((result || {}).error || '');
this.clearResult(); this.clearResult();
if (options.hide_results || this.mode === 'assessment') {
// In assessment mode, display of checkmark would be redundant.
return;
}
if (result.status) {
if (result.status === "correct") { if (result.status === "correct") {
checkmark.addClass('checkmark-correct icon-ok fa-check'); checkmark.addClass('checkmark-correct icon-ok fa-check');
checkmark.attr('aria-label', checkmark.data('label_correct'));
} }
else { else {
checkmark.addClass('checkmark-incorrect icon-exclamation fa-exclamation'); checkmark.addClass('checkmark-incorrect icon-exclamation fa-exclamation');
checkmark.attr('aria-label', checkmark.data('label_incorrect'));
}
} }
}, },
...@@ -54,7 +64,7 @@ function AnswerBlock(runtime, element) { ...@@ -54,7 +64,7 @@ function AnswerBlock(runtime, element) {
var answer_length = input_value.length; var answer_length = input_value.length;
var data = input.data(); 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)) { if (_.isNumber(data.min_characters)) {
var min_characters = _.max([data.min_characters, 1]); var min_characters = _.max([data.min_characters, 1]);
if (answer_length < min_characters) { if (answer_length < min_characters) {
...@@ -62,6 +72,29 @@ function AnswerBlock(runtime, element) { ...@@ -62,6 +72,29 @@ function AnswerBlock(runtime, element) {
} }
} }
return true; 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();
}
},
});
} }
}; };
} }
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);
}
});
}
};
}
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");
}
// 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);
}
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 {};
}
...@@ -8,7 +8,9 @@ function MentoringBlock(runtime, element) { ...@@ -8,7 +8,9 @@ function MentoringBlock(runtime, element) {
var attemptsTemplate = _.template($('#xblock-attempts-template').html()); var attemptsTemplate = _.template($('#xblock-attempts-template').html());
var data = $('.mentoring', element).data(); var data = $('.mentoring', element).data();
var children = runtime.children(element); 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 step = data.step;
var mentoring = { var mentoring = {
...@@ -61,17 +63,13 @@ function MentoringBlock(runtime, element) { ...@@ -61,17 +63,13 @@ function MentoringBlock(runtime, element) {
if (typeof obj !== 'undefined' && typeof obj[fn] == 'function') { if (typeof obj !== 'undefined' && typeof obj[fn] == 'function') {
return obj[fn].apply(obj, Array.prototype.slice.call(arguments, 2)); return obj[fn].apply(obj, Array.prototype.slice.call(arguments, 2));
} else { } else {
return undefined; return null;
} }
} }
function setContent(dom, content) { function setContent(dom, content) {
dom.html(''); dom.html('');
dom.append(content); dom.append(content);
var template = $('#light-child-template', dom).html();
if (template) {
dom.append(template);
}
} }
function renderAttempts() { function renderAttempts() {
...@@ -107,12 +105,14 @@ function MentoringBlock(runtime, element) { ...@@ -107,12 +105,14 @@ function MentoringBlock(runtime, element) {
function getChildByName(name) { function getChildByName(name) {
for (var i = 0; i < children.length; i++) { for (var i = 0; i < children.length; i++) {
var child = children[i]; var child = children[i];
if (child && child.name === name) { if (child && typeof child.name !== 'undefined' && child.name.toString() === name) {
return child; return child;
} }
} }
} }
ProblemBuilderUtil.transformClarifications(element);
if (data.mode === 'standard') { if (data.mode === 'standard') {
MentoringStandardView(runtime, element, mentoring); MentoringStandardView(runtime, element, mentoring);
} }
......
...@@ -5,7 +5,7 @@ function MentoringEditComponents(runtime, element) { ...@@ -5,7 +5,7 @@ function MentoringEditComponents(runtime, element) {
var updateButtons = function() { var updateButtons = function() {
$buttons.each(function() { $buttons.each(function() {
var msg_type = $(this).data('boilerplate'); 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(); updateButtons();
...@@ -17,5 +17,8 @@ function MentoringEditComponents(runtime, element) { ...@@ -17,5 +17,8 @@ function MentoringEditComponents(runtime, element) {
$(this).addClass('disabled'); $(this).addClass('disabled');
} }
}); });
ProblemBuilderUtil.transformClarifications(element);
runtime.listenTo('deleted-child', updateButtons); runtime.listenTo('deleted-child', updateButtons);
} }
...@@ -4,49 +4,92 @@ function MentoringStandardView(runtime, element, mentoring) { ...@@ -4,49 +4,92 @@ function MentoringStandardView(runtime, element, mentoring) {
var callIfExists = mentoring.callIfExists; var callIfExists = mentoring.callIfExists;
function handleSubmitResults(results) { function handleSubmitResults(response, disable_submit) {
messagesDOM.empty().hide(); messagesDOM.empty().hide();
$.each(results.submitResults || [], function(index, submitResult) { var hide_results = response.message === undefined;
var input = submitResult[0];
var result = submitResult[1]; var all_have_results = response.results.length > 0;
$.each(response.results || [], function(index, result_spec) {
var input = result_spec[0];
var result = result_spec[1];
var child = mentoring.getChildByName(input); var child = mentoring.getChildByName(input);
var options = { var options = {
max_attempts: results.max_attempts, max_attempts: response.max_attempts,
num_attempts: results.num_attempts num_attempts: response.num_attempts,
hide_results: hide_results,
}; };
callIfExists(child, 'handleSubmit', result, options); callIfExists(child, 'handleSubmit', result, options);
all_have_results = all_have_results && !$.isEmptyObject(result);
}); });
$('.attempts', element).data('max_attempts', results.max_attempts); $('.attempts', element).data('max_attempts', response.max_attempts);
$('.attempts', element).data('num_attempts', results.num_attempts); $('.attempts', element).data('num_attempts', response.num_attempts);
mentoring.renderAttempts(); mentoring.renderAttempts();
// Messages should only be displayed upon hitting 'submit', not on page reload if (!hide_results) {
mentoring.setContent(messagesDOM, results.message); mentoring.setContent(messagesDOM, response.message);
if (messagesDOM.html().trim()) { if (messagesDOM.html().trim()) {
messagesDOM.prepend('<div class="title1">' + mentoring.data.feedback_label + '</div>'); messagesDOM.prepend('<div class="title1">' + mentoring.data.feedback_label + '</div>');
messagesDOM.show(); 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'); submitDOM.attr('disabled', 'disabled');
} }
}
function submit() { function handleSubmitError(jqXHR, textStatus, errorThrown, disable_submit) {
var success = true; if (textStatus == "error") {
var errMsg = errorThrown;
// Check if there's a more specific JSON error message:
if (jqXHR.responseText) {
// Is there a more specific error message we can show?
try {
errMsg = JSON.parse(jqXHR.responseText).error;
} catch (error) { errMsg = jqXHR.responseText.substr(0, 300); }
}
mentoring.setContent(messagesDOM, errMsg);
messagesDOM.show();
}
if (disable_submit) {
submitDOM.attr('disabled', 'disabled');
}
}
function calculate_results(handler_name, disable_submit) {
var data = {}; var data = {};
var children = mentoring.children; var children = mentoring.children;
for (var i = 0; i < children.length; i++) { for (var i = 0; i < children.length; i++) {
var child = children[i]; var child = children[i];
if (child && child.name !== undefined && typeof(child.submit) !== "undefined") { if (child && child.name !== undefined && typeof(child[handler_name]) !== "undefined") {
data[child.name] = child.submit(); data[child.name.toString()] = child[handler_name]();
} }
} }
var handlerUrl = runtime.handlerUrl(element, 'submit'); var handlerUrl = runtime.handlerUrl(element, handler_name);
if (submitXHR) { if (submitXHR) {
submitXHR.abort(); 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() { function clearResults() {
...@@ -70,15 +113,20 @@ function MentoringStandardView(runtime, element, mentoring) { ...@@ -70,15 +113,20 @@ function MentoringStandardView(runtime, element, mentoring) {
submitDOM.show(); submitDOM.show();
var options = { var options = {
onChange: onChange onChange: onChange,
validateXBlock: validateXBlock
}; };
mentoring.initChildren(options); mentoring.initChildren(options);
mentoring.renderAttempts();
mentoring.renderDependency(); mentoring.renderDependency();
get_results();
var submitPossible = submitDOM.length > 0;
if (submitPossible) {
mentoring.renderAttempts();
validateXBlock(); validateXBlock();
} // else display_submit is false and this is read-only
} }
// validate all children // validate all children
......
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);
}
});
}
};
}
// TODO: Split in two files // TODO: Split in two files
function display_message(message, messageView, checkmark){
if (message) {
var msg = '<div class="message-content">' + message + '</div>' +
'<div class="close icon-remove-sign fa-times-circle"></div>';
messageView.showMessage(msg);
if (checkmark) {
checkmark.addClass('checkmark-clickable');
checkmark.on('click', function(ev) {
ev.stopPropagation();
messageView.showMessage(msg);
});
}
}
}
function MessageView(element, mentoring) { function MessageView(element, mentoring) {
return { return {
messageDOM: $('.feedback', element), messageDOM: $('.feedback', element),
...@@ -18,16 +33,23 @@ function MessageView(element, mentoring) { ...@@ -18,16 +33,23 @@ function MessageView(element, mentoring) {
// Set the width/height // Set the width/height
var tip = $('.tip', popupDOM)[0]; var tip = $('.tip', popupDOM)[0];
var data = $(tip).data(); var data = $(tip).data();
var innerDOM = popupDOM.find('.tip-choice-group');
if (data && data.width) { if (data && data.width) {
popupDOM.css('width', data.width); popupDOM.css('width', data.width);
innerDOM.css('width', data.width);
} else { } else {
popupDOM.css('width', ''); popupDOM.css('width', '');
innerDOM.css('width', '');
} }
if (data && data.height) { if (data && data.height) {
popupDOM.css('height', data.height); popupDOM.css('height', data.height);
popupDOM.css('maxHeight', data.height);
innerDOM.css('maxHeight', data.height);
} else { } else {
popupDOM.css('height', ''); popupDOM.css('height', '');
popupDOM.css('maxHeight', '');
innerDOM.css('maxHeight', '');
} }
var container = popupDOM.parent('.choice-tips-container'); var container = popupDOM.parent('.choice-tips-container');
...@@ -66,6 +88,7 @@ function MessageView(element, mentoring) { ...@@ -66,6 +88,7 @@ function MessageView(element, mentoring) {
this.allResultsDOM.removeClass( this.allResultsDOM.removeClass(
'checkmark-incorrect icon-exclamation fa-exclamation checkmark-correct icon-ok fa-check' '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) { ...@@ -90,45 +113,47 @@ function MCQBlock(runtime, element) {
} }
}, },
handleSubmit: function(result) { handleReview: function(result){
if (this.mode === 'assessment') $('.choice input[value="' + result.submission + '"]', element).prop('checked', true);
return; $('.choice input', element).prop('disabled', true);
},
handleSubmit: function(result, options) {
mentoring = this.mentoring; var mentoring = this.mentoring;
var messageView = MessageView(element, mentoring); var messageView = MessageView(element, mentoring);
messageView.clearResult(); messageView.clearResult();
var choiceInputs = $('.choice input', element); var choiceInputDOM = $('.choice-selector input[value="'+ result.submission +'"]');
$.each(choiceInputs, function(index, choiceInput) {
var choiceInputDOM = $(choiceInput);
var choiceDOM = choiceInputDOM.closest('.choice'); var choiceDOM = choiceInputDOM.closest('.choice');
var choiceResultDOM = $('.choice-result', choiceDOM); var choiceResultDOM = $('.choice-result', choiceDOM);
var choiceTipsDOM = $('.choice-tips', choiceDOM); var choiceTipsDOM = $('.choice-tips', choiceDOM);
var choiceTipsCloseDOM;
if (result.status === "correct" && choiceInputDOM.val() === result.submission) { // We're showing previous answers, so go ahead and display results as well
choiceDOM.addClass('correct'); 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'); choiceResultDOM.addClass('checkmark-correct icon-ok fa-check');
} choiceResultDOM.attr('aria-label', choiceResultDOM.data('label_correct'));
else if (choiceInputDOM.val() === result.submission || _.isNull(result.submission)) { } else {
choiceDOM.addClass('incorrect'); choiceDOM.addClass('incorrect');
choiceResultDOM.addClass('checkmark-incorrect icon-exclamation fa-exclamation'); 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() { choiceResultDOM.off('click').on('click', function() {
if (choiceTipsDOM.html() !== '') { if (choiceTipsDOM.html() !== '') {
messageView.showMessage(choiceTipsDOM); messageView.showMessage(choiceTipsDOM);
} }
}); });
}); if (result.tips) {
mentoring.setContent(choiceTipsDOM, result.tips);
messageView.showMessage(choiceTipsDOM);
}
}
if (_.isNull(result.submission)) { if (_.isNull(result.submission)) {
messageView.showMessage('<div class="message-content">You have not provided an answer.</div>' + messageView.showMessage('<div class="message-content">You have not provided an answer.</div>' +
...@@ -171,29 +196,36 @@ function MRQBlock(runtime, element) { ...@@ -171,29 +196,36 @@ function MRQBlock(runtime, element) {
return checkedValues; 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) { handleSubmit: function(result, options) {
if (this.mode === 'assessment')
return;
mentoring = this.mentoring; var mentoring = this.mentoring;
var messageView = MessageView(element, mentoring); var messageView = MessageView(element, mentoring);
if (result.message) {
messageView.showMessage('<div class="message-content">' + result.message + '</div>'+
'<div class="close icon-remove-sign fa-times-circle"></div>');
}
var questionnaireDOM = $('fieldset.questionnaire', element); var questionnaireDOM = $('fieldset.questionnaire', element);
var data = questionnaireDOM.data(); 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) { $.each(result.choices, function(index, choice) {
var choiceInputDOM = $('.choice input[value='+choice.value+']', element); var choiceInputDOM = $('.choice input[value='+choice.value+']', element);
var choiceDOM = choiceInputDOM.closest('.choice'); var choiceDOM = choiceInputDOM.closest('.choice');
var choiceResultDOM = $('.choice-result', choiceDOM); var choiceResultDOM = $('.choice-result', choiceDOM);
var choiceTipsDOM = $('.choice-tips', choiceDOM); var choiceTipsDOM = $('.choice-tips', choiceDOM);
var choiceTipsCloseDOM;
/* show hint if checked or max_attempts is disabled */ /* show hint if checked or max_attempts is disabled */
if (!hide_results && if (!hide_results &&
...@@ -201,14 +233,15 @@ function MRQBlock(runtime, element) { ...@@ -201,14 +233,15 @@ function MRQBlock(runtime, element) {
if (choice.completed) { if (choice.completed) {
choiceDOM.addClass('correct'); choiceDOM.addClass('correct');
choiceResultDOM.addClass('checkmark-correct icon-ok fa-check'); choiceResultDOM.addClass('checkmark-correct icon-ok fa-check');
choiceResultDOM.attr('aria-label', choiceResultDOM.data('label_correct'));
} else if (!choice.completed) { } else if (!choice.completed) {
choiceDOM.addClass('incorrect'); choiceDOM.addClass('incorrect');
choiceResultDOM.addClass('checkmark-incorrect icon-exclamation fa-exclamation'); choiceResultDOM.addClass('checkmark-incorrect icon-exclamation fa-exclamation');
choiceResultDOM.attr('aria-label', choiceResultDOM.data('label_incorrect'));
} }
mentoring.setContent(choiceTipsDOM, choice.tips); mentoring.setContent(choiceTipsDOM, choice.tips);
choiceTipsCloseDOM = $('.close', choiceTipsDOM);
choiceResultDOM.off('click').on('click', function() { choiceResultDOM.off('click').on('click', function() {
messageView.showMessage(choiceTipsDOM); messageView.showMessage(choiceTipsDOM);
}); });
......
// 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);
}
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);
}
};
}
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();
}
};
}
(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 = $("<span>").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
};
})();
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 = $(
'<span class="clarification" tabindex="0" role="note" aria-label="Clarification">' +
'<i data-tooltip-show-on-click="true" class="fa fa-info-circle" aria-hidden="true"></i>' +
'<span class="sr"></span>' +
'</span>'
);
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});
}
}
};
This source diff could not be displayed because it is too large. You can view the blob instead.
.mentoring .questionnaire .choice-tips,
.mentoring .questionnaire .feedback {
box-sizing: content-box; /* Avoid a global reset to border-box found on Apros */
}
.themed-xblock.mentoring .choices-list .choice-selector {
padding: 4px 3px 0 3px;
font-size: 16px;
}
.mentoring .title h2 {
/* Same as h2.main in Apros */
color: #66a5b5;
}
.mentoring h3 {
text-transform: uppercase;
}
.themed-xblock.mentoring .sb-review-score {
margin-left: 40px;
margin-top: 15px;
}
.themed-xblock.mentoring .review-tips-list li {
margin-left: 1.8em;
padding-left: 0;
}
.themed-xblock.mentoring .copyright {
display: none;
}
.themed-xblock.mentoring .questionnaire .choice-result {
display: table-cell;
}
.themed-xblock.mentoring .choice-result::before { .themed-xblock.mentoring .choice-result::before {
content: ""; content: "";
display: block; display: block;
...@@ -31,7 +27,7 @@ div.course-wrapper section.course-content .themed-xblock.mentoring p:empty { ...@@ -31,7 +27,7 @@ div.course-wrapper section.course-content .themed-xblock.mentoring p:empty {
margin-bottom: 1.41575em; margin-bottom: 1.41575em;
} }
.themed-xblock.mentoring .choices-list { .themed-xblock.mentoring .questionnaire .choices-list {
display: table; display: table;
width: 100%; width: 100%;
border-spacing: 0; border-spacing: 0;
...@@ -40,9 +36,6 @@ div.course-wrapper section.course-content .themed-xblock.mentoring p:empty { ...@@ -40,9 +36,6 @@ div.course-wrapper section.course-content .themed-xblock.mentoring p:empty {
.themed-xblock.mentoring .choice-label { .themed-xblock.mentoring .choice-label {
display: table-cell; display: table-cell;
vertical-align: middle; vertical-align: middle;
width: 100%;
padding-bottom: 10px;
padding-top: 2px;
} }
.themed-xblock.mentoring .choice-label span.low { .themed-xblock.mentoring .choice-label span.low {
...@@ -55,7 +48,7 @@ div.course-wrapper section.course-content .themed-xblock.mentoring p:empty { ...@@ -55,7 +48,7 @@ div.course-wrapper section.course-content .themed-xblock.mentoring p:empty {
vertical-align: top; vertical-align: top;
} }
.themed-xblock.mentoring .choice-tips { .themed-xblock.mentoring .choice-tips-container .choice-tips {
position: relative; position: relative;
} }
...@@ -104,3 +97,11 @@ div.course-wrapper section.course-content .themed-xblock.mentoring p:empty { ...@@ -104,3 +97,11 @@ div.course-wrapper section.course-content .themed-xblock.mentoring p:empty {
.themed-xblock.mentoring .choice.incorrect .choice-tips-container.active { .themed-xblock.mentoring .choice.incorrect .choice-tips-container.active {
border-color: #c1373f; border-color: #c1373f;
} }
.themed-xblock.mentoring .review-list {
margin-top: 0;
}
.themed-xblock.mentoring .grade .grade-result .results-section p {
margin-bottom: 4px; /* Override LMS rule 'div.course-wrapper section.course-content p { margin-bottom: huge; }' */
}
...@@ -20,18 +20,20 @@ ...@@ -20,18 +20,20 @@
# Imports ########################################################### # Imports ###########################################################
from lxml import etree from django.utils.safestring import mark_safe
from lazy import lazy
import uuid
from xblock.core import XBlock from xblock.core import XBlock
from xblock.fields import Scope, String, Float, List, UNIQUE_ID from xblock.fields import Scope, String
from xblock.fragment import Fragment from xblock.fragment import Fragment
from xblock.validation import ValidationMessage from xblock.validation import ValidationMessage
from xblockutils.helpers import child_isinstance from xblockutils.helpers import child_isinstance
from xblockutils.resources import ResourceLoader from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import StudioEditableXBlockMixin, StudioContainerXBlockMixin from xblockutils.studio_editable import StudioEditableXBlockMixin, StudioContainerXBlockMixin, XBlockWithPreviewMixin
from .choice import ChoiceBlock from .choice import ChoiceBlock
from .mentoring import MentoringBlock from .message import MentoringMessageBlock
from .step import StepMixin from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin
from .tip import TipBlock from .tip import TipBlock
# Globals ########################################################### # Globals ###########################################################
...@@ -47,7 +49,10 @@ def _(text): ...@@ -47,7 +49,10 @@ def _(text):
@XBlock.needs("i18n") @XBlock.needs("i18n")
class QuestionnaireAbstractBlock(StudioEditableXBlockMixin, StudioContainerXBlockMixin, StepMixin, XBlock): class QuestionnaireAbstractBlock(
StudioEditableXBlockMixin, StudioContainerXBlockMixin, QuestionMixin, XBlock, XBlockWithPreviewMixin,
XBlockWithTranslationServiceMixin
):
""" """
An abstract class used for MCQ/MRQ blocks An abstract class used for MCQ/MRQ blocks
...@@ -55,38 +60,26 @@ class QuestionnaireAbstractBlock(StudioEditableXBlockMixin, StudioContainerXBloc ...@@ -55,38 +60,26 @@ class QuestionnaireAbstractBlock(StudioEditableXBlockMixin, StudioContainerXBloc
values entered by the student, and supports multiple types of multiple-choice values entered by the student, and supports multiple types of multiple-choice
set, with preset choices and author-defined values. set, with preset choices and author-defined values.
""" """
name = String(
# This doesn't need to be a field but is kept for backwards compatibility with v1 student data
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
)
question = String( question = String(
display_name=_("Question"), display_name=_("Question"),
help=_("Question to ask the student"), help=_("Question to ask the student"),
scope=Scope.content, scope=Scope.content,
default="" default="",
) multiline_editor=True,
message = String(
display_name=_("Message"),
help=_("General feedback provided when submiting"),
scope=Scope.content,
default=""
)
weight = Float(
display_name=_("Weight"),
help=_("Defines the maximum total grade of this question."),
default=1,
scope=Scope.content,
enforce_type=True
) )
editable_fields = ('question', 'message', 'weight', 'display_name', 'show_title')
editable_fields = ('question', 'weight', 'display_name', 'show_title')
has_children = True has_children = True
answerable = True
@lazy
def html_id(self):
"""
A short, simple ID string used to uniquely identify this question.
def _(self, text): This is only used by templates for matching <input> and <label> elements.
""" translate text """ """
return self.runtime.service(self, "i18n").ugettext(text) return uuid.uuid4().hex[:20]
def student_view(self, context=None): def student_view(self, context=None):
name = getattr(self, "unmixed_class", self.__class__).__name__ name = getattr(self, "unmixed_class", self.__class__).__name__
...@@ -100,8 +93,10 @@ class QuestionnaireAbstractBlock(StudioEditableXBlockMixin, StudioContainerXBloc ...@@ -100,8 +93,10 @@ class QuestionnaireAbstractBlock(StudioEditableXBlockMixin, StudioContainerXBloc
fragment = Fragment(loader.render_template(template_path, context)) fragment = Fragment(loader.render_template(template_path, context))
# If we use local_resource_url(self, ...) the runtime may insert many identical copies # If we use local_resource_url(self, ...) the runtime may insert many identical copies
# of questionnaire.[css/js] into the DOM. So we use the mentoring block here if possible # of questionnaire.[css/js] into the DOM. So we use the mentoring block here if possible.
block_with_resources = self.get_parent() block_with_resources = self.get_parent()
from .mentoring import MentoringBlock
# We use an inline import here to avoid a circular dependency with the .mentoring module.
if not isinstance(block_with_resources, MentoringBlock): if not isinstance(block_with_resources, MentoringBlock):
block_with_resources = self block_with_resources = self
fragment.add_css_url(self.runtime.local_resource_url(block_with_resources, 'public/css/questionnaire.css')) fragment.add_css_url(self.runtime.local_resource_url(block_with_resources, 'public/css/questionnaire.css'))
...@@ -126,7 +121,7 @@ class QuestionnaireAbstractBlock(StudioEditableXBlockMixin, StudioContainerXBloc ...@@ -126,7 +121,7 @@ class QuestionnaireAbstractBlock(StudioEditableXBlockMixin, StudioContainerXBloc
@property @property
def human_readable_choices(self): def human_readable_choices(self):
return [{"display_name": c.content, "value": c.value} for c in self.custom_choices] return [{"display_name": mark_safe(c.content), "value": c.value} for c in self.custom_choices]
@staticmethod @staticmethod
def choice_values_provider(question): def choice_values_provider(question):
...@@ -155,13 +150,28 @@ class QuestionnaireAbstractBlock(StudioEditableXBlockMixin, StudioContainerXBloc ...@@ -155,13 +150,28 @@ class QuestionnaireAbstractBlock(StudioEditableXBlockMixin, StudioContainerXBloc
return choice.content return choice.content
return submission return submission
def get_author_edit_view_fragment(self, context):
fragment = super(QuestionnaireAbstractBlock, self).author_edit_view(context)
return fragment
def author_edit_view(self, context): def author_edit_view(self, context):
""" """
Add some HTML to the author view that allows authors to add choices and tips. Add some HTML to the author view that allows authors to add choices and tips.
""" """
fragment = super(QuestionnaireAbstractBlock, self).author_edit_view(context) fragment = self.get_author_edit_view_fragment(context)
fragment.add_content(loader.render_template('templates/html/questionnaire_add_buttons.html', {}))
# Let the parent block determine whether to display buttons to add review-related child blocks.
# * Problem Builder units use MentoringBlock parent components, which define an 'is_assessment' property,
# indicating whether the (deprecated) assessment mode is enabled.
# * Step Builder units can show review components in the Review Step.
fragment.add_content(loader.render_template('templates/html/questionnaire_add_buttons.html', {
'show_review': getattr(self.get_parent(), 'is_assessment', True),
}))
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/questionnaire-edit.css')) fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/questionnaire-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/mentoring_edit.js'))
fragment.initialize_js('MentoringEditComponents')
return fragment return fragment
def validate_field_data(self, validation, data): def validate_field_data(self, validation, data):
...@@ -206,3 +216,22 @@ class QuestionnaireAbstractBlock(StudioEditableXBlockMixin, StudioContainerXBloc ...@@ -206,3 +216,22 @@ class QuestionnaireAbstractBlock(StudioEditableXBlockMixin, StudioContainerXBloc
break break
values_with_tips.update(values) values_with_tips.update(values)
return validation return validation
def get_review_tip(self):
""" Get the text to show on the assessment review when the student gets this question wrong """
for child_id in self.children:
if child_isinstance(self, child_id, MentoringMessageBlock):
child = self.runtime.get_block(child_id)
if child.type == "on-assessment-review-question":
return child.content
@property
def message_formatted(self):
""" Get the feedback message HTML, if any, formatted by the runtime """
if self.message:
# For any HTML that we aren't 'rendering' through an XBlock view such as
# student_view the runtime may need to rewrite URLs
# e.g. converting '/static/x.png' to '/c4x/.../x.png'
format_html = getattr(self.runtime, 'replace_urls', lambda html: html)
return format_html(self.message)
return ""
# -*- coding: utf-8 -*-
#
# Copyright (c) 2014-2015 Harvard, edX & OpenCraft
#
# This software's license gives you freedom; you can copy, convey,
# propagate, redistribute and/or modify this program under the terms of
# the GNU Affero General Public License (AGPL) as published by the Free
# Software Foundation (FSF), either version 3 of the License, or (at your
# option) any later version of the AGPL published by the FSF.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
# General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program in a file in the toplevel directory called
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
# Imports ###########################################################
import logging
import uuid
from xblock.core import XBlock
from xblock.fields import Scope, String, Float
from xblock.fragment import Fragment
from xblockutils.studio_editable import StudioEditableXBlockMixin
from xblockutils.resources import ResourceLoader
from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin
from .sub_api import sub_api, SubmittingXBlockMixin
# Globals ###########################################################
log = logging.getLogger(__name__)
loader = ResourceLoader(__name__)
# Make '_' a no-op so we can scrape strings
def _(text):
return text
# Classes ###########################################################
@XBlock.needs("i18n")
class SliderBlock(
SubmittingXBlockMixin, QuestionMixin, StudioEditableXBlockMixin, XBlockWithTranslationServiceMixin, XBlock,
):
"""
An XBlock used by students to indicate a numeric value on a sliding scale.
The student's answer is always considered "correct".
"""
CATEGORY = 'pb-slider'
STUDIO_LABEL = _(u"Ranged Value Slider")
answerable = True
min_label = String(
display_name=_("Low"),
help=_("Label for low end of the range"),
scope=Scope.content,
default=_("0%"),
)
max_label = String(
display_name=_("High"),
help=_("Label for high end of the range"),
scope=Scope.content,
default=_("100%"),
)
question = String(
display_name=_("Question"),
help=_("Question to ask the student (optional)"),
scope=Scope.content,
default="",
multiline_editor=True,
)
student_value = Float(
# The value selected by the student
default=None,
scope=Scope.user_state,
)
editable_fields = ('min_label', 'max_label', 'display_name', 'question', 'show_title')
@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(SliderBlock, self).url_name
except AttributeError:
return self.name
def mentoring_view(self, context):
""" Main view of this block """
context = context.copy() if context else {}
context['question'] = self.question
context['slider_id'] = 'pb-slider-{}'.format(uuid.uuid4().hex[:20])
context['initial_value'] = int(self.student_value*100) if self.student_value is not None else 50
context['min_label'] = self.min_label
context['max_label'] = self.max_label
context['title'] = self.display_name_with_default
context['hide_header'] = context.get('hide_header', False) or not self.show_title
context['instructions_string'] = self._("Select a value from {min_label} to {max_label}").format(
min_label=self.min_label, max_label=self.max_label
)
html = loader.render_template('templates/html/slider.html', context)
fragment = Fragment(html)
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/slider.js'))
fragment.initialize_js('SliderBlock')
return fragment
student_view = mentoring_view
preview_view = mentoring_view
def author_view(self, context):
"""
Add some HTML to the author view that allows authors to see the ID of the block, so they
can refer to it in other blocks such as Plot blocks.
"""
context['hide_header'] = True # Header is already shown in the Studio wrapper
fragment = self.student_view(context)
fragment.add_content(loader.render_template('templates/html/slider_edit_footer.html', {
"url_name": self.url_name
}))
return fragment
def get_last_result(self):
""" Return the current/last result in the required format """
if self.student_value is None:
return {}
return {
'submission': self.student_value,
'status': 'correct',
'tips': [],
'weight': self.weight,
'score': 1,
}
def get_results(self, _previous_result_unused=None):
""" Alias for get_last_result() """
return self.get_last_result()
def submit(self, value):
log.debug(u'Received Slider submission: "%s"', value)
if value < 0 or value > 1:
return {} # Invalid
self.student_value = value
if sub_api:
# Also send to the submissions API:
sub_api.create_submission(self.student_item_key, value)
result = self.get_last_result()
log.debug(u'Slider submission result: %s', result)
return result
def get_submission_display(self, submission):
"""
Get the human-readable version of a submission value
"""
return submission * 100
def validate_field_data(self, validation, data):
"""
Validate this block's field data.
"""
super(SliderBlock, self).validate_field_data(validation, data)
# -*- coding: utf-8 -*-
from south.db import db
from south.v2 import SchemaMigration
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'Share'
db.create_table('problem_builder_share', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('shared_by', self.gf('django.db.models.fields.related.ForeignKey')(
related_name='problem_builder_shared_by', to=orm['auth.User']
)),
('submission_uid', self.gf('django.db.models.fields.CharField')(max_length=32)),
('block_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('shared_with', self.gf('django.db.models.fields.related.ForeignKey')(
related_name='problem_builder_shared_with', to=orm['auth.User']
)),
('notified', self.gf('django.db.models.fields.BooleanField')(default=False, db_index=True)),
))
db.send_create_signal('problem_builder', ['Share'])
# Adding unique constraint on 'Share', fields ['shared_by', 'shared_with', 'block_id']
db.create_unique('problem_builder_share', ['shared_by_id', 'shared_with_id', 'block_id'])
def backwards(self, orm):
# Removing unique constraint on 'Share', fields ['shared_by', 'shared_with', 'block_id']
db.delete_unique('problem_builder_share', ['shared_by_id', 'shared_with_id', 'block_id'])
# Deleting model 'Share'
db.delete_table('problem_builder_share')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {
'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'
})
},
'auth.permission': {
'Meta': {
'ordering': "('content_type__app_label', 'content_type__model', 'codename')",
'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'
},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {
'to': "orm['contenttypes.ContentType']"
}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {
'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'
}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {
'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'
}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {
'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)",
'object_name': 'ContentType', 'db_table': "'django_content_type'"
},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'problem_builder.answer': {
'Meta': {'unique_together': "(('student_id', 'course_id', 'name'),)", 'object_name': 'Answer'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'created_on': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified_on': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'student_id': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
'student_input': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'})
},
'problem_builder.share': {
'Meta': {'unique_together': "(('shared_by', 'shared_with', 'block_id'),)", 'object_name': 'Share'},
'block_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'notified': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_index': 'True'}),
'shared_by': ('django.db.models.fields.related.ForeignKey', [], {
'related_name': "'problem_builder_shared_by'", 'to': "orm['auth.User']"
}),
'shared_with': ('django.db.models.fields.related.ForeignKey', [], {
'related_name': "'problem_builder_shared_with'", 'to': "orm['auth.User']"
}),
'submission_uid': ('django.db.models.fields.CharField', [], {'max_length': '32'})
}
}
complete_apps = ['problem_builder']
"""
Celery task for CSV student answer export.
"""
import time
from celery.task import task
from celery.utils.log import get_task_logger
from instructor_task.models import ReportStore
from opaque_keys.edx.keys import CourseKey
from student.models import user_by_anonymous_id
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from .mcq import MCQBlock, RatingBlock
from problem_builder.answer import AnswerBlock
from .questionnaire import QuestionnaireAbstractBlock
from .sub_api import sub_api
logger = get_task_logger(__name__)
@task()
def export_data(course_id, source_block_id_str, block_types, user_ids, match_string):
"""
Exports student answers to all MCQ questions to a CSV file.
"""
start_timestamp = time.time()
logger.debug("Beginning data export")
try:
course_key = CourseKey.from_string(course_id)
src_block = modulestore().get_items(course_key, qualifiers={'name': source_block_id_str}, depth=0)[0]
except IndexError:
raise ValueError("Could not find the specified Block ID.")
course_key_str = unicode(course_key)
type_map = {cls.__name__: cls for cls in [MCQBlock, RatingBlock, AnswerBlock]}
if not block_types:
block_types = tuple(type_map.values())
else:
block_types = tuple(type_map[class_name] for class_name in block_types)
# Build an ordered list of blocks to include in the export
blocks_to_include = []
def scan_for_blocks(block):
""" Recursively scan the course tree for blocks of interest """
if isinstance(block, block_types):
blocks_to_include.append(block)
elif block.has_children:
for child_id in block.children:
try:
scan_for_blocks(block.runtime.get_block(child_id))
except ItemNotFoundError:
# Blocks may refer to missing children. Don't break in this case.
pass
scan_for_blocks(src_block)
# Define the header row of our CSV:
rows = []
rows.append(["Section", "Subsection", "Unit", "Type", "Question", "Answer", "Username"])
# Collect results for each block in blocks_to_include
for block in blocks_to_include:
if not user_ids:
results = _extract_data(course_key_str, block, None, match_string)
rows += results
else:
for user_id in user_ids:
results = _extract_data(course_key_str, block, user_id, match_string)
rows += results
# Generate the CSV:
filename = u"pb-data-export-{}.csv".format(time.strftime("%Y-%m-%d-%H%M%S", time.gmtime(start_timestamp)))
report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD')
report_store.store_rows(course_key, filename, rows)
generation_time_s = time.time() - start_timestamp
logger.debug("Done data export - took {} seconds".format(generation_time_s))
return {
"error": None,
"report_filename": filename,
"start_timestamp": start_timestamp,
"generation_time_s": generation_time_s,
"display_data": [] if len(rows) == 1 else rows[1:1001] # Limit to preview of 1000 items
}
def _extract_data(course_key_str, block, user_id, match_string):
"""
Extract results for `block`.
"""
rows = []
# Extract info for "Section", "Subsection", and "Unit" columns
section_name, subsection_name, unit_name = _get_context(block)
# Extract info for "Type" column
block_type = _get_type(block)
# Extract info for "Question" column
block_question = _get_question(block)
# Extract info for "Answer" and "Username" columns
# - Get all of the most recent student submissions for this block:
submissions = _get_submissions(course_key_str, block, user_id)
# - For each submission, look up student's username and answer:
answer_cache = {}
for submission in submissions:
username = _get_username(submission, user_id)
answer = _get_answer(block, submission, answer_cache)
# Short-circuit if answer does not match search criteria
if not match_string.lower() in answer.lower():
continue
rows.append([section_name, subsection_name, unit_name, block_type, block_question, answer, username])
return rows
def _get_context(block):
"""
Return section, subsection, and unit names for `block`.
"""
block_names_by_type = {}
block_iter = block
while block_iter:
block_iter_type = block_iter.scope_ids.block_type
block_names_by_type[block_iter_type] = block_iter.display_name_with_default
block_iter = block_iter.get_parent() if block_iter.parent else None
section_name = block_names_by_type.get('chapter', '')
subsection_name = block_names_by_type.get('sequential', '')
unit_name = block_names_by_type.get('vertical', '')
return section_name, subsection_name, unit_name
def _get_type(block):
"""
Return type of `block`.
"""
return block.scope_ids.block_type
def _get_question(block):
"""
Return question for `block`; default to question ID if `question` is not set.
"""
return block.question or block.name
def _get_submissions(course_key_str, block, user_id):
"""
Return submissions for `block`.
"""
# Load the actual student submissions for `block`.
# Note this requires one giant query that retrieves all student submissions for `block` at once.
block_id = unicode(block.scope_ids.usage_id.replace(branch=None, version_guid=None))
block_type = _get_type(block)
if block_type == 'pb-answer':
block_id = block.name # item_id of Long Answer submission matches question ID and not block ID
if not user_id:
return sub_api.get_all_submissions(course_key_str, block_id, block_type)
else:
student_dict = {
'student_id': user_id,
'item_id': block_id,
'course_id': course_key_str,
'item_type': block_type,
}
return sub_api.get_submissions(student_dict, limit=1)
def _get_username(submission, user_id):
"""
Return username of student who provided `submission`.
If the anonymous id of the submission can't be resolved into a username, the anonymous
id is returned.
"""
# If the student ID key doesn't exist, we're dealing with a single student and know the ID already.
student_id = submission.get('student_id', user_id)
user = user_by_anonymous_id(student_id)
if user is None:
return student_id
return user.username
def _get_answer(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']
if isinstance(block, QuestionnaireAbstractBlock):
# 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]
return answer
{% load i18n %}
<div class="xblock-answer" data-completed="{{ self.completed }}"> <div class="xblock-answer" data-completed="{{ self.completed }}">
{% if not hide_header %}<h3 class="question-title">{{ self.display_name_with_default }}</h3>{% endif %} {% if not hide_header %}<h3 class="question-title">{{ self.display_name_with_default }}</h3>{% endif %}
<p>{{ self.question }}</p> <label><p>{{ self.question|safe }}</p>
<textarea <textarea
class="answer editable" cols="50" rows="10" name="input" class="answer editable" cols="50" rows="10" name="input"
data-min_characters="{{ self.min_characters }}" data-min_characters="{{ self.min_characters }}"
>{{ self.student_input }}</textarea> >{{ self.student_input }}</textarea>
<span class="answer-checkmark fa icon-2x"></span> </label>
<div style="display: none;" class="orig-student-answer">{{ self.student_input }}</div> <!-- To detect edits -->
<span class="answer-checkmark fa icon-2x" aria-label=""
data-label_correct="{% trans "Correct" %}" data-label_incorrect="{% trans "Incorrect" %}"></span>
</div> </div>
{% load i18n %} {% load i18n %}
<div class="xblock-answer" data-completed="{{ student_input|yesno:"true,false" }}"> <div class="xblock-answer">
{% if not hide_header %}<h3 class="question-title">{{ title }}</h3>{% endif %} {% if not hide_header %}<h3 class="question-title">{{ title }}</h3>{% endif %}
{% if description %}<p>{{ description|safe }}</p>{% endif %} {% if description %}<p>{{ description|safe }}</p>{% endif %}
<blockquote class="answer read_only"> <blockquote class="answer read_only">
......
{% load i18n %}
<h2>{% trans "Instructor Tool" %}</h3>
<div class="data-export-options">
<div class="data-export-header">
<h3>{% trans "Filters" %}</h3>
</div>
<div class="data-export-row">
<div class="data-export-field-container">
<div class="data-export-field">
<label>
<span>{% trans "Username[s]:" %}</span>
<input type="text" name="usernames" title="{% trans "Enter one or more usernames, comma separated." %}" />
</label>
</div>
</div>
<div class="data-export-field-container">
<div class="data-export-field">
<label>
<span>{% trans "Text:" %}</span>
<input type="text" name="match_string" />
</label>
</div>
</div>
</div>
<div class="data-export-row">
<div class="data-export-field-container">
<div class="data-export-field">
<label>
<span>{% trans "Section/Question:" %}</span>
<select name="root_block_id">
{% for block in block_tree %}
<option value="{{ block.id }}"
{% if not block.eligible %} disabled="disabled" {% endif %}>
{% for _ in ""|ljust:block.depth %}&nbsp;&nbsp;{% endfor %}
{{ block.name }}
</option>
{% endfor %}
</select>
</label>
</div>
</div>
<div class="data-export-field-container">
<div class="data-export-field">
<label>
<span>{% trans "Problem types:" %}</span>
<select name="block_types">
<option value="all">{% trans "All" %}</option>
{% for label, value in block_choices.items %}
<option value="{{value}}">{{label}}</option>
{% endfor %}
</select>
</label>
</div>
</div>
<div class="data-export-actions">
<button class="data-export-start">{% trans "Search" %}</button>
</div>
</div>
</div>
<div id="results-wrapper" aria-live="polite">
<div id="results" class="data-export-results">
<table>
<thead>
<tr>
<th>{% trans "Section" %}</th>
<th>{% trans "Subsection" %}</th>
<th>{% trans "Unit" %}</th>
<th>{% trans "Type" %}</th>
<th>{% trans "Question" %}</th>
<th>{% trans "Answer" %}</th>
<th>{% trans "Username" %}</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="data-export-info"></div>
<div class="data-export-result-actions">
<button id="first-page">{% trans "First" %}</button>
<button id="prev-page">{% trans "Prev" %}</button>
<span id="current-page"></span>/<span id="total-pages"></span>
<button id="next-page">{% trans "Next" %}</button>
<button id="last-page">{% trans "Last" %}</button>
</div>
</div>
<div class="data-export-status"></div>
<div class="data-export-actions">
<button class="data-export-download">{% trans "Download as CSV" %}</button>
<button class="data-export-cancel">{% trans "Cancel search" %}</button>
<button class="data-export-delete">{% trans "Delete results" %}</button>
</div>
</div>
<fieldset class="choices questionnaire"> {% load i18n %}
<legend class="question"> {% if not hide_header %}
{% if not hide_header %}<h3 class="question-title">{{ self.display_name_with_default }}</h3>{% endif %} <h3 class="question-title" id="heading_{{ self.html_id }}">{{ self.display_name_with_default }}</h3>
<p>{{ self.question }}</p> {% endif %}
</legend> <fieldset class="choices questionnaire" id="{{ self.html_id }}">
<legend class="question field-group-hd">{{ self.question|safe }}</legend>
<div class="choices-list"> <div class="choices-list">
{% for choice in custom_choices %} {% for choice in custom_choices %}
<div class="choice"> <div class="choice" aria-live="polite" aria-atomic="true">
<div class="choice-result fa icon-2x"></div> <label class="choice-label"
<label class="choice-label"> aria-describedby="feedback_{{ self.html_id }} choice_tips_{{ self.html_id }}-{{ forloop.counter }}">
<input class="choice-selector" type="radio" name="{{ self.name }}" value="{{ choice.value }}"{% if self.student_choice == choice.value %} checked{% endif %} /> <div class="choice-result fa icon-2x" aria-label=""
<span class="choice-text">{{ choice.content|safe }}</span> data-label_correct="{% trans "Correct" %}" data-label_incorrect="{% trans "Incorrect" %}"></div>
<div class="choice-selector">
<input type="radio" name="{{ self.name }}" value="{{ choice.value }}"
{% if self.student_choice == choice.value and not hide_prev_answer %} checked{% endif %}
/>
</div>
{{ choice.content|safe }}
</label> </label>
<div class="choice-tips-container"> <div class="choice-tips-container">
<div class="choice-tips"></div> <div class="choice-tips" id="choice_tips_{{ self.html_id }}-{{ forloop.counter }}"></div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
<div class="feedback"></div> <div class="feedback" id="feedback_{{ self.html_id }}"></div>
</div> </div>
</fieldset> </fieldset>
{% load i18n %}
{% if allow_sharing %}
<div class="share-panel-container">
<div class="mentoring-share-panel">
{% if view_options %}
<span>{% trans "Display Map from:" %}</span>
<select class="mentoring-display-dropdown">
<option value="{{username}}">{% blocktrans %}You ({{username}}){% endblocktrans %}</option>
{% for option in view_options %}
<option value="{{option}}">{{option}}</option>
{% endfor %}
</select>
{% endif %}
<button class="mentoring-share-button">
<i class="fa fa-share-alt"></i>{% trans "Share" %}
</button>
<div class="mentoring-share-with" style="display: none;">
<div class="shared-with-container"></div>
<div class="share-with-instructions">
{% trans "Enter the username of another student you'd like to share this with:" %}
</div>
<div class="new-share-container"><input class="add-share-username"><button class="add-share-field">+</button></div>
<div class="share-errors-container">
<div class="share-errors"></div>
</div>
<div class="share-action-buttons">
<button class="do-share-button">{% trans "Share" %}</button>
</div>
</div>
{% if share_notifications %}
<div class="share-notification" data-shared="{{share_notifications}}">
<a class="notification-close"><i class="fa fa-close"></i></a>
<p><strong>{% trans "Map added!" %}</strong></p>
<p>{% trans "Another user has shared a map with you." %}</p>
<p>{% trans "You can change the user you're currently displaying using the drop-down selector above." %}</p>
</div>
{% endif %}
</div>
</div>
<div class="clear"></div>
{% endif %}
<div class="mentoring-table-container">
<div class="mentoring-table {{ self.type }}" style="background-image: url({{ bg_image_url }})">
<div class="cont-text-sr">{{ bg_image_description }}</div>
<div class="mentoring-table-target"></div>
</div>
</div>
{% if allow_download %}
<div class="report-download-container"><a class="report-download-link" href="#report_download" download="report.html">{% trans "Download report" %}</a></div>
{% endif %}
{% load i18n %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<style>
body {
font-family: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
{{css|safe}}
</style>
</head>
<body>
<div class="mentoring">
<div class="identification">
{% if student_name %}{% trans "Student" %}: {{student_name}}<br>{% endif %}
{% if course_name %}{% trans "Course" %}: {{course_name}}<br>{% endif %}
{% trans "Date" %}: {% now "DATE_FORMAT" %}<br>
</div>
REPORT_GOES_HERE
</div>
</body>
</html>
{% load i18n %}
{% if shared_with %}
<div class="share-header">{% trans "Shared with:" %}</div>
{% endif %}
<ul class="shared-list">
{% for username in shared_with %}
<li><span class="username">{{username}}</span><a class="remove-share" href="#"><i class="fa fa-trash"></i></a></li>
{% endfor %}
</ul>
\ No newline at end of file
<div class="mentoring-table {{ self.type }}" style="background-image: url({{ bg_image_url }})"> <table>
<div class="cont-text-sr">{{ bg_image_description }}</div>
<table>
{% if header_values %} {% if header_values %}
<thead> <thead>
{% for header in header_values %} {% for header in header_values %}
...@@ -19,5 +17,4 @@ ...@@ -19,5 +17,4 @@
{% endfor %} {% endfor %}
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> \ No newline at end of file
{% load i18n %} {% load i18n %}
<div class="mentoring themed-xblock" data-mode="{{ self.mode }}" data-step="{{ self.step }}" data-feedback_label="{{ self.feedback_label}}"> <div class="mentoring themed-xblock" data-mode="{{ self.mode }}" data-step="{{ self.step }}" data-feedback_label="{{ self.feedback_label }}" data-hide_feedback="{{ self.hide_feedback }}">
<div class="missing-dependency warning" data-missing="{{ self.has_missing_dependency }}"> <div class="missing-dependency warning" data-missing="{{ self.has_missing_dependency }}">
{% with url=missing_dependency_url|safe %} {% with url=missing_dependency_url|safe %}
{% blocktrans with link_start="<a href='"|add:url|add:"'>" link_end="</a>" %} {% blocktrans with link_start="<a href='"|add:url|add:"'>" link_end="</a>" %}
...@@ -9,23 +9,31 @@ ...@@ -9,23 +9,31 @@
{% endwith %} {% endwith %}
</div> </div>
{% if title %} {% if show_title and title %}
<div class="title"> <div class="title">
{% if title %} <h2>{{ title }}</h2> {% endif %} <h2>{{ title }}</h2>
</div> </div>
{% endif %} {% endif %}
<div class="{{self.mode}}-question-block"> <div class="{{self.mode}}-question-block">
<div class="assessment-message"></div>
{{child_content|safe}} {{child_content|safe}}
{% if self.display_submit %} {% if self.display_submit %}
<div class="grade" data-score="{{ self.score.1 }}" <div class="grade" data-score="{{ self.score.1 }}"
data-correct_answer="{{ self.score.2 }}" data-correct_answer="{{ self.score.2|length }}"
data-incorrect_answer="{{ self.score.3 }}" data-incorrect_answer="{{ self.score.3|length }}"
data-partially_correct_answer="{{ self.score.4 }}" data-partially_correct_answer="{{ self.score.4|length }}"
data-max_attempts="{{ self.max_attempts }}" data-max_attempts="{{ self.max_attempts }}"
data-num_attempts="{{ self.num_attempts }}"> data-num_attempts="{{ self.num_attempts }}"
data-extended_feedback="{%if self.extended_feedback %}True{% endif %}"
data-assessment_message="{{ self.assessment_message }}"
data-assessment_review_tips="{{ self.review_tips_json }}"
data-correct="{{ self.correct_json }}"
data-incorrect="{{ self.incorrect_json }}"
data-partial="{{ self.partial_json }}">
</div> </div>
<div class="submit"> <div class="submit">
...@@ -33,17 +41,24 @@ ...@@ -33,17 +41,24 @@
<span class="assessment-checkmark fa icon-2x"></span> <span class="assessment-checkmark fa icon-2x"></span>
{% endif %} {% endif %}
<input type="button" class="input-main" value="Submit" disabled="disabled" /> <input type="button" class="input-main" value="{% trans "Submit" %}" disabled="disabled" />
{% if self.mode == 'assessment' %} {% if self.mode == 'assessment' %}
<input type="button" class="input-next" value="Next Question" disabled="disabled" /> <input type="button" class="input-next" value="{% trans "Next Question" %}" disabled="disabled" />
<input type="button" class="input-review" value="Review grade" disabled="disabled" /> <input type="button" class="input-review" value="{% trans "Review grade" %}" disabled="disabled" />
<input type="button" class="input-try-again" value="Try again" disabled="disabled" /> <input type="button" class="input-try-again" value="{% trans "Try again" %}" disabled="disabled" />
{% endif %} {% endif %}
<div class="attempts" data-max_attempts="{{ self.max_attempts }}" data-num_attempts="{{ self.num_attempts }}"></div> <div class="attempts" data-max_attempts="{{ self.max_attempts }}" data-num_attempts="{{ self.num_attempts }}"></div>
</div> </div>
{% endif %} {% endif %}
<div class="messages"></div> <div class="messages"></div>
<div class="assessment-review-tips"></div>
</div> </div>
<div class="review-link"><a href="#">{% trans "Review final grade" %}</a></div>
<p class="copyright">
Copyright &copy; 2013&ndash;2015 OpenCraft, Harvard, edX, McKinsey, and The People's Science, released under the
<a target="_blank" href="https://github.com/open-craft/problem-builder/blob/master/LICENSE">{% trans "APGLv3 license" %}</a>
</p>
</div> </div>
{% load i18n %}
<div class="add-xblock-component new-component-item adding">
<div class="new-component">
<h5>{% trans "Add New Component" %}</h5>
<ul class="new-component-type">
<li><a href="#" class="single-template add-xblock-component-button" data-category="pb-answer" data-boilerplate="studio_default">{% trans "Long Answer" %}</a></li>
<li><a href="#" class="single-template add-xblock-component-button" data-category="pb-mcq">{% trans "Multiple Choice Question" %}</a></li>
<li><a href="#" class="single-template add-xblock-component-button" data-category="pb-rating">{% trans "Rating Question" %}</a></li>
<li><a href="#" class="single-template add-xblock-component-button" data-category="pb-mrq">{% trans "Multiple Response Question" %}</a></li>
<li><a href="#" class="single-template add-xblock-component-button" data-category="html">{% trans "HTML" %}</a></li>
<li><a href="#" class="single-template add-xblock-component-button" data-category="pb-answer-recap">{% trans "Long Answer Recap" %}</a></li>
<li><a href="#" class="single-template add-xblock-component-button" data-category="pb-table">{% trans "Answer Recap Table" %}</a></li>
<li><a href="#" class="single-template add-xblock-component-button" data-category="pb-message" data-boilerplate="completed">{% trans "Message (Complete)" %}</a></li>
<li><a href="#" class="single-template add-xblock-component-button" data-category="pb-message" data-boilerplate="incomplete">{% trans "Message (Incomplete)" %}</a></li>
<li><a href="#" class="single-template add-xblock-component-button" data-category="pb-message" data-boilerplate="max_attempts_reached">{% trans "Message (Max # Attempts)" %}</a></li>
</ul>
</div>
</div>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment