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
```
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
-------
......
......@@ -11,7 +11,7 @@ dependencies:
- "pip install -r requirements.txt"
- "pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements/base.txt"
- "pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements/test.txt"
- "pip uninstall -y xblock-problem-builder && python setup.py sdist && pip install dist/xblock-problem-builder-2.6.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"
- "mkdir var"
test:
......
......@@ -23,11 +23,6 @@
import logging
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 xblock.core import XBlock
......@@ -37,7 +32,7 @@ from xblock.validation import ValidationMessage
from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import StudioEditableXBlockMixin, XBlockWithPreviewMixin
from problem_builder.sub_api import SubmittingXBlockMixin, sub_api
from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin
from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin, StudentViewUserStateMixin
import uuid
......@@ -54,7 +49,7 @@ def _(text):
# 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.
"""
......@@ -69,35 +64,6 @@ class AnswerMixin(XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin):
except AttributeError:
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):
"""
Fetches the Answer model object for the answer named `name`
......@@ -110,18 +76,13 @@ class AnswerMixin(XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin):
raise ValueError('AnswerBlock.name field need to be set to a non-null/empty value')
student_id = self._get_student_id()
course_id = self._get_course_id()
course_key = self._get_course_id()
try:
answer_data = self._fetch_model_object(name, student_id, course_id)
except Answer.DoesNotExist:
try:
# 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)
answer_data, _ = Answer.objects.get_or_create(
student_id=student_id,
course_key=course_key,
name=name,
)
return answer_data
......@@ -154,6 +115,20 @@ class AnswerMixin(XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin):
if not data.name:
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")
class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, QuestionMixin, StudioEditableXBlockMixin, XBlock):
......@@ -248,7 +223,7 @@ class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, QuestionMixin, StudioEdita
The parent block is handling a student submission, including a new answer for this
block. Update accordingly.
"""
self.student_input = submission[0]['value'].strip()
self.student_input = submission['value'].strip()
self.save()
if sub_api:
......@@ -300,12 +275,18 @@ class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, QuestionMixin, StudioEdita
return {'data': {'name': uuid.uuid4().hex[:7]}}
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,
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")
......
......@@ -29,7 +29,7 @@ from xblock.fragment import Fragment
from xblock.validation import ValidationMessage
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
......@@ -40,7 +40,10 @@ def _(text):
@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
"""
......@@ -66,6 +69,16 @@ class ChoiceBlock(StudioEditableXBlockMixin, XBlockWithPreviewMixin, XBlockWithT
status = self._(u"Out of Context") # Parent block should implement describe_choice_correctness()
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):
""" Render this choice string within a mentoring block question. """
return Fragment(u'<span class="choice-text">{}</span>'.format(self.content))
......
......@@ -28,7 +28,7 @@ from xblock.fragment import Fragment
from xblockutils.studio_editable import StudioEditableXBlockMixin
from xblockutils.resources import ResourceLoader
from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin
from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin, StudentViewUserStateMixin
from .sub_api import sub_api, SubmittingXBlockMixin
......@@ -47,7 +47,8 @@ def _(text):
@XBlock.needs('i18n')
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.
......@@ -105,6 +106,20 @@ class CompletionBlock(
student_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):
""" Return the current/last result in the required format """
if self.student_value is None:
......
......@@ -382,6 +382,7 @@ class DashboardBlock(StudioEditableXBlockMixin, ExportMixin, XBlock):
if child_isinstance(mentoring_block, child_id, MCQBlock):
yield child_id
@XBlock.supports("multi_device") # Mark as mobile-friendly
def student_view(self, context=None): # pylint: disable=unused-argument
"""
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
from xblock.validation import ValidationMessage
from xblockutils.resources import ResourceLoader
from problem_builder.mixins import StudentViewUserStateMixin
from .questionnaire import QuestionnaireAbstractBlock
from .sub_api import sub_api, SubmittingXBlockMixin
......@@ -44,7 +45,7 @@ def _(text):
# Classes ###########################################################
class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock):
class MCQBlock(SubmittingXBlockMixin, StudentViewUserStateMixin, QuestionnaireAbstractBlock):
"""
An XBlock used to ask multiple-choice questions
"""
......@@ -124,8 +125,8 @@ class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock):
def submit(self, submission):
log.debug(u'Received MCQ submission: "%s"', submission)
result = self.calculate_results(submission)
self.student_choice = submission
result = self.calculate_results(submission['value'])
self.student_choice = submission['value']
log.debug(u'MCQ submission result: %s', result)
return result
......@@ -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))
)
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):
"""
......
......@@ -36,8 +36,8 @@ from xblock.validation import ValidationMessage
from .message import MentoringMessageBlock, get_message_label
from .mixins import (
_normalize_id, QuestionMixin, MessageParentMixin, StepParentMixin, XBlockWithTranslationServiceMixin
)
_normalize_id, QuestionMixin, MessageParentMixin, StepParentMixin, XBlockWithTranslationServiceMixin,
StudentViewUserStateMixin)
from .step_review import ReviewStepBlock
from xblockutils.helpers import child_isinstance
......@@ -91,6 +91,7 @@ PARTIAL = 'partial'
class BaseMentoringBlock(
XBlock, XBlockWithTranslationServiceMixin, XBlockWithSettingsMixin,
StudioEditableXBlockMixin, ThemableXBlockMixin, MessageParentMixin,
StudentViewUserStateMixin,
):
"""
An XBlock that defines functionality shared by mentoring blocks.
......@@ -439,6 +440,7 @@ class MentoringBlock(BaseMentoringBlock, StudioContainerWithNestedXBlocksMixin,
return Score(score, int(round(score * 100)), correct, incorrect, partially_correct)
@XBlock.supports("multi_device") # Mark as mobile-friendly
def student_view(self, context):
from .questionnaire import QuestionnaireAbstractBlock # Import here to avoid circular dependency
......@@ -905,6 +907,31 @@ class MentoringBlock(BaseMentoringBlock, StudioContainerWithNestedXBlocksMixin,
"""
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):
"""
......@@ -1068,6 +1095,7 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes
def show_extended_feedback(self):
return self.extended_feedback and self.max_attempts_reached
@XBlock.supports("multi_device") # Mark as mobile-friendly
def student_view(self, context):
fragment = Fragment()
children_contents = []
......@@ -1220,3 +1248,20 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/container_edit.js'))
fragment.initialize_js('ProblemBuilderContainerEdit')
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):
]
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 problem_builder.tests.unit.utils import DateTimeEncoder
from xblock.core import XBlock
from xblock.fields import String, Boolean, Float, Scope, UNIQUE_ID
from xblock.fragment import Fragment
from xblockutils.helpers import child_isinstance
......@@ -176,3 +181,43 @@ class NoSettingsMixin(object):
def studio_view(self, _context=None):
""" Studio View """
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):
"""
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 = (
('student_id', 'course_id', 'name'),
('student_id', 'course_key', 'name'),
......@@ -43,11 +46,9 @@ class Answer(models.Model):
name = models.CharField(max_length=50, 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 = 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.
# We need to allow NULL values during the transition period,
# 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)
course_key = models.CharField(max_length=255, db_index=True)
student_input = models.TextField(blank=True, default='')
created_on = models.DateTimeField('created on', auto_now_add=True)
modified_on = models.DateTimeField('modified on', auto_now=True)
......@@ -71,4 +72,7 @@ class Share(models.Model):
notified = models.BooleanField(default=False, db_index=True)
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'),)
......@@ -24,6 +24,8 @@ import logging
from xblock.fields import List, Scope, Boolean, String
from xblock.validation import ValidationMessage
from problem_builder.mixins import StudentViewUserStateMixin
from .questionnaire import QuestionnaireAbstractBlock
from xblockutils.resources import ResourceLoader
......@@ -40,7 +42,7 @@ def _(text):
# Classes ###########################################################
class MRQBlock(QuestionnaireAbstractBlock):
class MRQBlock(StudentViewUserStateMixin, QuestionnaireAbstractBlock):
"""
An XBlock used to ask multiple-response questions
"""
......@@ -188,3 +190,26 @@ class MRQBlock(QuestionnaireAbstractBlock):
add_error(self._(u"A choice value listed as required does not exist: {}").format(choice_name(val)))
for val in (ignored - all_values):
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
fragment.initialize_js('PlotBlock')
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):
"""
Add some HTML to the author view that allows authors to add child blocks.
......
......@@ -41,7 +41,7 @@
margin-top: 10px;
}
.xblock .mentoring h3 {
.xblock .mentoring h4 {
margin-top: 0px;
margin-bottom: 7px;
}
......
......@@ -2,19 +2,23 @@
display: table;
position: relative;
width: 100%;
border-spacing: 0 6px;
border-spacing: 0 2px;
padding-top: 10px;
margin-bottom: 10px;
}
.mentoring .questionnaire .choice-result {
display: inline-block;
display: table-cell;
width: 34px;
vertical-align: top;
cursor: pointer;
float: none;
}
.mentoring div[data-block-type=pb-mrq] .questionnaire .choice-result {
display: inline-block;
}
.mentoring .questionnaire .choice {
overflow-y: hidden;
display: table-row;
......@@ -78,7 +82,13 @@
}
.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,
......
......@@ -15,7 +15,13 @@ function AnswerBlock(runtime, element) {
},
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) {
......
......@@ -16,10 +16,12 @@ function CompletionBlock(runtime, element) {
return $completion.is(':checked');
},
handleSubmit: function(result) {
handleSubmit: function(result, options) {
if (typeof result.submission !== 'undefined') {
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) {
var checkedRadio = $('input[type=radio]:checked', element);
if(checkedRadio.length) {
return checkedRadio.val();
return {"value": checkedRadio.val()};
} else {
return null;
}
......@@ -221,7 +221,9 @@ function MRQBlock(runtime, element) {
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 choiceDOM = choiceInputDOM.closest('.choice');
var choiceResultDOM = $('.choice-result', choiceDOM);
......
......@@ -3,17 +3,21 @@
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 {
padding: 4px 3px 0 3px;
font-size: 16px;
}
.mentoring .title h2 {
/* Same as h2.main in Apros */
.mentoring .title h3 {
/* Same as h2.main in Apros, amended to h3 */
color: #66a5b5;
}
.mentoring h3 {
.mentoring h4 {
text-transform: uppercase;
}
......
......@@ -235,10 +235,3 @@ class QuestionnaireAbstractBlock(
format_html = getattr(self.runtime, 'replace_urls', lambda html: html)
return format_html(self.message)
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
from xblockutils.studio_editable import StudioEditableXBlockMixin
from xblockutils.resources import ResourceLoader
from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin
from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin, StudentViewUserStateMixin
from .sub_api import sub_api, SubmittingXBlockMixin
......@@ -48,7 +48,8 @@ def _(text):
@XBlock.needs("i18n")
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.
......@@ -121,6 +122,17 @@ class SliderBlock(
student_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):
"""
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 (
from problem_builder.answer import AnswerBlock, AnswerRecapBlock
from problem_builder.completion import CompletionBlock
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.plot import PlotBlock
from problem_builder.slider import SliderBlock
......@@ -70,7 +70,7 @@ class Correctness(object):
@XBlock.needs('i18n')
class MentoringStepBlock(
StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin, XBlockWithPreviewMixin,
EnumerableChildMixin, StepParentMixin, XBlock
EnumerableChildMixin, StepParentMixin, StudentViewUserStateMixin, XBlock
):
"""
An XBlock for a step.
......@@ -265,3 +265,24 @@ class MentoringStepBlock(
fragment.initialize_js('MentoringStepBlock')
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(
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):
""" Render this message. """
html = u'<div class="review-conditional-message">{content}</div>'.format(
......@@ -151,6 +159,13 @@ class ScoreSummaryBlock(XBlockWithTranslationServiceMixin, XBlockWithPreviewMixi
html = loader.render_template("templates/html/sb-review-score.html", context.get("score_summary", {}))
return Fragment(html)
def student_view_data(self, context=None):
context = context or {}
return {
'type': self.CATEGORY,
}
embedded_student_view = student_view
def author_view(self, context=None):
......@@ -199,6 +214,11 @@ class PerQuestionFeedbackBlock(XBlockWithTranslationServiceMixin, XBlockWithPrev
html = u""
return Fragment(html)
def student_view_data(self, context=None):
return {
'type': self.CATEGORY,
}
embedded_student_view = student_view
def author_view(self, context=None):
......@@ -279,6 +299,24 @@ class ReviewStepBlock(
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
def author_edit_view(self, context):
......
{% load i18n %}
<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>
<textarea
class="answer editable" cols="50" rows="10" name="input"
......
{% load i18n %}
<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 %}
<blockquote class="answer read_only">
{% if student_input %}
......
{% load i18n %}
<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">
<p>{{ question|safe }}</p>
<p>
......
{% load i18n %}
<div class="pb-dashboard">
<div class="dashboard-report">
<h2>{{display_name}}</h2>
<h3>{{display_name}}</h3>
{% if header_html %}
<div class="report-header">
......
{% load i18n %}
<h2>{% trans "Instructor Tool" %}</h3>
<h4>{% trans "Instructor Tool" %}</h4>
<div class="data-export-options">
<div class="data-export-header">
<h3>{% trans "Filters" %}</h3>
<h4>{% trans "Filters" %}</h4>
</div>
<div class="data-export-row">
<div class="data-export-field-container">
......
{% load i18n %}
{% 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 %}
<fieldset class="choices questionnaire" id="{{ self.html_id }}">
<legend class="question field-group-hd">{{ self.question|safe }}</legend>
......@@ -8,15 +8,17 @@
{% for choice in custom_choices %}
<div class="choice" aria-live="polite" aria-atomic="true">
<label class="choice-label"
aria-describedby="feedback_{{ self.html_id }} choice_tips_{{ self.html_id }}-{{ forloop.counter }}">
<div class="choice-result fa icon-2x" aria-label=""
data-label_correct="{% trans "Correct" %}" data-label_incorrect="{% trans "Incorrect" %}"></div>
<div class="choice-selector">
aria-describedby="feedback_{{ self.html_id }} choice_tips_{{ self.html_id }}-{{ forloop.counter }}">
<span class="choice-result fa icon-2x"
aria-label=""
data-label_correct="{% trans "Correct" %}"
data-label_incorrect="{% trans "Incorrect" %}"></span>
<span class="choice-selector">
<input type="radio" name="{{ self.name }}" value="{{ choice.value }}"
{% if self.student_choice == choice.value and not hide_prev_answer %} checked{% endif %}
/>
</div>
{{ choice.content|safe }}
</span>
<span class="choice-label-text">{{ choice.content|safe }}</span>
</label>
<div class="choice-tips-container">
<div class="choice-tips" id="choice_tips_{{ self.html_id }}-{{ forloop.counter }}"></div>
......
......@@ -11,7 +11,7 @@
{% if show_title and title %}
<div class="title">
<h2>{{ title }}</h2>
<h3>{{ title }}</h3>
</div>
{% endif %}
......
<script type="text/template" id="xblock-grade-template">
<div class="grade-result">
<h2>
<h4>
<%= _.template(gettext("You scored {percent}% on this assessment."), {percent: score}, {interpolate: /\{(.+?)\}/g}) %>
</h2>
</h4>
<hr/>
<span class="assessment-checkmark icon-2x checkmark-correct icon-ok fa fa-check"></span>
......
......@@ -3,7 +3,7 @@
{% if show_title and title %}
<div class="title">
<h2>{{ title }}</h2>
<h3>{{ title }}</h3>
</div>
{% endif %}
......
{% load i18n %}
{% 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 %}
<fieldset class="choices questionnaire" id="{{ self.html_id }}"
data-hide_results="{{ self.hide_results }}" data-hide_prev_answer="{{ hide_prev_answer }}">
......@@ -8,17 +8,20 @@
<div class="choices-list">
{% for choice in custom_choices %}
<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=""
data-label_correct="{% trans "Correct" %}" data-label_incorrect="{% trans "Incorrect" %}"></div>
<div class="choice-result fa icon-2x"
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 }}
result_{{ 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 }}"
{% if choice.value in self.student_choices and not hide_prev_answer %} checked{% endif %}
/>
</div>
{{ choice.content|safe }}
</span>
<span class="choice-label-text">{{ choice.content|safe }}</span>
</label>
<div class="choice-tips-container">
<div class="choice-tips" id="choice_tips_{{ self.html_id }}-{{ forloop.counter }}"></div>
......
{% load i18n %}
<div class="sb-plot-overlay">
{% 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 %}
<p>
<strong>{% trans "Description:" %}</strong>
......
......@@ -15,7 +15,7 @@
</div>
<div class="overlays">
<h3>{% trans "Compare your plot to others!" %}</h3>
<h4>{% trans "Compare your plot to others!" %}</h4>
<input type="button"
class="plot-default"
......
{% load i18n %}
{% 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 %}
<fieldset class="rating questionnaire" id="{{ self.html_id }}">
<legend class="question field-group-hd">{{ self.question|safe }}</legend>
......@@ -8,17 +8,19 @@
{% for i in '12345' %}
<div class="choice" aria-live="polite" aria-atomic="true">
<label class="choice-label"
aria-describedby="feedback_{{ self.html_id }} choice_tips_{{ self.html_id }}-{{ i }}">
<div class="choice-result fa icon-2x" aria-label=""
data-label_correct="{% trans "Correct" %}" data-label_incorrect="{% trans "Incorrect" %}"></div>
<div class="choice-selector">
aria-describedby="feedback_{{ self.html_id }} choice_tips_{{ self.html_id }}-{{ i }}">
<span class="choice-result fa icon-2x" aria-label=""
data-label_correct="{% trans "Correct" %}" data-label_incorrect="{% trans "Incorrect" %}"></span>
<span class="choice-selector">
<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 %}
/>
</span>
<span class="choice-label-text">
{{i}}
{% if i == '1' %} - {{ self.low|safe }}{% endif %}
{% if i == '5' %} - {{ self.high|safe }}{% endif %}
</div>
</span>
</label>
<div class="choice-tips-container">
<div class="choice-tips" id="choice_tips_{{ self.html_id }}-{{ i }}"></div>
......@@ -29,15 +31,15 @@
{% for choice in custom_choices %}
<div class="choice" aria-live="polite" aria-atomic="true">
<label class="choice-label"
aria-describedby="feedback_{{ self.html_id }} choice_tips_{{ self.html_id }}-{{ forloop.counter }}">
<div class="choice-result fa icon-2x" aria-label=""
data-label_correct="{% trans "Correct" %}" data-label_incorrect="{% trans "Incorrect" %}"></div>
<div class="choice-selector">
aria-describedby="feedback_{{ self.html_id }} choice_tips_{{ self.html_id }}-{{ forloop.counter }}">
<span class="choice-result fa icon-2x" aria-label=""
data-label_correct="{% trans "Correct" %}" data-label_incorrect="{% trans "Incorrect" %}"></span>
<span class="choice-selector">
<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 %}
/>
</div>
{{ choice.content|safe }}
</span>
<span class="choice-label-text">{{ choice.content|safe }}</span>
</label>
<div class="choice-tips-container">
<div class="choice-tips" id="choice_tips_{{ self.html_id }}-{{ forloop.counter }}"></div>
......
......@@ -2,7 +2,7 @@
<div class="sb-review-score">
<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 %}
<p class="review-links-explanation">
{% trans "Click a question to review feedback on your response." %}
......
{% load i18n %}
<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">
<p><label>{{ question|safe }} <span class="sr">({{instructions_string}})</span>
<input type="range" id="{{ slider_id }}" class="pb-slider-range"
......
......@@ -2,13 +2,13 @@
data-next-button-label="{{ self.next_button_label }}" {% if self.has_question %} data-has-question="true" {% endif %}>
{% if show_title %}
<div class="title">
<h3>
<h4>
{% if title %}
{{ title }}
{% else %}
{{ self.display_name_with_default }}
{% endif %}
</h3>
</h4>
</div>
{% endif %}
......
......@@ -281,7 +281,7 @@ class MentoringAssessmentBaseTest(ProblemBuilderBaseTest):
self.wait_until_text_in(question_text, mentoring)
question_div = None
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:
question_div = xblock_div
self.assertTrue(xblock_div.is_displayed())
......
......@@ -48,12 +48,12 @@ class TitleTest(SeleniumXBlockTest):
self.set_scenario_xml(xml)
pb_element = self.go_to_view()
if expected_title is not None:
h2 = pb_element.find_element_by_css_selector('h2')
self.assertEqual(h2.text, expected_title)
h3 = pb_element.find_element_by_css_selector('h3')
self.assertEqual(h3.text, expected_title)
else:
# No <h2> element should be present:
all_h2s = pb_element.find_elements_by_css_selector('h2')
self.assertEqual(len(all_h2s), 0)
# No <h3> element should be present:
all_h3s = pb_element.find_elements_by_css_selector('h3')
self.assertEqual(len(all_h3s), 0)
class StepTitlesTest(SeleniumXBlockTest):
......@@ -145,9 +145,9 @@ class StepTitlesTest(SeleniumXBlockTest):
self.set_scenario_xml(xml)
pb_element = self.go_to_view()
if expected_title:
h3 = pb_element.find_element_by_css_selector('h3')
self.assertEqual(h3.text, expected_title)
h4 = pb_element.find_element_by_css_selector('h4')
self.assertEqual(h4.text, expected_title)
else:
# No <h3> element should be present:
all_h3s = pb_element.find_elements_by_css_selector('h3')
self.assertEqual(len(all_h3s), 0)
# No <h4> element should be present:
all_h4s = pb_element.find_elements_by_css_selector('h4')
self.assertEqual(len(all_h4s), 0)
......@@ -32,6 +32,8 @@
<pb-tip values='["notwant"]'>Your loss!</pb-tip>
</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="incomplete">Not done yet</pb-message>
</problem-builder>
......
"""
Tests temporary AnswerMixin code that helps migrate course_id column to course_key.
Unit tests for AnswerMixin.
"""
import json
import unittest
from collections import namedtuple
from datetime import datetime
from django.utils.crypto import get_random_string
from mock import patch
from problem_builder.answer import AnswerMixin
from problem_builder.models import Answer
......@@ -29,6 +31,8 @@ class TestAnswerMixin(unittest.TestCase):
answer_mixin = AnswerMixin()
answer_mixin.name = name
answer_mixin.runtime = self.FakeRuntime(course_id, student_id)
answer_mixin.fields = {}
answer_mixin.has_children = False
return answer_mixin
def test_creates_model_instance(self):
......@@ -37,7 +41,6 @@ class TestAnswerMixin(unittest.TestCase):
model = answer_mixin.get_model_object()
self.assertEqual(model.name, name)
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(Answer.objects.get(pk=model.pk), model)
......@@ -47,32 +50,63 @@ class TestAnswerMixin(unittest.TestCase):
name=name,
student_id=self.anonymous_student_id,
course_key=self.course_id,
course_id='ignored'
)
existing_model.save()
answer_mixin = self.make_answer_mixin(name=name)
model = answer_mixin.get_model_object()
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):
course_id = 'course-v1:VeryLongOrganizationName+VeryLongCourseNumber+VeryLongCourseRun'
self.assertTrue(len(course_id) > 50) # precondition check
answer_mixin = self.make_answer_mixin(course_id=course_id)
model = answer_mixin.get_model_object()
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
from xblock.field_data import DictFieldData
from problem_builder.mcq import MCQBlock
from problem_builder.mrq import MRQBlock
from problem_builder.mentoring import MentoringBlock, MentoringMessageBlock, _default_options_config
from .utils import BlockWithChildrenTestMixin
@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):
def test_sends_progress_event_when_rendered_student_view_with_display_submit_false(self):
block = MentoringBlock(MagicMock(), DictFieldData({
......@@ -109,6 +124,42 @@ class TestMentoringBlock(BlockWithChildrenTestMixin, unittest.TestCase):
(['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
class TestMentoringBlockTheming(unittest.TestCase):
......
......@@ -33,11 +33,7 @@ class Parent(StepParentMixin):
pass
class BaseClass(object):
pass
class Step(BaseClass, QuestionMixin):
class Step(QuestionMixin):
def __init__(self):
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
"""
import json
from datetime import datetime, date
from mock import MagicMock, Mock, patch
from xblock.field_data import DictFieldData
......@@ -87,3 +90,11 @@ def instantiate_block(cls, fields=None):
block.children = children
block.runtime.get_block = lambda child_id: children[child_id]
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">
<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>
</html>
<pb-answer-recap name="improvement-frog"/>
......
......@@ -7,7 +7,7 @@ This contains a typical problem taken from a live course (content changed)
<![CDATA[
<mentoring enforce_dependency="false" followed_by="past_attempts">
<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>
</html>
<answer name="improvement-frog" read_only="true"/>
......
......@@ -17,6 +17,7 @@ logging_level_overrides = {
'workbench.runtime': logging.ERROR,
}
def patch_broken_pipe_error():
"""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
......
......@@ -71,7 +71,11 @@ BLOCKS = [
setup(
name='xblock-problem-builder',
<<<<<<< HEAD
version='2.6.5patch1',
=======
version='2.7.2',
>>>>>>> master
description='XBlock - Problem Builder',
packages=['problem_builder', 'problem_builder.v1', 'problem_builder.management', 'problem_builder.management.commands'],
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