Commit 91e8b526 by J. Cliff Dyer Committed by J. Cliff Dyer

Merge branch 'master' into cliff/migrate-course-key

parents 34633365 be723402
...@@ -149,6 +149,47 @@ $ SERVICE_VARIANT=cms DJANGO_SETTINGS_MODULE="cms.envs.devstack" python -m probl ...@@ -149,6 +149,47 @@ $ SERVICE_VARIANT=cms DJANGO_SETTINGS_MODULE="cms.envs.devstack" python -m probl
``` ```
Where "Org/Course/Run" is replaced with the ID of the course to upgrade. Where "Org/Course/Run" is replaced with the ID of the course to upgrade.
Open edX Installation
---------------------
Problem Builder releases are tagged with a version number, e.g.
[`v2.6.0`](https://github.com/open-craft/problem-builder/tree/v2.6.0),
[`v2.6.5`](https://github.com/open-craft/problem-builder/tree/v2.6.5). We recommend installing the most recently tagged
version, with the exception of the following compatibility issues:
* `edx-platform` version `open-release/eucalyptus.2` and earlier must use
[v2.6.0](https://github.com/open-craft/problem-builder/tree/v2.6.0). See
[PR 128](https://github.com/open-craft/problem-builder/pull/128) for details.
* `edx-platform` version `named-release/dogwood.3` must use
[v2.0.0](https://github.com/open-craft/problem-builder/tree/v2.0.0).
* Otherwise, consult the `edx-platform/requirements/edx/edx-private.txt` file to see which revision was
used by [edx.org](https://edx.org) for your branch.
The `edx-platform` `master` branch will generally always be compatible with the most recent Problem Builder tag. See
[edx-private.txt](https://github.com/edx/edx-platform/blob/master/requirements/edx/edx-private.txt) for the version
currently installed on [edx.org](https://edx.org).
To install Problem Builder on an Open edX installation, choose the tag you wish to install, and run:
```bash
$ sudo -u edxapp -Hs
edxapp $ cd ~
edxapp $ source edxapp_env
edxapp $ TAG='v2.6.5' # example revision
edxapp $ pip install "git+https://github.com/open-craft/problem-builder.git@$TAG#egg=xblock-problem-builder==$TAG"
edxapp $ cd edx-platform
edxapp $ ./manage.py lms migrate --settings=aws # or openstack, as appropriate
```
Then, restart the edxapp services:
```bash
$ sudo /edx/bin/supervisorctl restart edxapp:
$ sudo /edx/bin/supervisorctl restart edxapp_workers:
```
See [Usage Instructions](doc/Usage.md) for how to enable in Studio.
License License
------- -------
......
...@@ -11,7 +11,7 @@ dependencies: ...@@ -11,7 +11,7 @@ dependencies:
- "pip install -r requirements.txt" - "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/base.txt"
- "pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements/test.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.5.patch1.tar.gz" - "pip uninstall -y xblock-problem-builder && python setup.py sdist && pip install dist/xblock-problem-builder-2.7.2.tar.gz"
- "pip install -r test_requirements.txt" - "pip install -r test_requirements.txt"
- "mkdir var" - "mkdir var"
test: test:
......
Native API Documentation
========================
This documents the APIs that can be used to implement native wrappers. There are
three types of APIs:
- `student_view_data`: Exposes block settings and content as JSON data. Can be
retrieved via the edX
[Course Blocks API](http://edx.readthedocs.io/projects/edx-platform-api/en/latest/courses/blocks.html),
e.g.,
```
GET https://<lms_server_url>/api/courses/v1/blocks/?course_id=<course_id>&username=<username>&depth=all&requested_fields=student_view_data&student_view_data=<xblock_types>
```
Does not include user-specific data, which is available from `student_view_user_state`.
- `student_view_user_state`: XBlock handler which returns the currently logged
in user's state data in JSON format (e.g. items derived from `Scope.user_state`
fields).
```
GET https://<lms_server_url>/courses/<course_id>/xblock/<xblock_id>/handler/student_view_user_state
```
- Custom XBlock handlers used for submitting user input and recording user actions.
Problem Builder (`problem-builder`)
-----------------------------------
### `student_view_data`
- `max_attempts`: (integer) Max number of allowed attempts.
- `extended_feedback`: (boolean) `true` if extended feedback is enabled for this
block.
- `feedback_label`: (string) Feedback message header.
- `messages`: (object) A dict with three string entries: `completed`,
`incomplete`, and `max_attempts_reached`. See below for info on each.
- `components`: (array) A list of `student_view_data` output of all immediate
child components which implement `student_view_data`. Child components which
don't implement `student_view_data` are omitted from the list.
#### `messages`
These messages are displayed as feedback, depending on the state of the
block. Any of these may be `null`.
- `completed`: (string) Message to be shown if the user successfully completes
the problem. May contain HTML.
- `incomplete`: (string) Message to be shown if the user did not successfully
complete the problem, but still has available attempts left. May contain HTML.
- `max_attempts_reached`: (string) Message to be shown if the user did not
complete the problem and has no available attempts left.
### `student_view_user_state`
- `num_attempts`: (integer) Number of attempts used so far.
- `completed`: (boolean) `true` if the user successfully completed the problem at least once.
- `student_results`: (array) A list of user submitted answers with metadata for
each child component. See below for more info.
- `components`: (object) Contains the `student_view_user_state` output of all
immediate child components which implement `student_view_user_state`. Each key
is a component ID (string), and the value is the component's
`student_view_user_state` (object). Child components which don't implement
student_view_user_state are not included in the dict.
#### `student_results`
The `student_results` field is an array of two-element arrays. Each nested array
has the form of `[component_name, data]`. If the user has not made any
submissions to particular component, then the element corresponding to that
component may be omitted from the array. The structure of the `data` object
depends on the type of the child component and is described separately for each
type of child component below.
### Custom Handlers
#### `submit`
The `submit` XBlock JSON handler is used for submitting student input. The
`POST` data should be a dict where keys are child component names. The values
depend on the component type and are described separately for each type of child
component below.
Example URL:
```
POST https://<lms_server_url>/courses/<course_id>/xblock/<xblock_id>/handler/submit
```
Returns a JSON object which contains these fields:
- `results`: (array) Same as `student_results` described under
`student_view_user_state` above.
- `completed`: (boolean) Same as `completed` described under
`student_view_user_state` above.
- `message`: (string) One of the `completed`, `incomplete`, or
`max_attempts_reached` feedback messages described under `messages` above. Can
be `null` if no messages has been defined. May contain HTML.
- `max_attempts`: (integer) Same as `max_attempts` under `student_view_data`.
- `num_attempts`: (integer) Same as `num_attempts` under
`student_view_user_state`.
Step Builder (`step-builder`)
-----------------------------
### `student_view_data`
- `title`: (string) The display name of the component.
- `show_title`: (boolean) `true` if the title should be displayed.
- `weight`: (float) The weight of the problem.
- `extended_feedback`: (boolean) `true` if extended feedback is enabled for this
block.
- `max_attempts`: (integer) Max number of allowed attempts.
- `components`: (array) A list of `student_view_data` output of all immediate
child components which implement `student_view_data`. For `step-builder`,
immediate children are typically `sb-step` and `sb-review-step` type
components. Child components which don't implement `student_view_data` are
omitted from the list.
### `student_view_user_state`
Same as [Problem Builder above](#problem-builder-problem-builder), but also
contains these additional fields:
- `active_step`: (integer) The index of the step which is currently
active. Starts at zero.
### Custom Handlers
#### `submit`
The `submit` XBlock JSON handler is used for submitting student input. The
`POST` data should be a dict where keys are child component names. The values
depend on the component type and are described separately for each type of child
component below.
In addition to child component names, the `POST` data should also include the
`active_step` field. The value should be the index of the current step.
Example URL:
```
POST https://<lms_server_url>/courses/<course_id>/xblock/<xblock_id>/handler/submit --data '{"bf9c37a":[{"name":"input","value":"Freeform answer"}],"71f85d0d3e7dabfc1a8cfecf72dce4284f18b13a":"3","71c526b60ec97364214ac70860def69914de84e7":["000bc8e","da9691e"],"477847c4f3540f37b8b3815430a74632a352064d":0.59,"b66a6115af051939c5ce92fb5ff739ccf704d1e9":false}'
```
#### `try_again`
The `try_again` XBlock JSON handler can be used when the student reaches the
final step. Sending a `POST` request to `try_again` will reset the problem and
all of its children.
Returns a JSON object containing the new value of `active_step`.
Example URL:
```
POST https://<lms_server_url>/courses/<course_id>/xblock/<xblock_id>/handler/try_again
```
Mentoring Step (`sb-step`)
--------------------------
### `student_view_data`
- `type`: (string) Always equals `"sb-step"` for Mentoring Step components.
- `title`: (string) Step component's display name.
- `show_title`: (boolean) `true` if the title should be displayed.
- `next_button_label`: (string) Text label of the "Next Step" button.
- `message`: (string) Feedback or instructional message which should be
displayed after student submits the step. May be `null`.
- `components`: (array) A list of `student_view_data` output of all immediate
child components which implement `student_view_data`. Child components which
don't implement `student_view_data` are omitted from the list.
### `student_view_user_state`
- `student_results`: (array) Same as `student_results` described under
`student_view_user_state` in the Problem Builder section.
- `components`: (object) Same as `components` described under
`student_view_user_state` in the Problem Builder section.
Review Step (`sb-review-step`)
------------------------------
### `student_view_data`
- `type`: (string) Always equals `"sb-review-step`" for Review Step components.
- `title`: (string) Display name of the component.
- `components`: (array) A list of `student_view_data` output of all immediate
child components which implement `student_view_data`. Child components which
don't implement `student_view_data` are omitted from the list.
Conditional Message (`sb-conditional-message`)
----------------------------------------------
Conditional Message component is always child of a Review Step component.
### `student_view_data`
- `type`: (string) Always equals `"sb-conditional-message"` for Conditional
Message components.
- `content`: (string) Content of the message. May contain HTML.
- `score_condition`: (string) One of `"perfect"`, `"imperfect"`, or `"any"`.
- `num_attempts_condition`: (string) One of `"can_try_again"`,
`"cannot_try_again"`, or `"any"`.
Score Summary (`sb-review-score`)
---------------------------------
### `student_view_data`
- `type`: (string) Always equals `"sb-review-score"` for Score Summary
components.
Per-Question Feedback (`sb-review-per-question-feedback`)
---------------------------------------------------------
### `student_view_data`
- `type`: (string) Always equals `"sb-review-per-question-feedback"` for Score
Summary components.
Long Answer (`pb-answer`)
-------------------------
### `student_view_data`
- `type`: (string) Always equals `"pb-answer"` for Long Answer components.
- `id`: (string) Unique ID (name) of the component.
- `weight`: (float) The weight of this component.
- `question`: (string) The question/text displayed above the input field.
### `student_view_user_state`
- `answer_data`: (object) A dict with info about students submitted answer. See
below for more info.
#### `answer_data`
The `answer_data` field contains these items:
- `student_input`: (string) Text that the student entered.
- `created_on`: (string) Date/Time when the answer was first submitted.
- `modified_on`: (string) Date/Time when the answer was last modified.
### `student_results`
- `status`: (string) One of `"correct"` or `"incorrect"`.
- `score`: (integer) Student's score. One of `1` or `0`.
- `weight`: (float) Child component's weight attribute.
- `student_input`: (string) Text entered by the user.
### POST Submit Data
When submitting the problem either via Problem Builder or Step Builder, the data
entry corresponding to the Long Answer block should be a single object
containing the `"value"` property. Example: `{"value": "Student's input"}`.
Multiple Choice Question (`pb-mcq`)
-----------------------------------
### `student_view_data`
- `type`: (string) Always equals `"pb-mcq"` for MCQ components.
- `id`: (string) Unique ID (name) of the component.
- `question`: (string) The content of the question.
- `message`: (string) General feedback provided when submitting.
- `weight`: (float) The weight of the problem.
- `choices`: (array) A list of objects providing info about available
choices. See below for more info.
- `tips`: (array) A list of objects providing info about tips defined for the
problem. See below for more info.
#### `choices`
Each entry in the `choices` array contains these values:
- `content`: (string) Display name of the choice.
- `value`: (string) The value of the choice. This is the value that gets
submitted and stored when users submits their answer.
#### `tips`
Each entry in the `tips` array contains these values:
- `content`: (string) The text content of the tip.
- `for_choices`: (array) A list of string values corresponding to choices to
which this tip applies to.
### `student_view_user_state`
- `student_choice`: (string) The value of the last submitted choice.
### `student_results`
- `status`: (string) One of `"correct"` or `"incorrect"`.
- `score`: (integer) Student's score. One of `1` or `0`.
- `weight`: (float) Child component's weight attribute.
- `submission`: (string) The value of the choice that the user selected.
- `message`: (string) General feedback. May contain HTML.
- `tips`: (string) HTML representation of tips. May be `null`.
### POST Submit Data
When submitting the problem the data should be a single object containing the
`"value"` property which has the value of the selected choice.
Example: `{"value": "blue"}`
Rating Question (`pb-rating`)
-----------------------------
### `student_view_data`
Identical to [MCQ questions](#multiple-choice-question-pb-mcq) except that the
`type` field always equals `"pb-rating"`.
### `student_view_user_state`
- `student_choice`: (string) The value of the last submitted choice.
### `student_results`
Identical to [MCQ questions](#multiple-choice-question-pb-mcq).
### POST Submit Data
When submitting the problem the data should be equal to the string value of the
selected choice. Example: `"3"`.
Multiple Response Question (`pb-mrq`)
-------------------------------------
### `student_view_data`
- `type`: (string) Always equals `"pb-mrq"` for Multiple Response Question
components.
- `id`: (string) Unique ID (name) of the component.
- `title`: (string) Display name of the component.
- `weight`: (float) Weight of the problem.
- `question`: (string) The content of the question.
- `message`: (string) General feedback provided when submitting.
- `hide_results`: (boolean) `true` if results should be hidden.
- `choices`: (array) A list of objects providing info about available
choices. See below for more info.
- `tips`: (array) A list of objects providing info about tips defined for the
problem. See below for more info.
### `student_view_user_state`
- `student_choices`: (array) A list of string values corresponding to choices
last submitted by the student.
### `student_results`
- `status`: (string) One of `"correct"`, `"incorrect"`, or `"partial"`.
- `score`: (float) Student's score. Float in the range `0 - 1`.
- `weight`: (float) Child component's weight attribute.
- `submissions`: (array) A list of string values corresponding to the choices
that the user selected.
- `message`: (string) General feedback. May contain HTML.
- `choices`: (array) A list of dicts containing info about available
choices. See below for more info.
#### `choices`
Each item in the `choices` array contains these fields:
- `completed`: (boolean) Boolean indicating whether the state of the choice is
correct.
- `selected`: (boolean) `true` if the user selected this choice.
- `tips`: (string) Tips formatted as a string of HTML.
- `value`: (string) The value of the choice.
### POST Submit Data
The submitted data should be a JSON array of string values corresponding to
selected choices. Example: `["red","blue"]`.
Ranged Value Slider (`pb-slider`)
---------------------------------
### `student_view_data`
- `type`: (string) Always equals `"pb-slider"` for Ranged Value Slider
components.
- `id`: (string) Unique ID (name) of the component.
- `title`: (string) Display name of the component.
- `hide_header`: (boolean) `true` if header should be hidden.
- `question`: (string) The content of the question.
- `min_label`: (string) Label for the low end of the range.
- `max_label`: (string) Label for the high end of the range.
### `student_view_user_state`
- `student_value`: (float) The value last selected by the user.
### `student_results`
- `status`: (string) Always `"correct"` for Ranged Value Slider components.
- `score`: (integer) Always equals `1` for Ranged Value Slider components.
- `weight`: (float) Child component's weight attribute.
- `submission`: (float) The float value of the user submission, or `null` if the
component has never been submitted.
### POST Submit Data
The submitted data should be a float value representing the value selected by
the user. Example: `0.65`.
Completion (`pb-completion`)
----------------------------
### `student_view_data`
- `type`: (string) Always equals `"pb-completion"` for Completion components.
- `id`: (string) Unique ID (name) of the component.
- `title`: (string) Display name of the problem.
- `hide_header`: (boolean) `true` if the header should be hidden.
- `question`: (string) The content of the question.
- `answer`: (string) Represents the answer that the student can (un)check.
### `student_view_user_state`
- `student_value`: (boolean) Boolean indicating whether the user checked the
checkbox. Can also be `null` if the user never checked or unchecked the
checkbox.
### `student_results`
- `status`: (string) Always `"correct"` for Completion components.
- `score`: (integer) Always equals `1` for Completion components.
- `weight`: (float) Child component's weight attribute.
- `submission`: (boolean) The boolean value of the user submission, or `null` if
the component has never been submitted.
### POST Submit Data
The submitted data should be JSON boolean, `true` if the checkbox is checked and
`false` if it is not. Example: `true`.
Plot (`sb-plot`)
----------------
### `student_view_data`
- `type`: (string) Always equals `"sb-plot"` for Plot components.
- `title`: (string) Display name of the component.
- `q1_label`: (string) Quadrant I label.
- `q2_label`: (string) Quadrant II label.
- `q3_label`: (string) Quadrant III label.
- `q4_label`: (string) Quadrant IV label.
- `point_color_default`: (string) Point color to use for default overlay
(string). - `plot_label`: Label for default overlay which shows student's
answers to scale questions.
- `point_color_average`: (string) Point color to use for the average overlay.
- `overlay_data`: (string) JSON data representing points on overlays.
- `hide_header`: (boolean) Always `true` for Plot components.
...@@ -23,11 +23,6 @@ ...@@ -23,11 +23,6 @@
import logging import logging
from lazy import lazy from lazy import lazy
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.db.models import Q
from django.utils.crypto import get_random_string
from .models import Answer from .models import Answer
from xblock.core import XBlock from xblock.core import XBlock
...@@ -37,7 +32,7 @@ from xblock.validation import ValidationMessage ...@@ -37,7 +32,7 @@ from xblock.validation import ValidationMessage
from xblockutils.resources import ResourceLoader from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import StudioEditableXBlockMixin, XBlockWithPreviewMixin from xblockutils.studio_editable import StudioEditableXBlockMixin, XBlockWithPreviewMixin
from problem_builder.sub_api import SubmittingXBlockMixin, sub_api from problem_builder.sub_api import SubmittingXBlockMixin, sub_api
from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin, StudentViewUserStateMixin
import uuid import uuid
...@@ -54,7 +49,7 @@ def _(text): ...@@ -54,7 +49,7 @@ def _(text):
# Classes ########################################################### # Classes ###########################################################
class AnswerMixin(XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin): class AnswerMixin(XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin, StudentViewUserStateMixin):
""" """
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.
""" """
...@@ -69,35 +64,6 @@ class AnswerMixin(XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin): ...@@ -69,35 +64,6 @@ class AnswerMixin(XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin):
except AttributeError: except AttributeError:
return self.scope_ids.user_id return self.scope_ids.user_id
@staticmethod
def _fetch_model_object(name, student_id, course_id):
answer = Answer.objects.get(
Q(name=name),
Q(student_id=student_id),
Q(course_key=course_id) | Q(course_id=course_id)
)
if not answer.course_key:
answer.course_key = answer.course_id
return answer
@staticmethod
def _create_model_object(name, student_id, course_id):
# Try to store the course_id into the deprecated course_id field if it fits into
# the 50 character limit for compatibility with old code. If it does not fit,
# use a random temporary value until the column gets removed in next release.
# This should not create any issues with old code running alongside the new code,
# since Answer blocks don't work with old code when course_id is longer than 50 chars anyway.
if len(course_id) > 50:
# The deprecated course_id field cannot be blank. It also needs to be unique together with
# the name and student_id fields, so we cannot use a static placeholder value, we generate
# a random value instead, to make the database happy.
deprecated_course_id = get_random_string(24)
else:
deprecated_course_id = course_id
answer = Answer(student_id=student_id, name=name, course_key=course_id, course_id=deprecated_course_id)
answer.save()
return answer
def get_model_object(self, name=None): def get_model_object(self, name=None):
""" """
Fetches the Answer model object for the answer named `name` Fetches the Answer model object for the answer named `name`
...@@ -110,18 +76,13 @@ class AnswerMixin(XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin): ...@@ -110,18 +76,13 @@ class AnswerMixin(XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin):
raise ValueError('AnswerBlock.name field need to be set to a non-null/empty value') raise ValueError('AnswerBlock.name field need to be set to a non-null/empty value')
student_id = self._get_student_id() student_id = self._get_student_id()
course_id = self._get_course_id() course_key = self._get_course_id()
try: answer_data, _ = Answer.objects.get_or_create(
answer_data = self._fetch_model_object(name, student_id, course_id) student_id=student_id,
except Answer.DoesNotExist: course_key=course_key,
try: name=name,
# Answer object does not exist, try to create it. )
answer_data = self._create_model_object(name, student_id, course_id)
except (IntegrityError, ValidationError):
# Integrity/validation error means the object must have been created in the meantime,
# so fetch the new object from the db.
answer_data = self._fetch_model_object(name, student_id, course_id)
return answer_data return answer_data
...@@ -154,6 +115,20 @@ class AnswerMixin(XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin): ...@@ -154,6 +115,20 @@ class AnswerMixin(XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin):
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 build_user_state_data(self, context=None):
"""
Returns a JSON representation of the student data of this XBlock,
retrievable from the Course Block API.
"""
result = super(AnswerMixin, self).build_user_state_data(context)
answer_data = self.get_model_object()
result["answer_data"] = {
"student_input": answer_data.student_input,
"created_on": answer_data.created_on,
"modified_on": answer_data.modified_on,
}
return result
@XBlock.needs("i18n") @XBlock.needs("i18n")
class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, QuestionMixin, StudioEditableXBlockMixin, XBlock): class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, QuestionMixin, StudioEditableXBlockMixin, XBlock):
...@@ -248,7 +223,7 @@ class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, QuestionMixin, StudioEdita ...@@ -248,7 +223,7 @@ class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, QuestionMixin, StudioEdita
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
block. Update accordingly. block. Update accordingly.
""" """
self.student_input = submission[0]['value'].strip() self.student_input = submission['value'].strip()
self.save() self.save()
if sub_api: if sub_api:
...@@ -300,12 +275,18 @@ class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, QuestionMixin, StudioEdita ...@@ -300,12 +275,18 @@ class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, QuestionMixin, StudioEdita
return {'data': {'name': uuid.uuid4().hex[:7]}} return {'data': {'name': uuid.uuid4().hex[:7]}}
return {'metadata': {}, 'data': {}} return {'metadata': {}, 'data': {}}
def student_view_data(self): def student_view_data(self, context=None):
""" """
Returns a JSON representation of the student_view of this XBlock, Returns a JSON representation of the student_view of this XBlock,
retrievable from the Course Block API. retrievable from the Course Block API.
""" """
return {'question': self.question} return {
'id': self.name,
'type': self.CATEGORY,
'weight': self.weight,
'question': self.question,
'name': self.name, # For backwards compatibility; same as 'id'
}
@XBlock.needs("i18n") @XBlock.needs("i18n")
......
...@@ -29,7 +29,7 @@ from xblock.fragment import Fragment ...@@ -29,7 +29,7 @@ from xblock.fragment import Fragment
from xblock.validation import ValidationMessage from xblock.validation import ValidationMessage
from xblockutils.studio_editable import StudioEditableXBlockMixin, XBlockWithPreviewMixin from xblockutils.studio_editable import StudioEditableXBlockMixin, XBlockWithPreviewMixin
from problem_builder.mixins import XBlockWithTranslationServiceMixin from problem_builder.mixins import XBlockWithTranslationServiceMixin, StudentViewUserStateMixin
# Make '_' a no-op so we can scrape strings # Make '_' a no-op so we can scrape strings
...@@ -40,7 +40,10 @@ def _(text): ...@@ -40,7 +40,10 @@ def _(text):
@XBlock.needs("i18n") @XBlock.needs("i18n")
class ChoiceBlock(StudioEditableXBlockMixin, XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin, XBlock): class ChoiceBlock(
StudioEditableXBlockMixin, XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin, StudentViewUserStateMixin,
XBlock
):
""" """
Custom choice of an answer for a MCQ/MRQ Custom choice of an answer for a MCQ/MRQ
""" """
...@@ -66,6 +69,16 @@ class ChoiceBlock(StudioEditableXBlockMixin, XBlockWithPreviewMixin, XBlockWithT ...@@ -66,6 +69,16 @@ class ChoiceBlock(StudioEditableXBlockMixin, XBlockWithPreviewMixin, XBlockWithT
status = self._(u"Out of Context") # Parent block should implement describe_choice_correctness() status = self._(u"Out of Context") # Parent block should implement describe_choice_correctness()
return self._(u"Choice ({status})").format(status=status) return self._(u"Choice ({status})").format(status=status)
def student_view_data(self, context=None):
"""
Returns a JSON representation of the student_view of this XBlock,
retrievable from the Course Block API.
"""
return {
'value': self.value,
'content': self.content,
}
def mentoring_view(self, context=None): def mentoring_view(self, context=None):
""" Render this choice string within a mentoring block question. """ """ Render this choice string within a mentoring block question. """
return Fragment(u'<span class="choice-text">{}</span>'.format(self.content)) return Fragment(u'<span class="choice-text">{}</span>'.format(self.content))
......
...@@ -28,7 +28,7 @@ from xblock.fragment import Fragment ...@@ -28,7 +28,7 @@ from xblock.fragment import Fragment
from xblockutils.studio_editable import StudioEditableXBlockMixin from xblockutils.studio_editable import StudioEditableXBlockMixin
from xblockutils.resources import ResourceLoader from xblockutils.resources import ResourceLoader
from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin, StudentViewUserStateMixin
from .sub_api import sub_api, SubmittingXBlockMixin from .sub_api import sub_api, SubmittingXBlockMixin
...@@ -47,7 +47,8 @@ def _(text): ...@@ -47,7 +47,8 @@ def _(text):
@XBlock.needs('i18n') @XBlock.needs('i18n')
class CompletionBlock( class CompletionBlock(
SubmittingXBlockMixin, QuestionMixin, StudioEditableXBlockMixin, XBlockWithTranslationServiceMixin, XBlock SubmittingXBlockMixin, QuestionMixin, StudioEditableXBlockMixin, XBlockWithTranslationServiceMixin,
StudentViewUserStateMixin, XBlock
): ):
""" """
An XBlock used by students to indicate that they completed a given task. An XBlock used by students to indicate that they completed a given task.
...@@ -105,6 +106,20 @@ class CompletionBlock( ...@@ -105,6 +106,20 @@ class CompletionBlock(
student_view = mentoring_view student_view = mentoring_view
preview_view = mentoring_view preview_view = mentoring_view
def student_view_data(self, context=None):
"""
Returns a JSON representation of the student_view of this XBlock,
retrievable from the Course XBlock API.
"""
return {
'id': self.name,
'type': self.CATEGORY,
'question': self.question,
'answer': self.answer,
'title': self.display_name_with_default,
'hide_header': not self.show_title,
}
def get_last_result(self): def get_last_result(self):
""" Return the current/last result in the required format """ """ Return the current/last result in the required format """
if self.student_value is None: if self.student_value is None:
......
...@@ -382,6 +382,7 @@ class DashboardBlock(StudioEditableXBlockMixin, ExportMixin, XBlock): ...@@ -382,6 +382,7 @@ class DashboardBlock(StudioEditableXBlockMixin, ExportMixin, XBlock):
if child_isinstance(mentoring_block, child_id, MCQBlock): if child_isinstance(mentoring_block, child_id, MCQBlock):
yield child_id yield child_id
@XBlock.supports("multi_device") # Mark as mobile-friendly
def student_view(self, context=None): # pylint: disable=unused-argument def student_view(self, context=None): # pylint: disable=unused-argument
""" """
Standard view of this XBlock. Standard view of this XBlock.
......
import time
from django.core.management.base import BaseCommand
from problem_builder.models import Answer
class Command(BaseCommand):
"""
Copy content of the deprecated Answer.course_id column into Answer.course_key in batches.
The batch size and sleep time between batches are configurable.
"""
help = 'Copy content of the deprecated Answer.course_id column into Answer.course_key in batches'
def add_arguments(self, parser):
parser.add_argument(
'--batch-size',
help='The size of each batch of records to copy (default: 5000).',
type=int,
default=5000,
)
parser.add_argument(
'--sleep',
help='Number of seconds to sleep before processing the next batch (default: 5).',
type=int,
default=5
)
def handle(self, *args, **options):
batch_size = options['batch_size']
sleep_time = options['sleep']
queryset = Answer.objects.filter(course_key__isnull=True)
self.stdout.write(
"Copying Answer.course_id field into Answer.course_key in batches of {}".format(batch_size)
)
idx = 0
while True:
idx += 1
batch = queryset[:batch_size]
if not batch:
break
for answer in batch:
answer.course_key = answer.course_id
answer.save()
self.stdout.write("Processed batch {}".format(idx))
time.sleep(sleep_time)
self.stdout.write("Successfully copied Answer.course_id into Answer.course_key")
...@@ -27,6 +27,7 @@ from xblock.fragment import Fragment ...@@ -27,6 +27,7 @@ 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 problem_builder.mixins import StudentViewUserStateMixin
from .questionnaire import QuestionnaireAbstractBlock from .questionnaire import QuestionnaireAbstractBlock
from .sub_api import sub_api, SubmittingXBlockMixin from .sub_api import sub_api, SubmittingXBlockMixin
...@@ -44,7 +45,7 @@ def _(text): ...@@ -44,7 +45,7 @@ def _(text):
# Classes ########################################################### # Classes ###########################################################
class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock): class MCQBlock(SubmittingXBlockMixin, StudentViewUserStateMixin, QuestionnaireAbstractBlock):
""" """
An XBlock used to ask multiple-choice questions An XBlock used to ask multiple-choice questions
""" """
...@@ -124,8 +125,8 @@ class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock): ...@@ -124,8 +125,8 @@ class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock):
def submit(self, submission): def submit(self, submission):
log.debug(u'Received MCQ submission: "%s"', submission) log.debug(u'Received MCQ submission: "%s"', submission)
result = self.calculate_results(submission) result = self.calculate_results(submission['value'])
self.student_choice = submission self.student_choice = submission['value']
log.debug(u'MCQ submission result: %s', result) log.debug(u'MCQ submission result: %s', result)
return result return result
...@@ -167,6 +168,27 @@ class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock): ...@@ -167,6 +168,27 @@ class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock):
self._(u"A choice value listed as correct does not exist: {choice}").format(choice=choice_name(val)) self._(u"A choice value listed as correct does not exist: {choice}").format(choice=choice_name(val))
) )
def student_view_data(self, context=None):
"""
Returns a JSON representation of the student_view of this XBlock,
retrievable from the Course Block API.
"""
return {
'id': self.name,
'type': self.CATEGORY,
'question': self.question,
'message': self.message,
'choices': [
{'value': choice['value'], 'content': choice['display_name']}
for choice in self.human_readable_choices
],
'weight': self.weight,
'tips': [
{'content': tip.content, 'for_choices': tip.values}
for tip in self.get_tips()
],
}
class RatingBlock(MCQBlock): class RatingBlock(MCQBlock):
""" """
......
...@@ -36,8 +36,8 @@ from xblock.validation import ValidationMessage ...@@ -36,8 +36,8 @@ from xblock.validation import ValidationMessage
from .message import MentoringMessageBlock, get_message_label from .message import MentoringMessageBlock, get_message_label
from .mixins import ( from .mixins import (
_normalize_id, QuestionMixin, MessageParentMixin, StepParentMixin, XBlockWithTranslationServiceMixin _normalize_id, QuestionMixin, MessageParentMixin, StepParentMixin, XBlockWithTranslationServiceMixin,
) StudentViewUserStateMixin)
from .step_review import ReviewStepBlock from .step_review import ReviewStepBlock
from xblockutils.helpers import child_isinstance from xblockutils.helpers import child_isinstance
...@@ -91,6 +91,7 @@ PARTIAL = 'partial' ...@@ -91,6 +91,7 @@ PARTIAL = 'partial'
class BaseMentoringBlock( class BaseMentoringBlock(
XBlock, XBlockWithTranslationServiceMixin, XBlockWithSettingsMixin, XBlock, XBlockWithTranslationServiceMixin, XBlockWithSettingsMixin,
StudioEditableXBlockMixin, ThemableXBlockMixin, MessageParentMixin, StudioEditableXBlockMixin, ThemableXBlockMixin, MessageParentMixin,
StudentViewUserStateMixin,
): ):
""" """
An XBlock that defines functionality shared by mentoring blocks. An XBlock that defines functionality shared by mentoring blocks.
...@@ -439,6 +440,7 @@ class MentoringBlock(BaseMentoringBlock, StudioContainerWithNestedXBlocksMixin, ...@@ -439,6 +440,7 @@ class MentoringBlock(BaseMentoringBlock, StudioContainerWithNestedXBlocksMixin,
return Score(score, int(round(score * 100)), correct, incorrect, partially_correct) return Score(score, int(round(score * 100)), correct, incorrect, partially_correct)
@XBlock.supports("multi_device") # Mark as mobile-friendly
def student_view(self, context): def student_view(self, context):
from .questionnaire import QuestionnaireAbstractBlock # Import here to avoid circular dependency from .questionnaire import QuestionnaireAbstractBlock # Import here to avoid circular dependency
...@@ -905,6 +907,31 @@ class MentoringBlock(BaseMentoringBlock, StudioContainerWithNestedXBlocksMixin, ...@@ -905,6 +907,31 @@ class MentoringBlock(BaseMentoringBlock, StudioContainerWithNestedXBlocksMixin,
""" """
return loader.load_scenarios_from_path('templates/xml') return loader.load_scenarios_from_path('templates/xml')
def student_view_data(self, context=None):
"""
Returns a JSON representation of the student_view of this XBlock,
retrievable from the Course Block API.
"""
components = []
for child_id in self.children:
block = self.runtime.get_block(child_id)
if hasattr(block, 'student_view_data'):
components.append(block.student_view_data())
return {
'max_attempts': self.max_attempts,
'extended_feedback': self.extended_feedback,
'feedback_label': self.feedback_label,
'components': components,
'messages': {
message_type: self.get_message_content(message_type)
for message_type in (
'completed',
'incomplete',
'max_attempts_reached',
)
}
}
class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNestedXBlocksMixin): class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNestedXBlocksMixin):
""" """
...@@ -1068,6 +1095,7 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes ...@@ -1068,6 +1095,7 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes
def show_extended_feedback(self): def show_extended_feedback(self):
return self.extended_feedback and self.max_attempts_reached return self.extended_feedback and self.max_attempts_reached
@XBlock.supports("multi_device") # Mark as mobile-friendly
def student_view(self, context): def student_view(self, context):
fragment = Fragment() fragment = Fragment()
children_contents = [] children_contents = []
...@@ -1220,3 +1248,20 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes ...@@ -1220,3 +1248,20 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/container_edit.js')) fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/container_edit.js'))
fragment.initialize_js('ProblemBuilderContainerEdit') fragment.initialize_js('ProblemBuilderContainerEdit')
return fragment return fragment
def student_view_data(self, context=None):
components = []
for child_id in self.children:
child = self.runtime.get_block(child_id)
if hasattr(child, 'student_view_data'):
components.append(child.student_view_data(context))
return {
'title': self.display_name,
'show_title': self.show_title,
'weight': self.weight,
'extended_feedback': self.extended_feedback,
'max_attempts': self.max_attempts,
'components': components,
}
...@@ -21,5 +21,5 @@ class Migration(migrations.Migration): ...@@ -21,5 +21,5 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.RunPython(code=migrations.RunPython.noop, reverse_code=migrations.RunPython.noop), migrations.RunPython(code=copy_course_id_to_course_key, reverse_code=migrations.RunPython.noop),
] ]
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('problem_builder', '0004_copy_course_ids'),
]
operations = [
migrations.AlterField(
model_name='answer',
name='course_id',
field=models.CharField(default=None, max_length=50, null=True, db_index=True, blank=True),
),
migrations.AlterField(
model_name='answer',
name='course_key',
field=models.CharField(max_length=255, db_index=True),
),
]
import json
import webob
from lazy import lazy from lazy import lazy
from problem_builder.tests.unit.utils import DateTimeEncoder
from xblock.core import XBlock
from xblock.fields import String, Boolean, Float, Scope, UNIQUE_ID from xblock.fields import String, Boolean, Float, Scope, UNIQUE_ID
from xblock.fragment import Fragment from xblock.fragment import Fragment
from xblockutils.helpers import child_isinstance from xblockutils.helpers import child_isinstance
...@@ -176,3 +181,43 @@ class NoSettingsMixin(object): ...@@ -176,3 +181,43 @@ class NoSettingsMixin(object):
def studio_view(self, _context=None): def studio_view(self, _context=None):
""" Studio View """ """ Studio View """
return Fragment(u'<p>{}</p>'.format(self._("This XBlock does not have any settings."))) return Fragment(u'<p>{}</p>'.format(self._("This XBlock does not have any settings.")))
class StudentViewUserStateMixin(object):
"""
Mixin to provide student_view_user_state view
"""
NESTED_BLOCKS_KEY = "components"
INCLUDE_SCOPES = (Scope.user_state, Scope.user_info, Scope.preferences)
def build_user_state_data(self, context=None):
"""
Returns a JSON representation of the student data of this XBlock,
retrievable from the Course Block API.
"""
result = {}
for _, field in self.fields.iteritems():
if field.scope in self.INCLUDE_SCOPES:
result[field.name] = field.read_from(self)
if getattr(self, "has_children", False):
components = {}
for child_id in self.children:
child = self.runtime.get_block(child_id)
if hasattr(child, 'build_user_state_data'):
components[str(child_id)] = child.build_user_state_data(context)
result[self.NESTED_BLOCKS_KEY] = components
return result
@XBlock.handler
def student_view_user_state(self, context=None, suffix=''):
"""
Returns a JSON representation of the student data of this XBlock,
retrievable from the Course Block API.
"""
result = self.build_user_state_data(context)
json_result = json.dumps(result, cls=DateTimeEncoder)
return webob.response.Response(body=json_result, content_type='application/json')
...@@ -35,6 +35,9 @@ class Answer(models.Model): ...@@ -35,6 +35,9 @@ class Answer(models.Model):
""" """
class Meta: class Meta:
# Since problem_builder isn't added to INSTALLED_APPS until it's imported,
# specify the app_label here.
app_label = 'problem_builder'
unique_together = ( unique_together = (
('student_id', 'course_id', 'name'), ('student_id', 'course_id', 'name'),
('student_id', 'course_key', 'name'), ('student_id', 'course_key', 'name'),
...@@ -43,11 +46,9 @@ class Answer(models.Model): ...@@ -43,11 +46,9 @@ class Answer(models.Model):
name = models.CharField(max_length=50, db_index=True) name = models.CharField(max_length=50, db_index=True)
student_id = models.CharField(max_length=32, db_index=True) student_id = models.CharField(max_length=32, db_index=True)
# course_id is deprecated; it will be removed in next release. # course_id is deprecated; it will be removed in next release.
course_id = models.CharField(max_length=50, db_index=True) course_id = models.CharField(max_length=50, db_index=True, blank=True, null=True, default=None)
# course_key is the new course_id replacement with extended max_length. # course_key is the new course_id replacement with extended max_length.
# We need to allow NULL values during the transition period, course_key = models.CharField(max_length=255, db_index=True)
# but we will remove the null=True and default=None parameters in next release.
course_key = models.CharField(max_length=255, db_index=True, null=True, default=None)
student_input = models.TextField(blank=True, default='') student_input = models.TextField(blank=True, default='')
created_on = models.DateTimeField('created on', auto_now_add=True) created_on = models.DateTimeField('created on', auto_now_add=True)
modified_on = models.DateTimeField('modified on', auto_now=True) modified_on = models.DateTimeField('modified on', auto_now=True)
...@@ -71,4 +72,7 @@ class Share(models.Model): ...@@ -71,4 +72,7 @@ class Share(models.Model):
notified = models.BooleanField(default=False, db_index=True) notified = models.BooleanField(default=False, db_index=True)
class Meta(object): class Meta(object):
# Since problem_builder isn't added to INSTALLED_APPS until it's imported,
# specify the app_label here.
app_label = 'problem_builder'
unique_together = (('shared_by', 'shared_with', 'block_id'),) unique_together = (('shared_by', 'shared_with', 'block_id'),)
...@@ -24,6 +24,8 @@ import logging ...@@ -24,6 +24,8 @@ import logging
from xblock.fields import List, Scope, Boolean, String from xblock.fields import List, Scope, Boolean, String
from xblock.validation import ValidationMessage from xblock.validation import ValidationMessage
from problem_builder.mixins import StudentViewUserStateMixin
from .questionnaire import QuestionnaireAbstractBlock from .questionnaire import QuestionnaireAbstractBlock
from xblockutils.resources import ResourceLoader from xblockutils.resources import ResourceLoader
...@@ -40,7 +42,7 @@ def _(text): ...@@ -40,7 +42,7 @@ def _(text):
# Classes ########################################################### # Classes ###########################################################
class MRQBlock(QuestionnaireAbstractBlock): class MRQBlock(StudentViewUserStateMixin, QuestionnaireAbstractBlock):
""" """
An XBlock used to ask multiple-response questions An XBlock used to ask multiple-response questions
""" """
...@@ -188,3 +190,26 @@ class MRQBlock(QuestionnaireAbstractBlock): ...@@ -188,3 +190,26 @@ class MRQBlock(QuestionnaireAbstractBlock):
add_error(self._(u"A choice value listed as required does not exist: {}").format(choice_name(val))) add_error(self._(u"A choice value listed as required does not exist: {}").format(choice_name(val)))
for val in (ignored - all_values): for val in (ignored - all_values):
add_error(self._(u"A choice value listed as ignored does not exist: {}").format(choice_name(val))) add_error(self._(u"A choice value listed as ignored does not exist: {}").format(choice_name(val)))
def student_view_data(self, context=None):
"""
Returns a JSON representation of the student_view of this XBlock,
retrievable from the Course Block API.
"""
return {
'id': self.name,
'title': self.display_name,
'type': self.CATEGORY,
'weight': self.weight,
'question': self.question,
'message': self.message,
'choices': [
{'value': choice['value'], 'content': choice['display_name']}
for choice in self.human_readable_choices
],
'hide_results': self.hide_results,
'tips': [
{'content': tip.content, 'for_choices': tip.values}
for tip in self.get_tips()
],
}
...@@ -346,6 +346,26 @@ class PlotBlock(StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin ...@@ -346,6 +346,26 @@ class PlotBlock(StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin
fragment.initialize_js('PlotBlock') fragment.initialize_js('PlotBlock')
return fragment return fragment
def student_view_data(self, context=None):
"""
Returns a JSON representation of the student_view of this XBlock,
retrievable from the Course XBlock API.
"""
return {
'type': self.CATEGORY,
'title': self.display_name,
'q1_label': self.q1_label,
'q2_label': self.q2_label,
'q3_label': self.q3_label,
'q4_label': self.q4_label,
'point_color_default': self.point_color_default,
'plot_label': self.plot_label,
'point_color_average': self.point_color_average,
'overlay_data': self.overlay_data,
'hide_header': True,
'claims': self.claims,
}
def author_edit_view(self, context): def author_edit_view(self, context):
""" """
Add some HTML to the author view that allows authors to add child blocks. Add some HTML to the author view that allows authors to add child blocks.
......
...@@ -41,7 +41,7 @@ ...@@ -41,7 +41,7 @@
margin-top: 10px; margin-top: 10px;
} }
.xblock .mentoring h3 { .xblock .mentoring h4 {
margin-top: 0px; margin-top: 0px;
margin-bottom: 7px; margin-bottom: 7px;
} }
......
...@@ -2,19 +2,23 @@ ...@@ -2,19 +2,23 @@
display: table; display: table;
position: relative; position: relative;
width: 100%; width: 100%;
border-spacing: 0 6px; border-spacing: 0 2px;
padding-top: 10px; padding-top: 10px;
margin-bottom: 10px; margin-bottom: 10px;
} }
.mentoring .questionnaire .choice-result { .mentoring .questionnaire .choice-result {
display: inline-block; display: table-cell;
width: 34px; width: 34px;
vertical-align: top; vertical-align: top;
cursor: pointer; cursor: pointer;
float: none; float: none;
} }
.mentoring div[data-block-type=pb-mrq] .questionnaire .choice-result {
display: inline-block;
}
.mentoring .questionnaire .choice { .mentoring .questionnaire .choice {
overflow-y: hidden; overflow-y: hidden;
display: table-row; display: table-row;
...@@ -78,7 +82,13 @@ ...@@ -78,7 +82,13 @@
} }
.mentoring .choices-list .choice-selector { .mentoring .choices-list .choice-selector {
display: inline-block; display: table-cell;
}
.mentoring .choices-list .choice-label-text {
display: table-cell;
padding-left: 0.4em;
padding-right: 0.4em;
} }
.mentoring .choice-tips-container, .mentoring .choice-tips-container,
......
...@@ -15,7 +15,13 @@ function AnswerBlock(runtime, element) { ...@@ -15,7 +15,13 @@ function AnswerBlock(runtime, element) {
}, },
submit: function() { submit: function() {
return $(':input', element).serializeArray(); var freeform_answer = $(':input', element);
if(freeform_answer.length) {
return {"value": freeform_answer.val()};
} else {
return null;
}
}, },
handleReview: function(result) { handleReview: function(result) {
......
...@@ -16,10 +16,12 @@ function CompletionBlock(runtime, element) { ...@@ -16,10 +16,12 @@ function CompletionBlock(runtime, element) {
return $completion.is(':checked'); return $completion.is(':checked');
}, },
handleSubmit: function(result) { handleSubmit: function(result, options) {
if (typeof result.submission !== 'undefined') { if (typeof result.submission !== 'undefined') {
this.updateCompletion(result); this.updateCompletion(result);
$('.submit-result', element).css('visibility', 'visible'); if (!options.hide_results) {
$('.submit-result', element).css('visibility', 'visible');
}
} }
}, },
......
...@@ -107,7 +107,7 @@ function MCQBlock(runtime, element) { ...@@ -107,7 +107,7 @@ function MCQBlock(runtime, element) {
var checkedRadio = $('input[type=radio]:checked', element); var checkedRadio = $('input[type=radio]:checked', element);
if(checkedRadio.length) { if(checkedRadio.length) {
return checkedRadio.val(); return {"value": checkedRadio.val()};
} else { } else {
return null; return null;
} }
...@@ -221,7 +221,9 @@ function MRQBlock(runtime, element) { ...@@ -221,7 +221,9 @@ function MRQBlock(runtime, element) {
display_message(result.message, messageView, options.checkmark); display_message(result.message, messageView, options.checkmark);
} }
$.each(result.choices, function(index, choice) { // If user has never submitted an answer for this MRQ, `result` will be empty.
// So we need to fall back on an empty list for `result.choices` to avoid errors in the next step:
$.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);
......
...@@ -3,17 +3,21 @@ ...@@ -3,17 +3,21 @@
box-sizing: content-box; /* Avoid a global reset to border-box found on Apros */ box-sizing: content-box; /* Avoid a global reset to border-box found on Apros */
} }
.mentoring .questionnaire .choice-label {
width: 100%
}
.themed-xblock.mentoring .choices-list .choice-selector { .themed-xblock.mentoring .choices-list .choice-selector {
padding: 4px 3px 0 3px; padding: 4px 3px 0 3px;
font-size: 16px; font-size: 16px;
} }
.mentoring .title h2 { .mentoring .title h3 {
/* Same as h2.main in Apros */ /* Same as h2.main in Apros, amended to h3 */
color: #66a5b5; color: #66a5b5;
} }
.mentoring h3 { .mentoring h4 {
text-transform: uppercase; text-transform: uppercase;
} }
......
...@@ -235,10 +235,3 @@ class QuestionnaireAbstractBlock( ...@@ -235,10 +235,3 @@ class QuestionnaireAbstractBlock(
format_html = getattr(self.runtime, 'replace_urls', lambda html: html) format_html = getattr(self.runtime, 'replace_urls', lambda html: html)
return format_html(self.message) return format_html(self.message)
return "" return ""
def student_view_data(self):
"""
Returns a JSON representation of the student_view of this XBlock,
retrievable from the Course Block API.
"""
return {'question': self.question}
...@@ -29,7 +29,7 @@ from xblock.fragment import Fragment ...@@ -29,7 +29,7 @@ from xblock.fragment import Fragment
from xblockutils.studio_editable import StudioEditableXBlockMixin from xblockutils.studio_editable import StudioEditableXBlockMixin
from xblockutils.resources import ResourceLoader from xblockutils.resources import ResourceLoader
from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin, StudentViewUserStateMixin
from .sub_api import sub_api, SubmittingXBlockMixin from .sub_api import sub_api, SubmittingXBlockMixin
...@@ -48,7 +48,8 @@ def _(text): ...@@ -48,7 +48,8 @@ def _(text):
@XBlock.needs("i18n") @XBlock.needs("i18n")
class SliderBlock( class SliderBlock(
SubmittingXBlockMixin, QuestionMixin, StudioEditableXBlockMixin, XBlockWithTranslationServiceMixin, XBlock, SubmittingXBlockMixin, QuestionMixin, StudioEditableXBlockMixin, XBlockWithTranslationServiceMixin,
StudentViewUserStateMixin, XBlock,
): ):
""" """
An XBlock used by students to indicate a numeric value on a sliding scale. An XBlock used by students to indicate a numeric value on a sliding scale.
...@@ -121,6 +122,17 @@ class SliderBlock( ...@@ -121,6 +122,17 @@ class SliderBlock(
student_view = mentoring_view student_view = mentoring_view
preview_view = mentoring_view preview_view = mentoring_view
def student_view_data(self, context=None):
return {
'id': self.name,
'type': self.CATEGORY,
'question': self.question,
'min_label': self.min_label,
'max_label': self.max_label,
'title': self.display_name_with_default,
'hide_header': not self.show_title,
}
def author_view(self, context): def author_view(self, context):
""" """
Add some HTML to the author view that allows authors to see the ID of the block, so they Add some HTML to the author view that allows authors to see the ID of the block, so they
......
...@@ -33,7 +33,7 @@ from xblockutils.studio_editable import ( ...@@ -33,7 +33,7 @@ from xblockutils.studio_editable import (
from problem_builder.answer import AnswerBlock, AnswerRecapBlock from problem_builder.answer import AnswerBlock, AnswerRecapBlock
from problem_builder.completion import CompletionBlock from problem_builder.completion import CompletionBlock
from problem_builder.mcq import MCQBlock, RatingBlock from problem_builder.mcq import MCQBlock, RatingBlock
from problem_builder.mixins import EnumerableChildMixin, StepParentMixin from problem_builder.mixins import EnumerableChildMixin, StepParentMixin, StudentViewUserStateMixin
from problem_builder.mrq import MRQBlock from problem_builder.mrq import MRQBlock
from problem_builder.plot import PlotBlock from problem_builder.plot import PlotBlock
from problem_builder.slider import SliderBlock from problem_builder.slider import SliderBlock
...@@ -70,7 +70,7 @@ class Correctness(object): ...@@ -70,7 +70,7 @@ class Correctness(object):
@XBlock.needs('i18n') @XBlock.needs('i18n')
class MentoringStepBlock( class MentoringStepBlock(
StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin, XBlockWithPreviewMixin, StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin, XBlockWithPreviewMixin,
EnumerableChildMixin, StepParentMixin, XBlock EnumerableChildMixin, StepParentMixin, StudentViewUserStateMixin, XBlock
): ):
""" """
An XBlock for a step. An XBlock for a step.
...@@ -265,3 +265,24 @@ class MentoringStepBlock( ...@@ -265,3 +265,24 @@ class MentoringStepBlock(
fragment.initialize_js('MentoringStepBlock') fragment.initialize_js('MentoringStepBlock')
return fragment return fragment
def student_view_data(self, context=None):
"""
Returns a JSON representation of the student_view of this XBlock,
retrievable from the Course XBlock API.
"""
components = []
for child_id in self.children:
child = self.runtime.get_block(child_id)
if hasattr(child, 'student_view_data'):
components.append(child.student_view_data(context))
return {
'type': self.CATEGORY,
'title': self.display_name_with_default,
'show_title': self.show_title,
'next_button_label': self.next_button_label,
'message': self.message,
'components': components,
}
...@@ -109,6 +109,14 @@ class ConditionalMessageBlock( ...@@ -109,6 +109,14 @@ class ConditionalMessageBlock(
return True return True
def student_view_data(self, context=None):
return {
'type': self.CATEGORY,
'content': self.content,
'score_condition': self.score_condition,
'num_attempts_condition': self.num_attempts_condition,
}
def student_view(self, _context=None): def student_view(self, _context=None):
""" Render this message. """ """ Render this message. """
html = u'<div class="review-conditional-message">{content}</div>'.format( html = u'<div class="review-conditional-message">{content}</div>'.format(
...@@ -151,6 +159,13 @@ class ScoreSummaryBlock(XBlockWithTranslationServiceMixin, XBlockWithPreviewMixi ...@@ -151,6 +159,13 @@ class ScoreSummaryBlock(XBlockWithTranslationServiceMixin, XBlockWithPreviewMixi
html = loader.render_template("templates/html/sb-review-score.html", context.get("score_summary", {})) html = loader.render_template("templates/html/sb-review-score.html", context.get("score_summary", {}))
return Fragment(html) return Fragment(html)
def student_view_data(self, context=None):
context = context or {}
return {
'type': self.CATEGORY,
}
embedded_student_view = student_view embedded_student_view = student_view
def author_view(self, context=None): def author_view(self, context=None):
...@@ -199,6 +214,11 @@ class PerQuestionFeedbackBlock(XBlockWithTranslationServiceMixin, XBlockWithPrev ...@@ -199,6 +214,11 @@ class PerQuestionFeedbackBlock(XBlockWithTranslationServiceMixin, XBlockWithPrev
html = u"" html = u""
return Fragment(html) return Fragment(html)
def student_view_data(self, context=None):
return {
'type': self.CATEGORY,
}
embedded_student_view = student_view embedded_student_view = student_view
def author_view(self, context=None): def author_view(self, context=None):
...@@ -279,6 +299,24 @@ class ReviewStepBlock( ...@@ -279,6 +299,24 @@ class ReviewStepBlock(
return fragment return fragment
def student_view_data(self, context=None):
context = context.copy() if context else {}
components = []
for child_id in self.children:
child = self.runtime.get_block(child_id)
if hasattr(child, 'student_view_data'):
if hasattr(context, 'score_summary') and hasattr(child, 'is_applicable'):
if not child.is_applicable(context):
continue
components.append(child.student_view_data(context))
return {
'type': self.CATEGORY,
'title': self.display_name,
'components': components,
}
mentoring_view = student_view mentoring_view = student_view
def author_edit_view(self, context): def author_edit_view(self, context):
......
{% load i18n %} {% 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 %}<h4 class="question-title">{{ self.display_name_with_default }}</h4>{% endif %}
<label><p>{{ self.question|safe }}</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"
......
{% load i18n %} {% load i18n %}
<div class="xblock-answer"> <div class="xblock-answer">
{% if not hide_header %}<h3 class="question-title">{{ title }}</h3>{% endif %} {% if not hide_header %}<h4 class="question-title">{{ title }}</h4>{% 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">
{% if student_input %} {% if student_input %}
......
{% load i18n %} {% load i18n %}
<div class="xblock-pb-completion"> <div class="xblock-pb-completion">
{% if not hide_header %}<h3 class="question-title">{{ title }}</h3>{% endif %} {% if not hide_header %}<h4 class="question-title">{{ title }}</h4>{% endif %}
<div class="clearfix"> <div class="clearfix">
<p>{{ question|safe }}</p> <p>{{ question|safe }}</p>
<p> <p>
......
{% load i18n %} {% load i18n %}
<div class="pb-dashboard"> <div class="pb-dashboard">
<div class="dashboard-report"> <div class="dashboard-report">
<h2>{{display_name}}</h2> <h3>{{display_name}}</h3>
{% if header_html %} {% if header_html %}
<div class="report-header"> <div class="report-header">
......
{% load i18n %} {% load i18n %}
<h2>{% trans "Instructor Tool" %}</h3> <h4>{% trans "Instructor Tool" %}</h4>
<div class="data-export-options"> <div class="data-export-options">
<div class="data-export-header"> <div class="data-export-header">
<h3>{% trans "Filters" %}</h3> <h4>{% trans "Filters" %}</h4>
</div> </div>
<div class="data-export-row"> <div class="data-export-row">
<div class="data-export-field-container"> <div class="data-export-field-container">
......
{% load i18n %} {% load i18n %}
{% if not hide_header %} {% if not hide_header %}
<h3 class="question-title" id="heading_{{ self.html_id }}">{{ self.display_name_with_default }}</h3> <h4 class="question-title" id="heading_{{ self.html_id }}">{{ self.display_name_with_default }}</h4>
{% endif %} {% endif %}
<fieldset class="choices questionnaire" id="{{ self.html_id }}"> <fieldset class="choices questionnaire" id="{{ self.html_id }}">
<legend class="question field-group-hd">{{ self.question|safe }}</legend> <legend class="question field-group-hd">{{ self.question|safe }}</legend>
...@@ -8,15 +8,17 @@ ...@@ -8,15 +8,17 @@
{% for choice in custom_choices %} {% for choice in custom_choices %}
<div class="choice" aria-live="polite" aria-atomic="true"> <div class="choice" aria-live="polite" aria-atomic="true">
<label class="choice-label" <label class="choice-label"
aria-describedby="feedback_{{ self.html_id }} choice_tips_{{ self.html_id }}-{{ forloop.counter }}"> aria-describedby="feedback_{{ self.html_id }} choice_tips_{{ self.html_id }}-{{ forloop.counter }}">
<div class="choice-result fa icon-2x" aria-label="" <span class="choice-result fa icon-2x"
data-label_correct="{% trans "Correct" %}" data-label_incorrect="{% trans "Incorrect" %}"></div> aria-label=""
<div class="choice-selector"> data-label_correct="{% trans "Correct" %}"
data-label_incorrect="{% trans "Incorrect" %}"></span>
<span class="choice-selector">
<input type="radio" name="{{ self.name }}" value="{{ choice.value }}" <input type="radio" name="{{ self.name }}" value="{{ choice.value }}"
{% if self.student_choice == choice.value and not hide_prev_answer %} checked{% endif %} {% if self.student_choice == choice.value and not hide_prev_answer %} checked{% endif %}
/> />
</div> </span>
{{ choice.content|safe }} <span class="choice-label-text">{{ choice.content|safe }}</span>
</label> </label>
<div class="choice-tips-container"> <div class="choice-tips-container">
<div class="choice-tips" id="choice_tips_{{ self.html_id }}-{{ forloop.counter }}"></div> <div class="choice-tips" id="choice_tips_{{ self.html_id }}-{{ forloop.counter }}"></div>
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
{% if show_title and title %} {% if show_title and title %}
<div class="title"> <div class="title">
<h2>{{ title }}</h2> <h3>{{ title }}</h3>
</div> </div>
{% endif %} {% endif %}
......
<script type="text/template" id="xblock-grade-template"> <script type="text/template" id="xblock-grade-template">
<div class="grade-result"> <div class="grade-result">
<h2> <h4>
<%= _.template(gettext("You scored {percent}% on this assessment."), {percent: score}, {interpolate: /\{(.+?)\}/g}) %> <%= _.template(gettext("You scored {percent}% on this assessment."), {percent: score}, {interpolate: /\{(.+?)\}/g}) %>
</h2> </h4>
<hr/> <hr/>
<span class="assessment-checkmark icon-2x checkmark-correct icon-ok fa fa-check"></span> <span class="assessment-checkmark icon-2x checkmark-correct icon-ok fa fa-check"></span>
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
{% if show_title and title %} {% if show_title and title %}
<div class="title"> <div class="title">
<h2>{{ title }}</h2> <h3>{{ title }}</h3>
</div> </div>
{% endif %} {% endif %}
......
{% load i18n %} {% load i18n %}
{% if not hide_header %} {% if not hide_header %}
<h3 class="question-title" id="heading_{{ self.html_id }}">{{ self.display_name_with_default }}</h3> <h4 class="question-title" id="heading_{{ self.html_id }}">{{ self.display_name_with_default }}</h4>
{% endif %} {% endif %}
<fieldset class="choices questionnaire" id="{{ self.html_id }}" <fieldset class="choices questionnaire" id="{{ self.html_id }}"
data-hide_results="{{ self.hide_results }}" data-hide_prev_answer="{{ hide_prev_answer }}"> data-hide_results="{{ self.hide_results }}" data-hide_prev_answer="{{ hide_prev_answer }}">
...@@ -8,17 +8,20 @@ ...@@ -8,17 +8,20 @@
<div class="choices-list"> <div class="choices-list">
{% for choice in custom_choices %} {% for choice in custom_choices %}
<div class="choice" aria-live="polite" aria-atomic="true"> <div class="choice" aria-live="polite" aria-atomic="true">
<div class="choice-result fa icon-2x" id="result_{{ self.html_id }}-{{ forloop.counter }}" aria-label="" <div class="choice-result fa icon-2x"
data-label_correct="{% trans "Correct" %}" data-label_incorrect="{% trans "Incorrect" %}"></div> id="result_{{ self.html_id }}-{{ forloop.counter }}"
aria-label=""
data-label_correct="{% trans "Correct" %}"
data-label_incorrect="{% trans "Incorrect" %}"></div>
<label class="choice-label" aria-describedby="feedback_{{ self.html_id }} <label class="choice-label" aria-describedby="feedback_{{ self.html_id }}
result_{{ self.html_id }}-{{ forloop.counter }} result_{{ self.html_id }}-{{ forloop.counter }}
choice_tips_{{ self.html_id }}-{{ forloop.counter }}"> choice_tips_{{ self.html_id }}-{{ forloop.counter }}">
<div class="choice-selector"> <span class="choice-selector">
<input type="checkbox" name="{{ self.name }}" value="{{ choice.value }}" <input type="checkbox" name="{{ self.name }}" value="{{ choice.value }}"
{% if choice.value in self.student_choices and not hide_prev_answer %} checked{% endif %} {% if choice.value in self.student_choices and not hide_prev_answer %} checked{% endif %}
/> />
</div> </span>
{{ choice.content|safe }} <span class="choice-label-text">{{ choice.content|safe }}</span>
</label> </label>
<div class="choice-tips-container"> <div class="choice-tips-container">
<div class="choice-tips" id="choice_tips_{{ self.html_id }}-{{ forloop.counter }}"></div> <div class="choice-tips" id="choice_tips_{{ self.html_id }}-{{ forloop.counter }}"></div>
......
{% load i18n %} {% load i18n %}
<div class="sb-plot-overlay"> <div class="sb-plot-overlay">
{% if self.plot_label and self.point_color %} {% if self.plot_label and self.point_color %}
<h3 style="color: {{ self.point_color }};">{{ self.plot_label }} {% trans "Overlay" %}</h3> <h4 style="color: {{ self.point_color }};">{{ self.plot_label }} {% trans "Overlay" %}</h4>
{% endif %} {% endif %}
<p> <p>
<strong>{% trans "Description:" %}</strong> <strong>{% trans "Description:" %}</strong>
......
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
</div> </div>
<div class="overlays"> <div class="overlays">
<h3>{% trans "Compare your plot to others!" %}</h3> <h4>{% trans "Compare your plot to others!" %}</h4>
<input type="button" <input type="button"
class="plot-default" class="plot-default"
......
{% load i18n %} {% load i18n %}
{% if not hide_header %} {% if not hide_header %}
<h3 class="question-title" id="heading_{{ self.html_id }}">{{ self.display_name_with_default }}</h3> <h4 class="question-title" id="heading_{{ self.html_id }}">{{ self.display_name_with_default }}</h4>
{% endif %} {% endif %}
<fieldset class="rating questionnaire" id="{{ self.html_id }}"> <fieldset class="rating questionnaire" id="{{ self.html_id }}">
<legend class="question field-group-hd">{{ self.question|safe }}</legend> <legend class="question field-group-hd">{{ self.question|safe }}</legend>
...@@ -8,17 +8,19 @@ ...@@ -8,17 +8,19 @@
{% for i in '12345' %} {% for i in '12345' %}
<div class="choice" aria-live="polite" aria-atomic="true"> <div class="choice" aria-live="polite" aria-atomic="true">
<label class="choice-label" <label class="choice-label"
aria-describedby="feedback_{{ self.html_id }} choice_tips_{{ self.html_id }}-{{ i }}"> aria-describedby="feedback_{{ self.html_id }} choice_tips_{{ self.html_id }}-{{ i }}">
<div class="choice-result fa icon-2x" aria-label="" <span class="choice-result fa icon-2x" aria-label=""
data-label_correct="{% trans "Correct" %}" data-label_incorrect="{% trans "Incorrect" %}"></div> data-label_correct="{% trans "Correct" %}" data-label_incorrect="{% trans "Incorrect" %}"></span>
<div class="choice-selector"> <span class="choice-selector">
<input type="radio" name="{{ self.name }}" value="{{i}}" <input type="radio" name="{{ self.name }}" value="{{i}}"
{% if self.student_choice == i and not hide_prev_answer %} checked{%else%} data-student-choice='{{self.student_choice}}'{% endif %} {% if self.student_choice == i and not hide_prev_answer %} checked{%else%} data-student-choice='{{self.student_choice}}'{% endif %}
/> />
</span>
<span class="choice-label-text">
{{i}} {{i}}
{% if i == '1' %} - {{ self.low|safe }}{% endif %} {% if i == '1' %} - {{ self.low|safe }}{% endif %}
{% if i == '5' %} - {{ self.high|safe }}{% endif %} {% if i == '5' %} - {{ self.high|safe }}{% endif %}
</div> </span>
</label> </label>
<div class="choice-tips-container"> <div class="choice-tips-container">
<div class="choice-tips" id="choice_tips_{{ self.html_id }}-{{ i }}"></div> <div class="choice-tips" id="choice_tips_{{ self.html_id }}-{{ i }}"></div>
...@@ -29,15 +31,15 @@ ...@@ -29,15 +31,15 @@
{% for choice in custom_choices %} {% for choice in custom_choices %}
<div class="choice" aria-live="polite" aria-atomic="true"> <div class="choice" aria-live="polite" aria-atomic="true">
<label class="choice-label" <label class="choice-label"
aria-describedby="feedback_{{ self.html_id }} choice_tips_{{ self.html_id }}-{{ forloop.counter }}"> aria-describedby="feedback_{{ self.html_id }} choice_tips_{{ self.html_id }}-{{ forloop.counter }}">
<div class="choice-result fa icon-2x" aria-label="" <span class="choice-result fa icon-2x" aria-label=""
data-label_correct="{% trans "Correct" %}" data-label_incorrect="{% trans "Incorrect" %}"></div> data-label_correct="{% trans "Correct" %}" data-label_incorrect="{% trans "Incorrect" %}"></span>
<div class="choice-selector"> <span class="choice-selector">
<input type="radio" name="{{ self.name }}" value="{{ choice.value }}" <input type="radio" name="{{ self.name }}" value="{{ choice.value }}"
{% if self.student_choice == choice.value and not hide_prev_answer %} checked{%else%} data-student-choice='{{self.student_choice}}'{% endif %} {% if self.student_choice == choice.value and not hide_prev_answer %} checked{%else%} data-student-choice='{{self.student_choice}}'{% endif %}
/> />
</div> </span>
{{ choice.content|safe }} <span class="choice-label-text">{{ choice.content|safe }}</span>
</label> </label>
<div class="choice-tips-container"> <div class="choice-tips-container">
<div class="choice-tips" id="choice_tips_{{ self.html_id }}-{{ forloop.counter }}"></div> <div class="choice-tips" id="choice_tips_{{ self.html_id }}-{{ forloop.counter }}"></div>
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<div class="sb-review-score"> <div class="sb-review-score">
<div class="grade-result"> <div class="grade-result">
<h2>{% blocktrans %}You scored {{score}}% on this assessment. {% endblocktrans %}</h2> <h4>{% blocktrans %}You scored {{score}}% on this assessment. {% endblocktrans %}</h4>
{% if show_extended_review %} {% if show_extended_review %}
<p class="review-links-explanation"> <p class="review-links-explanation">
{% trans "Click a question to review feedback on your response." %} {% trans "Click a question to review feedback on your response." %}
......
{% load i18n %} {% load i18n %}
<div class="xblock-pb-slider"> <div class="xblock-pb-slider">
{% if not hide_header %}<h3 class="question-title">{{ title }}</h3>{% endif %} {% if not hide_header %}<h4 class="question-title">{{ title }}</h4>{% endif %}
<div class="pb-slider-box clearfix"> <div class="pb-slider-box clearfix">
<p><label>{{ question|safe }} <span class="sr">({{instructions_string}})</span> <p><label>{{ question|safe }} <span class="sr">({{instructions_string}})</span>
<input type="range" id="{{ slider_id }}" class="pb-slider-range" <input type="range" id="{{ slider_id }}" class="pb-slider-range"
......
...@@ -2,13 +2,13 @@ ...@@ -2,13 +2,13 @@
data-next-button-label="{{ self.next_button_label }}" {% if self.has_question %} data-has-question="true" {% endif %}> data-next-button-label="{{ self.next_button_label }}" {% if self.has_question %} data-has-question="true" {% endif %}>
{% if show_title %} {% if show_title %}
<div class="title"> <div class="title">
<h3> <h4>
{% if title %} {% if title %}
{{ title }} {{ title }}
{% else %} {% else %}
{{ self.display_name_with_default }} {{ self.display_name_with_default }}
{% endif %} {% endif %}
</h3> </h4>
</div> </div>
{% endif %} {% endif %}
......
...@@ -281,7 +281,7 @@ class MentoringAssessmentBaseTest(ProblemBuilderBaseTest): ...@@ -281,7 +281,7 @@ class MentoringAssessmentBaseTest(ProblemBuilderBaseTest):
self.wait_until_text_in(question_text, mentoring) self.wait_until_text_in(question_text, mentoring)
question_div = None question_div = None
for xblock_div in mentoring.find_elements_by_css_selector('div.xblock-v1'): for xblock_div in mentoring.find_elements_by_css_selector('div.xblock-v1'):
header_text = xblock_div.find_elements_by_css_selector('h3.question-title') header_text = xblock_div.find_elements_by_css_selector('h4.question-title')
if header_text and question_text in header_text[0].text: if header_text and question_text in header_text[0].text:
question_div = xblock_div question_div = xblock_div
self.assertTrue(xblock_div.is_displayed()) self.assertTrue(xblock_div.is_displayed())
......
...@@ -86,6 +86,9 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest): ...@@ -86,6 +86,9 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest):
def _get_answer_checkmark(self, answer): def _get_answer_checkmark(self, answer):
return answer.find_element_by_xpath("ancestor::node()[3]").find_element_by_css_selector(".answer-checkmark") return answer.find_element_by_xpath("ancestor::node()[3]").find_element_by_css_selector(".answer-checkmark")
def _get_completion_checkmark(self, completion):
return completion.find_element_by_xpath("ancestor::node()[4]").find_element_by_css_selector(".submit-result")
def _get_messages_element(self, mentoring): def _get_messages_element(self, mentoring):
return mentoring.find_element_by_css_selector('.messages') return mentoring.find_element_by_css_selector('.messages')
...@@ -97,14 +100,23 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest): ...@@ -97,14 +100,23 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest):
mcq = self._get_xblock(mentoring, "feedback_mcq_2") mcq = self._get_xblock(mentoring, "feedback_mcq_2")
mrq = self._get_xblock(mentoring, "feedback_mrq_3") mrq = self._get_xblock(mentoring, "feedback_mrq_3")
rating = self._get_xblock(mentoring, "feedback_rating_4") rating = self._get_xblock(mentoring, "feedback_rating_4")
completion = self._get_xblock(mentoring, "completion_1").find_element_by_css_selector('.pb-completion-value')
return answer, mcq, mrq, rating return answer, mcq, mrq, rating, completion
def _assert_answer(self, answer, results_shown=True): def _assert_answer(self, answer, results_shown=True):
self.assertEqual(answer.get_attribute('value'), 'This is the answer') self.assertEqual(answer.get_attribute('value'), 'This is the answer')
answer_checkmark = self._get_answer_checkmark(answer) answer_checkmark = self._get_answer_checkmark(answer)
self._assert_checkmark(answer_checkmark, shown=results_shown) self._assert_checkmark(answer_checkmark, shown=results_shown)
def _assert_completion(self, completion, results_shown=True):
self.assertTrue(completion.is_selected())
completion_checkmark = self._get_completion_checkmark(completion)
if results_shown:
self.assertTrue(completion_checkmark.is_displayed())
else:
self.assertFalse(completion_checkmark.is_displayed())
def _assert_checkmark(self, checkmark, correct=True, shown=True): def _assert_checkmark(self, checkmark, correct=True, shown=True):
result_classes = checkmark.get_attribute('class').split() result_classes = checkmark.get_attribute('class').split()
result_label = checkmark.get_attribute('aria-label').strip() result_label = checkmark.get_attribute('aria-label').strip()
...@@ -198,7 +210,7 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest): ...@@ -198,7 +210,7 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest):
self.assertFalse(messages.is_displayed()) self.assertFalse(messages.is_displayed())
self.assertEqual(messages.text, "") self.assertEqual(messages.text, "")
def _standard_filling(self, answer, mcq, mrq, rating): def _standard_filling(self, answer, mcq, mrq, rating, completion):
# Long answer # Long answer
answer.send_keys('This is the answer') answer.send_keys('This is the answer')
# MCQ # MCQ
...@@ -212,15 +224,18 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest): ...@@ -212,15 +224,18 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest):
self.click_choice(mrq, "Its bugs") self.click_choice(mrq, "Its bugs")
# Rating # Rating
self.click_choice(rating, "4") self.click_choice(rating, "4")
# Completion - tick the checkbox
completion.click()
# mcq and rating can't be reset easily, but it's not required; listing them here to keep method signature similar # mcq and rating can't be reset easily, but it's not required; listing them here to keep method signature similar
def _clear_filling(self, answer, mcq, mrq, rating): # pylint: disable=unused-argument def _clear_filling(self, answer, mcq, mrq, rating, completion): # pylint: disable=unused-argument
answer.clear() answer.clear()
completion.click()
for checkbox in mrq.find_elements_by_css_selector('.choice input'): for checkbox in mrq.find_elements_by_css_selector('.choice input'):
if checkbox.is_selected(): if checkbox.is_selected():
checkbox.click() checkbox.click()
def _standard_checks(self, answer, mcq, mrq, rating, messages): def _standard_checks(self, answer, mcq, mrq, rating, completion, messages):
self.wait_until_visible(messages) self.wait_until_visible(messages)
# Long answer: Previous answer and results visible # Long answer: Previous answer and results visible
...@@ -231,10 +246,12 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest): ...@@ -231,10 +246,12 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest):
self._assert_mrq(mrq) self._assert_mrq(mrq)
# Rating: Previous answer and results visible # Rating: Previous answer and results visible
self._assert_rating(rating) self._assert_rating(rating)
# Completion: Previous answer and results visible
self._assert_completion(completion)
# Messages visible # Messages visible
self._assert_messages(messages) self._assert_messages(messages)
def _mcq_hide_previous_answer_checks(self, answer, mcq, mrq, rating, messages): def _mcq_hide_previous_answer_checks(self, answer, mcq, mrq, rating, completion, messages):
self.wait_until_visible(messages) self.wait_until_visible(messages)
# Long answer: Previous answer and results visible # Long answer: Previous answer and results visible
...@@ -245,10 +262,12 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest): ...@@ -245,10 +262,12 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest):
self._assert_mrq(mrq, previous_answer_shown=False) self._assert_mrq(mrq, previous_answer_shown=False)
# Rating: Previous answer and results hidden # Rating: Previous answer and results hidden
self._assert_rating(rating, previous_answer_shown=False) self._assert_rating(rating, previous_answer_shown=False)
# Completion: Previous answer and results visible
self._assert_completion(completion)
# Messages visible # Messages visible
self._assert_messages(messages) self._assert_messages(messages)
def _hide_feedback_checks(self, answer, mcq, mrq, rating, messages): def _hide_feedback_checks(self, answer, mcq, mrq, rating, completion, messages):
# Long answer: Previous answer visible and results hidden # Long answer: Previous answer visible and results hidden
self._assert_answer(answer, results_shown=False) self._assert_answer(answer, results_shown=False)
# MCQ: Previous answer and results visible # MCQ: Previous answer and results visible
...@@ -257,10 +276,12 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest): ...@@ -257,10 +276,12 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest):
self._assert_mrq(mrq) self._assert_mrq(mrq)
# Rating: Previous answer and results visible # Rating: Previous answer and results visible
self._assert_rating(rating) self._assert_rating(rating)
# Completion: Previous answer visible and results hidden
self._assert_completion(completion, results_shown=False)
# Messages hidden # Messages hidden
self._assert_messages(messages, shown=False) self._assert_messages(messages, shown=False)
def _mcq_hide_previous_answer_hide_feedback_checks(self, answer, mcq, mrq, rating, messages): def _mcq_hide_previous_answer_hide_feedback_checks(self, answer, mcq, mrq, rating, completion, messages):
# Long answer: Previous answer visible and results hidden # Long answer: Previous answer visible and results hidden
self._assert_answer(answer, results_shown=False) self._assert_answer(answer, results_shown=False)
# MCQ: Previous answer and results hidden # MCQ: Previous answer and results hidden
...@@ -269,6 +290,8 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest): ...@@ -269,6 +290,8 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest):
self._assert_mrq(mrq, previous_answer_shown=False) self._assert_mrq(mrq, previous_answer_shown=False)
# Rating: Previous answer and feedback hidden # Rating: Previous answer and feedback hidden
self._assert_rating(rating, previous_answer_shown=False) self._assert_rating(rating, previous_answer_shown=False)
# Completion: Previous answer visible and results hidden
self._assert_completion(completion, results_shown=False)
# Messages hidden # Messages hidden
self._assert_messages(messages, shown=False) self._assert_messages(messages, shown=False)
...@@ -289,7 +312,7 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest): ...@@ -289,7 +312,7 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest):
def test_feedback_and_messages_not_shown_on_first_load(self): def test_feedback_and_messages_not_shown_on_first_load(self):
mentoring = self.load_scenario("feedback_persistence.xml") mentoring = self.load_scenario("feedback_persistence.xml")
answer, mcq, mrq, rating = self._get_controls(mentoring) answer, mcq, mrq, rating, completion = self._get_controls(mentoring)
messages = self._get_messages_element(mentoring) messages = self._get_messages_element(mentoring)
submit = self._get_submit(mentoring) submit = self._get_submit(mentoring)
...@@ -301,6 +324,8 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest): ...@@ -301,6 +324,8 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest):
self._assert_feedback_hidden(mrq, i) self._assert_feedback_hidden(mrq, i)
for i in range(5): for i in range(5):
self._assert_feedback_hidden(rating, i) self._assert_feedback_hidden(rating, i)
completion_checkmark = self._get_completion_checkmark(completion)
self.assertFalse(completion_checkmark.is_displayed())
self.assertFalse(messages.is_displayed()) self.assertFalse(messages.is_displayed())
self.assertFalse(submit.is_enabled()) self.assertFalse(submit.is_enabled())
...@@ -336,21 +361,21 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest): ...@@ -336,21 +361,21 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest):
with mock.patch("problem_builder.mentoring.MentoringBlock.get_options") as patched_options: with mock.patch("problem_builder.mentoring.MentoringBlock.get_options") as patched_options:
patched_options.return_value = options patched_options.return_value = options
mentoring = self.load_scenario("feedback_persistence.xml") mentoring = self.load_scenario("feedback_persistence.xml")
answer, mcq, mrq, rating = self._get_controls(mentoring) answer, mcq, mrq, rating, completion = self._get_controls(mentoring)
messages = self._get_messages_element(mentoring) messages = self._get_messages_element(mentoring)
self._standard_filling(answer, mcq, mrq, rating) self._standard_filling(answer, mcq, mrq, rating, completion)
self.click_submit(mentoring) self.click_submit(mentoring)
self._standard_checks(answer, mcq, mrq, rating, messages) self._standard_checks(answer, mcq, mrq, rating, completion, messages)
# Now reload the page... # Now reload the page...
mentoring = self.reload_student_view() mentoring = self.reload_student_view()
answer, mcq, mrq, rating = self._get_controls(mentoring) answer, mcq, mrq, rating, completion = self._get_controls(mentoring)
messages = self._get_messages_element(mentoring) messages = self._get_messages_element(mentoring)
submit = self._get_submit(mentoring) submit = self._get_submit(mentoring)
# ... and see if previous answers, results, feedback are shown/hidden correctly # ... and see if previous answers, results, feedback are shown/hidden correctly
getattr(self, after_reload_checks)(answer, mcq, mrq, rating, messages) getattr(self, after_reload_checks)(answer, mcq, mrq, rating, completion, messages)
# After reloading, submit is enabled only when: # After reloading, submit is enabled only when:
# - feedback is hidden; and # - feedback is hidden; and
...@@ -370,7 +395,7 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest): ...@@ -370,7 +395,7 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest):
def test_given_perfect_score_in_past_loads_current_result(self): def test_given_perfect_score_in_past_loads_current_result(self):
mentoring = self.load_scenario("feedback_persistence.xml") mentoring = self.load_scenario("feedback_persistence.xml")
answer, mcq, mrq, rating = self._get_controls(mentoring) answer, mcq, mrq, rating, completion = self._get_controls(mentoring)
messages = self._get_messages_element(mentoring) messages = self._get_messages_element(mentoring)
answer.send_keys('This is the answer') answer.send_keys('This is the answer')
...@@ -380,6 +405,7 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest): ...@@ -380,6 +405,7 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest):
self.click_choice(mrq, "Its gracefulness") self.click_choice(mrq, "Its gracefulness")
self.click_choice(mrq, "Its beauty") self.click_choice(mrq, "Its beauty")
self.click_choice(rating, "4") self.click_choice(rating, "4")
completion.click()
self.click_submit(mentoring) self.click_submit(mentoring)
# precondition - verifying 100% score achieved # precondition - verifying 100% score achieved
...@@ -396,23 +422,24 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest): ...@@ -396,23 +422,24 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest):
self._assert_feedback_shown(mrq, 2, "This MRQ is indeed very graceful", click_choice_result=True) self._assert_feedback_shown(mrq, 2, "This MRQ is indeed very graceful", click_choice_result=True)
self._assert_feedback_shown(mrq, 3, "Nah, there aren't any!", click_choice_result=True) self._assert_feedback_shown(mrq, 3, "Nah, there aren't any!", click_choice_result=True)
self._assert_feedback_shown(rating, 3, "I love good grades.", click_choice_result=True) self._assert_feedback_shown(rating, 3, "I love good grades.", click_choice_result=True)
self.assertTrue(completion.is_selected())
self.assertTrue(messages.is_displayed()) self.assertTrue(messages.is_displayed())
self.assertEqual(messages.text, "FEEDBACK\nAll Good") self.assertEqual(messages.text, "FEEDBACK\nAll Good")
self._clear_filling(answer, mcq, mrq, rating) self._clear_filling(answer, mcq, mrq, rating, completion)
self._standard_filling(answer, mcq, mrq, rating) self._standard_filling(answer, mcq, mrq, rating, completion)
self.click_submit(mentoring) self.click_submit(mentoring)
self._standard_checks(answer, mcq, mrq, rating, messages) self._standard_checks(answer, mcq, mrq, rating, completion, messages)
# now, reload the page and make sure LATEST submission is loaded and feedback is shown # now, reload the page and make sure LATEST submission is loaded and feedback is shown
mentoring = self.reload_student_view() mentoring = self.reload_student_view()
answer, mcq, mrq, rating = self._get_controls(mentoring) answer, mcq, mrq, rating, completion = self._get_controls(mentoring)
messages = self._get_messages_element(mentoring) messages = self._get_messages_element(mentoring)
self._standard_checks(answer, mcq, mrq, rating, messages) self._standard_checks(answer, mcq, mrq, rating, completion, messages)
def test_partial_mrq_is_not_completed(self): def test_partial_mrq_is_not_completed(self):
mentoring = self.load_scenario("feedback_persistence.xml") mentoring = self.load_scenario("feedback_persistence.xml")
answer, mcq, mrq, rating = self._get_controls(mentoring) answer, mcq, mrq, rating, completion = self._get_controls(mentoring)
messages = self._get_messages_element(mentoring) messages = self._get_messages_element(mentoring)
answer.send_keys('This is the answer') answer.send_keys('This is the answer')
...@@ -421,9 +448,10 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest): ...@@ -421,9 +448,10 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest):
self.click_choice(mrq, "Its elegance") self.click_choice(mrq, "Its elegance")
self.click_choice(mrq, "Its gracefulness") self.click_choice(mrq, "Its gracefulness")
self.click_choice(rating, "4") self.click_choice(rating, "4")
completion.click()
self.click_submit(mentoring) self.click_submit(mentoring)
def assert_state(answer, mcq, mrq, rating, messages): def assert_state(answer, mcq, mrq, rating, completion, messages):
self.wait_until_visible(messages) self.wait_until_visible(messages)
self.assertEqual(answer.get_attribute('value'), 'This is the answer') self.assertEqual(answer.get_attribute('value'), 'This is the answer')
self._assert_feedback_shown(mcq, 0, "Great!", click_choice_result=True) self._assert_feedback_shown(mcq, 0, "Great!", click_choice_result=True)
...@@ -438,16 +466,17 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest): ...@@ -438,16 +466,17 @@ class ProblemBuilderQuestionnaireBlockTest(ProblemBuilderBaseTest):
self._assert_feedback_shown(mrq, 2, "This MRQ is indeed very graceful", click_choice_result=True) self._assert_feedback_shown(mrq, 2, "This MRQ is indeed very graceful", click_choice_result=True)
self._assert_feedback_shown(mrq, 3, "Nah, there aren't any!", click_choice_result=True) self._assert_feedback_shown(mrq, 3, "Nah, there aren't any!", click_choice_result=True)
self._assert_feedback_shown(rating, 3, "I love good grades.", click_choice_result=True) self._assert_feedback_shown(rating, 3, "I love good grades.", click_choice_result=True)
self.assertTrue(completion.is_selected())
self.assertTrue(messages.is_displayed()) self.assertTrue(messages.is_displayed())
self.assertEqual(messages.text, "FEEDBACK\nNot done yet") self.assertEqual(messages.text, "FEEDBACK\nNot done yet")
assert_state(answer, mcq, mrq, rating, messages) assert_state(answer, mcq, mrq, rating, completion, messages)
# now, reload the page and make sure the same result is shown # now, reload the page and make sure the same result is shown
mentoring = self.reload_student_view() mentoring = self.reload_student_view()
answer, mcq, mrq, rating = self._get_controls(mentoring) answer, mcq, mrq, rating, completion = self._get_controls(mentoring)
messages = self._get_messages_element(mentoring) messages = self._get_messages_element(mentoring)
assert_state(answer, mcq, mrq, rating, messages) assert_state(answer, mcq, mrq, rating, completion, messages)
@ddt.unpack @ddt.unpack
@ddt.data( @ddt.data(
......
...@@ -48,12 +48,12 @@ class TitleTest(SeleniumXBlockTest): ...@@ -48,12 +48,12 @@ class TitleTest(SeleniumXBlockTest):
self.set_scenario_xml(xml) self.set_scenario_xml(xml)
pb_element = self.go_to_view() pb_element = self.go_to_view()
if expected_title is not None: if expected_title is not None:
h2 = pb_element.find_element_by_css_selector('h2') h3 = pb_element.find_element_by_css_selector('h3')
self.assertEqual(h2.text, expected_title) self.assertEqual(h3.text, expected_title)
else: else:
# No <h2> element should be present: # No <h3> element should be present:
all_h2s = pb_element.find_elements_by_css_selector('h2') all_h3s = pb_element.find_elements_by_css_selector('h3')
self.assertEqual(len(all_h2s), 0) self.assertEqual(len(all_h3s), 0)
class StepTitlesTest(SeleniumXBlockTest): class StepTitlesTest(SeleniumXBlockTest):
...@@ -145,9 +145,9 @@ class StepTitlesTest(SeleniumXBlockTest): ...@@ -145,9 +145,9 @@ class StepTitlesTest(SeleniumXBlockTest):
self.set_scenario_xml(xml) self.set_scenario_xml(xml)
pb_element = self.go_to_view() pb_element = self.go_to_view()
if expected_title: if expected_title:
h3 = pb_element.find_element_by_css_selector('h3') h4 = pb_element.find_element_by_css_selector('h4')
self.assertEqual(h3.text, expected_title) self.assertEqual(h4.text, expected_title)
else: else:
# No <h3> element should be present: # No <h4> element should be present:
all_h3s = pb_element.find_elements_by_css_selector('h3') all_h4s = pb_element.find_elements_by_css_selector('h4')
self.assertEqual(len(all_h3s), 0) self.assertEqual(len(all_h4s), 0)
...@@ -32,6 +32,8 @@ ...@@ -32,6 +32,8 @@
<pb-tip values='["notwant"]'>Your loss!</pb-tip> <pb-tip values='["notwant"]'>Your loss!</pb-tip>
</pb-rating> </pb-rating>
<pb-completion name="completion_1" question="Did you attend the meeting?" answer="Yes, I did." />
<pb-message type="completed">All Good</pb-message> <pb-message type="completed">All Good</pb-message>
<pb-message type="incomplete">Not done yet</pb-message> <pb-message type="incomplete">Not done yet</pb-message>
</problem-builder> </problem-builder>
......
""" """
Tests temporary AnswerMixin code that helps migrate course_id column to course_key. Unit tests for AnswerMixin.
""" """
import json
import unittest import unittest
from collections import namedtuple from collections import namedtuple
from datetime import datetime
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from mock import patch
from problem_builder.answer import AnswerMixin from problem_builder.answer import AnswerMixin
from problem_builder.models import Answer from problem_builder.models import Answer
...@@ -29,6 +31,8 @@ class TestAnswerMixin(unittest.TestCase): ...@@ -29,6 +31,8 @@ class TestAnswerMixin(unittest.TestCase):
answer_mixin = AnswerMixin() answer_mixin = AnswerMixin()
answer_mixin.name = name answer_mixin.name = name
answer_mixin.runtime = self.FakeRuntime(course_id, student_id) answer_mixin.runtime = self.FakeRuntime(course_id, student_id)
answer_mixin.fields = {}
answer_mixin.has_children = False
return answer_mixin return answer_mixin
def test_creates_model_instance(self): def test_creates_model_instance(self):
...@@ -37,7 +41,6 @@ class TestAnswerMixin(unittest.TestCase): ...@@ -37,7 +41,6 @@ class TestAnswerMixin(unittest.TestCase):
model = answer_mixin.get_model_object() model = answer_mixin.get_model_object()
self.assertEqual(model.name, name) self.assertEqual(model.name, name)
self.assertEqual(model.student_id, self.anonymous_student_id) self.assertEqual(model.student_id, self.anonymous_student_id)
self.assertEqual(model.course_id, self.course_id)
self.assertEqual(model.course_key, self.course_id) self.assertEqual(model.course_key, self.course_id)
self.assertEqual(Answer.objects.get(pk=model.pk), model) self.assertEqual(Answer.objects.get(pk=model.pk), model)
...@@ -47,32 +50,63 @@ class TestAnswerMixin(unittest.TestCase): ...@@ -47,32 +50,63 @@ class TestAnswerMixin(unittest.TestCase):
name=name, name=name,
student_id=self.anonymous_student_id, student_id=self.anonymous_student_id,
course_key=self.course_id, course_key=self.course_id,
course_id='ignored'
) )
existing_model.save() existing_model.save()
answer_mixin = self.make_answer_mixin(name=name) answer_mixin = self.make_answer_mixin(name=name)
model = answer_mixin.get_model_object() model = answer_mixin.get_model_object()
self.assertEqual(model, existing_model) self.assertEqual(model, existing_model)
def test_finds_instance_by_course_id(self):
name = 'test-course-id'
existing_model = Answer(
name=name,
student_id=self.anonymous_student_id,
course_id=self.course_id,
course_key=None
)
# Temporarily patch full_clean to allow saving object with blank course_key to the database.
with patch.object(Answer, 'full_clean', return_value=None):
existing_model.save()
answer_mixin = self.make_answer_mixin(name=name)
model = answer_mixin.get_model_object()
self.assertEqual(model, existing_model)
self.assertEqual(model.course_key, self.course_id)
def test_works_with_long_course_keys(self): def test_works_with_long_course_keys(self):
course_id = 'course-v1:VeryLongOrganizationName+VeryLongCourseNumber+VeryLongCourseRun' course_id = 'course-v1:VeryLongOrganizationName+VeryLongCourseNumber+VeryLongCourseRun'
self.assertTrue(len(course_id) > 50) # precondition check self.assertTrue(len(course_id) > 50) # precondition check
answer_mixin = self.make_answer_mixin(course_id=course_id) answer_mixin = self.make_answer_mixin(course_id=course_id)
model = answer_mixin.get_model_object() model = answer_mixin.get_model_object()
self.assertEqual(model.course_key, course_id) self.assertEqual(model.course_key, course_id)
def test_build_user_state_data(self):
name = 'test-course-key-2'
existing_model = Answer(
name=name,
student_id=self.anonymous_student_id,
course_key=self.course_id,
student_input="Test",
created_on=datetime(2017, 1, 2, 3, 4, 5),
modified_on=datetime(2017, 7, 8, 9, 10, 11),
)
existing_model.save()
answer_mixin = self.make_answer_mixin(name=name)
student_view_user_state = answer_mixin.build_user_state_data()
expected_user_state_data = {
"answer_data": {
"student_input": existing_model.student_input,
"created_on": existing_model.created_on,
"modified_on": existing_model.modified_on,
}
}
self.assertEqual(student_view_user_state, expected_user_state_data)
def test_student_view_user_state(self):
name = 'test-course-key-3'
existing_model = Answer(
name=name,
student_id=self.anonymous_student_id,
course_key=self.course_id,
student_input="Test",
created_on=datetime(2017, 1, 2, 3, 4, 5),
modified_on=datetime(2017, 7, 8, 9, 10, 11),
)
existing_model.save()
answer_mixin = self.make_answer_mixin(name=name)
student_view_user_state = answer_mixin.student_view_user_state()
parsed_student_state = json.loads(student_view_user_state.body)
expected_user_state_data = {
"answer_data": {
"student_input": existing_model.student_input,
"created_on": existing_model.created_on.isoformat(),
"modified_on": existing_model.modified_on.isoformat(),
}
}
self.assertEqual(parsed_student_state, expected_user_state_data)
import json
import unittest
from datetime import datetime
import pytz
from mock import MagicMock, Mock
from xblock.core import XBlock
from xblock.field_data import DictFieldData
from xblock.fields import String, Scope, Boolean, Integer, DateTime
from problem_builder.mixins import StudentViewUserStateMixin
class NoUserStateFieldsMixin(object):
scope_settings = String(name="Field1", scope=Scope.settings)
scope_content = String(name="Field1", scope=Scope.content)
user_state_summary = String(name="Not in the output", scope=Scope.user_state_summary)
class UserStateFieldsMixin(object):
answer_1 = String(name="state1", scope=Scope.user_state)
answer_2 = Boolean(name="state2", scope=Scope.user_state)
preference_1 = String(name="pref1", scope=Scope.preferences)
preference_2 = Integer(name="pref2", scope=Scope.preferences)
user_info_1 = String(name="info1", scope=Scope.user_info)
user_info_2 = DateTime(name="info2", scope=Scope.user_info)
class ChildrenMixin(object):
# overriding children for ease of testing
_children = []
has_children = True
@property
def children(self):
return self._children
@children.setter
def children(self, value):
self._children = value
class XBlockWithNoUserState(XBlock, NoUserStateFieldsMixin, StudentViewUserStateMixin):
pass
class XBlockNoChildrenWithUserState(XBlock, NoUserStateFieldsMixin, UserStateFieldsMixin, StudentViewUserStateMixin):
pass
class XBlockChildrenNoUserState(XBlock, NoUserStateFieldsMixin, ChildrenMixin, StudentViewUserStateMixin):
has_children = True
class XBlockChildrenUserState(
XBlock, NoUserStateFieldsMixin, UserStateFieldsMixin, ChildrenMixin, StudentViewUserStateMixin
):
has_children = True
class TestStudentViewUserStateMixin(unittest.TestCase):
def setUp(self):
self._runtime = MagicMock()
def _build_block(self, block_type, fields):
return block_type(self._runtime, DictFieldData(fields), Mock())
def _set_children(self, block, children):
block.children = children.keys()
self._runtime.get_block.side_effect = children.get
def _merge_dicts(self, dict1, dict2):
result = dict1.copy()
result.update(dict2)
return result
def test_no_user_state_returns_empty(self):
block = self._build_block(XBlockWithNoUserState, {"scope_settings": "qwe", "scope_content": "ASD"})
self.assertEqual(block.build_user_state_data(), {})
def test_no_child_blocks_with_user_state(self):
user_fields = {
"answer_1": "AAAA",
"answer_2": False,
"preference_1": "Yes",
"preference_2": 12,
"user_info_1": "John",
"user_info_2": datetime(2017, 1, 2, 3, 4, 5, tzinfo=pytz.UTC)
}
other_fields = {"setting": "setting", "content": "content", "user_state_summary": "Something"}
block_fields = self._merge_dicts(user_fields, other_fields)
block = self._build_block(XBlockNoChildrenWithUserState, block_fields)
self.assertEqual(block.build_user_state_data(), user_fields)
def test_children_empty_no_user_state(self):
block = self._build_block(XBlockChildrenNoUserState, {"scope_settings": "qwe", "scope_content": "ASD"})
self.assertEqual(block.children, []) # precondition
self.assertEqual(block.build_user_state_data(), {"components": {}})
def test_children_no_user_state(self):
block = self._build_block(XBlockChildrenNoUserState, {"scope_settings": "qwe", "scope_content": "ASD"})
no_user_state1 = self._build_block(XBlockWithNoUserState, {"scope_settings": "qwe", "scope_content": "ASD"})
no_user_state2 = self._build_block(XBlockWithNoUserState, {"scope_settings": "ZXC", "scope_content": "VBN"})
nested = {"child1": no_user_state1, "child2": no_user_state2}
self._set_children(block, nested)
# preconditions
self.assertEqual(block.children, nested.keys())
self.assertEqual(self._runtime.get_block("child1"), no_user_state1)
self.assertEqual(self._runtime.get_block("child2"), no_user_state2)
student_user_state = block.build_user_state_data()
expected = {"components": {"child1": {}, "child2": {}}}
self.assertEqual(student_user_state, expected)
def test_children_with_user_state(self):
block = self._build_block(XBlockChildrenNoUserState, {"scope_settings": "qwe", "scope_content": "ASD"})
other_fields = {"setting": "setting", "content": "content", "user_state_summary": "Something"}
user_fields1 = {
"answer_1": "AAAA",
"answer_2": False,
"preference_1": "Yes",
"preference_2": 12,
"user_info_1": "John",
"user_info_2": datetime(2017, 1, 2, 3, 4, 5, tzinfo=pytz.UTC)
}
user_fields2 = {
"answer_1": "BBBB",
"answer_2": True,
"preference_1": "No",
"preference_2": 7,
"user_info_1": "jane",
"user_info_2": datetime(2017, 1, 2, 3, 4, 5, tzinfo=pytz.UTC)
}
user_state1 = self._build_block(XBlockNoChildrenWithUserState, self._merge_dicts(user_fields1, other_fields))
user_state2 = self._build_block(XBlockNoChildrenWithUserState, self._merge_dicts(user_fields2, other_fields))
nested = {"child1": user_state1, "child2": user_state2}
self._set_children(block, nested)
# preconditions
self.assertEqual(block.children, nested.keys())
self.assertEqual(self._runtime.get_block("child1"), user_state1)
self.assertEqual(self._runtime.get_block("child2"), user_state2)
student_user_state = block.build_user_state_data()
expected = {"components": {"child1": user_fields1, "child2": user_fields2}}
self.assertEqual(student_user_state, expected)
def test_user_state_at_parent_and_children(self):
other_fields = {"setting": "setting", "content": "content", "user_state_summary": "Something"}
user_fields = {
"answer_1": "OOOO",
"answer_2": True,
"preference_1": "IDN",
"preference_2": 42,
"user_info_1": "Douglas",
"user_info_2": datetime(2017, 1, 2, 3, 4, 5, tzinfo=pytz.UTC)
}
block = self._build_block(XBlockChildrenUserState, self._merge_dicts(user_fields, other_fields))
nested_user_fields = {
"answer_1": "AAAA",
"answer_2": False,
"preference_1": "Yes",
"preference_2": 12,
"user_info_1": "John",
"user_info_2": datetime(2017, 1, 2, 3, 4, 5, tzinfo=pytz.UTC)
}
user_state = self._build_block(
XBlockNoChildrenWithUserState, self._merge_dicts(nested_user_fields, other_fields)
)
nested = {"child1": user_state}
self._set_children(block, nested)
# preconditions
self.assertEqual(block.children, nested.keys())
self.assertEqual(self._runtime.get_block("child1"), user_state)
student_user_state = block.build_user_state_data()
expected = user_fields.copy()
expected["components"] = {"child1": nested_user_fields}
self.assertEqual(student_user_state, expected)
def test_user_state_handler(self):
other_fields = {"setting": "setting", "content": "content", "user_state_summary": "Something"}
user_fields = {
"answer_1": "OOOO",
"answer_2": True,
"preference_1": "IDN",
"preference_2": 42,
"user_info_1": "Douglas",
"user_info_2": datetime(2017, 1, 2, 3, 4, 5, tzinfo=pytz.UTC)
}
block = self._build_block(XBlockChildrenUserState, self._merge_dicts(user_fields, other_fields))
nested_user_fields = {
"answer_1": "AAAA",
"answer_2": False,
"preference_1": "Yes",
"preference_2": 12,
"user_info_1": "John",
"user_info_2": datetime(2017, 1, 2, 3, 4, 5, tzinfo=pytz.UTC)
}
user_state = self._build_block(
XBlockNoChildrenWithUserState, self._merge_dicts(nested_user_fields, other_fields)
)
nested = {"child1": user_state}
self._set_children(block, nested)
# preconditions
self.assertEqual(block.children, nested.keys())
self.assertEqual(self._runtime.get_block("child1"), user_state)
student_user_state_response = block.student_view_user_state()
student_user_state = json.loads(student_user_state_response.body)
expected = user_fields.copy()
expected["user_info_2"] = expected["user_info_2"].isoformat()
nested_copy = nested_user_fields.copy()
nested_copy["user_info_2"] = nested_copy["user_info_2"].isoformat()
expected["components"] = {"child1": nested_copy}
self.assertEqual(student_user_state, expected)
...@@ -7,12 +7,27 @@ from random import random ...@@ -7,12 +7,27 @@ from random import random
from xblock.field_data import DictFieldData from xblock.field_data import DictFieldData
from problem_builder.mcq import MCQBlock from problem_builder.mcq import MCQBlock
from problem_builder.mrq import MRQBlock
from problem_builder.mentoring import MentoringBlock, MentoringMessageBlock, _default_options_config from problem_builder.mentoring import MentoringBlock, MentoringMessageBlock, _default_options_config
from .utils import BlockWithChildrenTestMixin from .utils import BlockWithChildrenTestMixin
@ddt.ddt @ddt.ddt
class TestMRQBlock(BlockWithChildrenTestMixin, unittest.TestCase):
def test_student_view_data(self):
"""
Ensure that all expected fields are always returned.
"""
block = MRQBlock(Mock(), DictFieldData({}), Mock())
self.assertListEqual(
block.student_view_data().keys(),
['hide_results', 'tips', 'weight', 'title', 'question', 'message', 'type', 'id', 'choices'])
@ddt.ddt
class TestMentoringBlock(BlockWithChildrenTestMixin, unittest.TestCase): class TestMentoringBlock(BlockWithChildrenTestMixin, unittest.TestCase):
def test_sends_progress_event_when_rendered_student_view_with_display_submit_false(self): def test_sends_progress_event_when_rendered_student_view_with_display_submit_false(self):
block = MentoringBlock(MagicMock(), DictFieldData({ block = MentoringBlock(MagicMock(), DictFieldData({
...@@ -109,6 +124,42 @@ class TestMentoringBlock(BlockWithChildrenTestMixin, unittest.TestCase): ...@@ -109,6 +124,42 @@ class TestMentoringBlock(BlockWithChildrenTestMixin, unittest.TestCase):
(['pb-message'] if block.is_assessment else []) # Message type: "on-assessment-review" (['pb-message'] if block.is_assessment else []) # Message type: "on-assessment-review"
) )
def test_student_view_data(self):
def get_mock_components():
child_a = Mock(spec=['student_view_data'])
child_a.block_id = 'child_a'
child_a.student_view_data.return_value = 'child_a_json'
child_b = Mock(spec=[])
child_b.block_id = 'child_b'
return [child_a, child_b]
shared_data = {
'max_attempts': 3,
'extended_feedback': True,
'feedback_label': 'Feedback label',
}
children = get_mock_components()
children_by_id = {child.block_id: child for child in children}
block_data = {'children': children}
block_data.update(shared_data)
block = MentoringBlock(Mock(), DictFieldData(block_data), Mock())
block.runtime = Mock(
get_block=lambda block: children_by_id[block.block_id],
load_block_type=lambda block: Mock,
id_reader=Mock(get_definition_id=lambda block: block, get_block_type=lambda block: block),
)
expected = {
'components': [
'child_a_json',
],
'messages': {
'completed': None,
'incomplete': None,
'max_attempts_reached': None,
}
}
expected.update(shared_data)
self.assertEqual(block.student_view_data(), expected)
@ddt.ddt @ddt.ddt
class TestMentoringBlockTheming(unittest.TestCase): class TestMentoringBlockTheming(unittest.TestCase):
......
...@@ -33,11 +33,7 @@ class Parent(StepParentMixin): ...@@ -33,11 +33,7 @@ class Parent(StepParentMixin):
pass pass
class BaseClass(object): class Step(QuestionMixin):
pass
class Step(BaseClass, QuestionMixin):
def __init__(self): def __init__(self):
pass pass
......
import unittest
from mock import Mock
from xblock.field_data import DictFieldData
from problem_builder.mentoring import MentoringWithExplicitStepsBlock
from problem_builder.step import MentoringStepBlock
from problem_builder.step_review import ReviewStepBlock, ConditionalMessageBlock, ScoreSummaryBlock
from .utils import BlockWithChildrenTestMixin
class TestMentoringBlock(BlockWithChildrenTestMixin, unittest.TestCase):
def test_student_view_data(self):
blocks_by_id = {}
mock_runtime = Mock(
get_block=lambda block_id: blocks_by_id[block_id],
load_block_type=lambda block: block.__class__,
id_reader=Mock(
get_definition_id=lambda block_id: block_id,
get_block_type=lambda block_id: blocks_by_id[block_id],
),
)
def make_block(block_type, data, **kwargs):
usage_id = str(make_block.id_counter)
make_block.id_counter += 1
mock_scope_ids = Mock(usage_id=usage_id)
block = block_type(
mock_runtime,
field_data=DictFieldData(data),
scope_ids=mock_scope_ids,
**kwargs
)
blocks_by_id[usage_id] = block
parent = kwargs.get('for_parent')
if parent:
parent.children.append(usage_id)
block.parent = parent.scope_ids.usage_id
return block
make_block.id_counter = 1
# Create top-level Step Builder block.
step_builder_data = {
'display_name': 'My Step Builder',
'show_title': False,
'weight': 5.0,
'max_attempts': 3,
'extended_feedback': True,
}
step_builder = make_block(MentoringWithExplicitStepsBlock, step_builder_data)
# Create a 'Step' block (as child of 'Step Builder') and add two mock children to it.
# One of the mocked children implements `student_view_data`, while the other one does not.
child_a = Mock(spec=['student_view_data'])
child_a.scope_ids = Mock(usage_id='child_a')
child_a.student_view_data.return_value = 'child_a_json'
blocks_by_id['child_a'] = child_a
child_b = Mock(spec=[])
child_b.scope_ids = Mock(usage_id='child_b')
blocks_by_id['child_b'] = child_b
step_data = {
'display_name': 'First Step',
'show_title': True,
'next_button_label': 'Next Question',
'message': 'This is the message.',
'children': [child_a.scope_ids.usage_id, child_b.scope_ids.usage_id],
}
make_block(MentoringStepBlock, step_data, for_parent=step_builder)
# Create a 'Step Review' block (as child of 'Step Builder').
review_step_data = {
'display_name': 'My Review Step',
}
review_step = make_block(ReviewStepBlock, review_step_data, for_parent=step_builder)
# Create 'Score Summary' block as child of 'Step Review'.
make_block(ScoreSummaryBlock, {}, for_parent=review_step)
# Create 'Conditional Message' block as child of 'Step Review'.
conditional_message_data = {
'content': 'This message is conditional',
'score_condition': 'perfect',
'num_attempts_condition': 'can_try_again',
}
make_block(ConditionalMessageBlock, conditional_message_data, for_parent=review_step)
expected = {
'title': step_builder_data['display_name'],
'show_title': step_builder_data['show_title'],
'weight': step_builder_data['weight'],
'max_attempts': step_builder_data['max_attempts'],
'extended_feedback': step_builder_data['extended_feedback'],
'components': [
{
'type': 'sb-step',
'title': step_data['display_name'],
'show_title': step_data['show_title'],
'next_button_label': step_data['next_button_label'],
'message': step_data['message'],
'components': ['child_a_json'],
},
{
'type': 'sb-review-step',
'title': review_step_data['display_name'],
'components': [
{
'type': 'sb-review-score',
},
{
'type': 'sb-conditional-message',
'content': conditional_message_data['content'],
'score_condition': conditional_message_data['score_condition'],
'num_attempts_condition': conditional_message_data['num_attempts_condition'],
},
],
},
],
}
self.assertEqual(step_builder.student_view_data(), expected)
""" """
Helper methods for testing Problem Builder / Step Builder blocks Helper methods for testing Problem Builder / Step Builder blocks
""" """
import json
from datetime import datetime, date
from mock import MagicMock, Mock, patch from mock import MagicMock, Mock, patch
from xblock.field_data import DictFieldData from xblock.field_data import DictFieldData
...@@ -87,3 +90,11 @@ def instantiate_block(cls, fields=None): ...@@ -87,3 +90,11 @@ def instantiate_block(cls, fields=None):
block.children = children block.children = children
block.runtime.get_block = lambda child_id: children[child_id] block.runtime.get_block = lambda child_id: children[child_id]
return block return block
class DateTimeEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, (datetime, date)):
return o.isoformat()
return json.JSONEncoder.default(self, o)
<problem-builder enforce_dependency="false" followed_by="past_attempts" show_title="false"> <problem-builder enforce_dependency="false" followed_by="past_attempts" show_title="false">
<html> <html>
<h3>Checking your improvement frog</h3> <h4>Checking your improvement frog</h4>
<p>Now, let's make sure your frog meets the criteria for a strong column 1. Here is your frog:</p> <p>Now, let's make sure your frog meets the criteria for a strong column 1. Here is your frog:</p>
</html> </html>
<pb-answer-recap name="improvement-frog"/> <pb-answer-recap name="improvement-frog"/>
......
...@@ -7,7 +7,7 @@ This contains a typical problem taken from a live course (content changed) ...@@ -7,7 +7,7 @@ This contains a typical problem taken from a live course (content changed)
<![CDATA[ <![CDATA[
<mentoring enforce_dependency="false" followed_by="past_attempts"> <mentoring enforce_dependency="false" followed_by="past_attempts">
<html> <html>
<h3>Checking your improvement frog</h3> <h4>Checking your improvement frog</h4>
<p>Now, let's make sure your frog meets the criteria for a strong column 1. Here is your frog:</p> <p>Now, let's make sure your frog meets the criteria for a strong column 1. Here is your frog:</p>
</html> </html>
<answer name="improvement-frog" read_only="true"/> <answer name="improvement-frog" read_only="true"/>
......
...@@ -17,6 +17,7 @@ logging_level_overrides = { ...@@ -17,6 +17,7 @@ logging_level_overrides = {
'workbench.runtime': logging.ERROR, 'workbench.runtime': logging.ERROR,
} }
def patch_broken_pipe_error(): def patch_broken_pipe_error():
"""Monkey Patch BaseServer.handle_error to not write a stacktrace to stderr on broken pipe. """Monkey Patch BaseServer.handle_error to not write a stacktrace to stderr on broken pipe.
This message is automatically suppressed in Django 1.8, so this monkey patch can be This message is automatically suppressed in Django 1.8, so this monkey patch can be
......
...@@ -71,7 +71,11 @@ BLOCKS = [ ...@@ -71,7 +71,11 @@ BLOCKS = [
setup( setup(
name='xblock-problem-builder', name='xblock-problem-builder',
<<<<<<< HEAD
version='2.6.5patch1', version='2.6.5patch1',
=======
version='2.7.2',
>>>>>>> master
description='XBlock - Problem Builder', description='XBlock - Problem Builder',
packages=['problem_builder', 'problem_builder.v1', 'problem_builder.management', 'problem_builder.management.commands'], packages=['problem_builder', 'problem_builder.v1', 'problem_builder.management', 'problem_builder.management.commands'],
install_requires=[ install_requires=[
......
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