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:
......
...@@ -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())
......
...@@ -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