diff --git a/build/lib/submit_and_compare/__init__.py b/build/lib/submit_and_compare/__init__.py
new file mode 100644
index 0000000..ca7340b
--- /dev/null
+++ b/build/lib/submit_and_compare/__init__.py
@@ -0,0 +1,3 @@
+"""
+This is an XBlock for submit and compare
+"""
diff --git a/build/lib/submit_and_compare/mixins/__init__.py b/build/lib/submit_and_compare/mixins/__init__.py
new file mode 100644
index 0000000..ccb716c
--- /dev/null
+++ b/build/lib/submit_and_compare/mixins/__init__.py
@@ -0,0 +1,3 @@
+"""
+Mixin behavior to XBlocks
+"""
diff --git a/build/lib/submit_and_compare/mixins/dates.py b/build/lib/submit_and_compare/mixins/dates.py
new file mode 100644
index 0000000..b4afb24
--- /dev/null
+++ b/build/lib/submit_and_compare/mixins/dates.py
@@ -0,0 +1,34 @@
+"""
+Extend XBlocks with datetime helpers
+"""
+import datetime
+
+
+# pylint: disable=too-few-public-methods
+class EnforceDueDates:
+ """
+ xBlock Mixin to allow xblocks to check the due date
+ (taking the graceperiod into account) of the
+ subsection in which they are placed
+ """
+
+ def is_past_due(self):
+ """
+ Determine if component is past-due
+ """
+ # These values are pulled from platform.
+ # They are defaulted to None for tests.
+ due = getattr(self, 'due', None)
+ graceperiod = getattr(self, 'graceperiod', None)
+ # Calculate the current DateTime so we can compare the due date to it.
+ # datetime.utcnow() returns timezone naive date object.
+ now = datetime.datetime.utcnow()
+ if due is not None:
+ # Remove timezone information from platform provided due date.
+ # Dates are stored as UTC timezone aware objects on platform.
+ due = due.replace(tzinfo=None)
+ if graceperiod is not None:
+ # Compare the datetime objects (both have to be timezone naive)
+ due = due + graceperiod
+ return now > due
+ return False
diff --git a/build/lib/submit_and_compare/mixins/events.py b/build/lib/submit_and_compare/mixins/events.py
new file mode 100644
index 0000000..2abf7d8
--- /dev/null
+++ b/build/lib/submit_and_compare/mixins/events.py
@@ -0,0 +1,67 @@
+"""
+Provide event-related mixin functionality
+"""
+from xblock.core import XBlock
+
+
+class EventableMixin:
+ """
+ Mix in standard event logic
+ """
+
+ @XBlock.json_handler
+ def publish_event(self, data, *args, **kwargs):
+ """
+ Publish events
+ """
+ try:
+ event_type = data.pop('event_type')
+ except KeyError:
+ return {
+ 'result': 'error',
+ 'message': 'Missing event_type in JSON data',
+ }
+ data['user_id'] = self.scope_ids.user_id
+ data['component_id'] = self._get_unique_id()
+ self.runtime.publish(self, event_type, data)
+ result = {
+ 'result': 'success',
+ }
+ return result
+
+ def _get_unique_id(self):
+ """
+ Get a unique component identifier
+ """
+ try:
+ unique_id = self.location.name
+ except AttributeError:
+ # workaround for xblock workbench
+ unique_id = 'workbench-workaround-id'
+ return unique_id
+
+ def _publish_grade(self):
+ """
+ Publish a grade event
+ """
+ self.runtime.publish(
+ self,
+ 'grade',
+ {
+ 'value': self.score,
+ 'max_value': 1.0,
+ }
+ )
+
+ def _publish_problem_check(self):
+ """
+ Publish a problem_check event
+ """
+ self.runtime.publish(
+ self,
+ 'problem_check',
+ {
+ 'grade': self.score,
+ 'max_grade': 1.0,
+ }
+ )
diff --git a/build/lib/submit_and_compare/mixins/fragment.py b/build/lib/submit_and_compare/mixins/fragment.py
new file mode 100644
index 0000000..b3f47ce
--- /dev/null
+++ b/build/lib/submit_and_compare/mixins/fragment.py
@@ -0,0 +1,96 @@
+"""
+Mixin fragment/html behavior into XBlocks
+
+Note: We should resume test coverage for all lines in this file once
+split into its own library.
+"""
+from django.template.context import Context
+from xblock.core import XBlock
+from web_fragments.fragment import Fragment
+
+
+class XBlockFragmentBuilderMixin:
+ """
+ Create a default XBlock fragment builder
+ """
+ static_css = [
+ 'view.css',
+ ]
+ static_js = [
+ 'view.js',
+ ]
+ static_js_init = None
+ template = 'view.html'
+
+ def get_i18n_service(self):
+ """
+ Get the i18n service from the runtime
+ """
+ return self.runtime.service(self, 'i18n')
+
+ def provide_context(self, context): # pragma: no cover
+ """
+ Build a context dictionary to render the student view
+
+ This should generally be overriden by child classes.
+ """
+ context = context or {}
+ context = dict(context)
+ return context
+
+ @XBlock.supports('multi_device')
+ def student_view(self, context=None):
+ """
+ Build the fragment for the default student view
+ """
+ template = self.template
+ context = self.provide_context(context)
+ static_css = self.static_css or []
+ static_js = self.static_js or []
+ js_init = self.static_js_init
+ fragment = self.build_fragment(
+ template=template,
+ context=context,
+ css=static_css,
+ js=static_js,
+ js_init=js_init,
+ )
+ return fragment
+
+ def build_fragment(
+ self,
+ template='',
+ context=None,
+ css=None,
+ js=None,
+ js_init=None,
+ ):
+ """
+ Creates a fragment for display.
+ """
+ context = context or {}
+ css = css or []
+ js = js or []
+ rendered_template = ''
+ if template: # pragma: no cover
+ template = 'templates/' + template
+ rendered_template = self.loader.render_django_template(
+ template,
+ context=Context(context),
+ i18n_service=self.get_i18n_service(),
+ )
+ fragment = Fragment(rendered_template)
+ for item in css:
+ if item.startswith('/'):
+ url = item
+ else:
+ item = 'public/' + item
+ url = self.runtime.local_resource_url(self, item)
+ fragment.add_css_url(url)
+ for item in js:
+ item = 'public/' + item
+ url = self.runtime.local_resource_url(self, item)
+ fragment.add_javascript_url(url)
+ if js_init: # pragma: no cover
+ fragment.initialize_js(js_init)
+ return fragment
diff --git a/build/lib/submit_and_compare/mixins/scenario.py b/build/lib/submit_and_compare/mixins/scenario.py
new file mode 100644
index 0000000..a28d2b8
--- /dev/null
+++ b/build/lib/submit_and_compare/mixins/scenario.py
@@ -0,0 +1,72 @@
+"""
+Mixin workbench behavior into XBlocks
+"""
+from glob import glob
+import pkg_resources
+
+
+def _read_file(file_path):
+ """
+ Read in a file's contents
+ """
+ with open(file_path, encoding="utf-8") as file_input:
+ file_contents = file_input.read()
+ return file_contents
+
+
+def _parse_title(file_path):
+ """
+ Parse a title from a file name
+ """
+ title = file_path
+ title = title.split('/')[-1]
+ title = '.'.join(title.split('.')[:-1])
+ title = ' '.join(title.split('-'))
+ title = ' '.join([
+ word.capitalize()
+ for word in title.split(' ')
+ ])
+ return title
+
+
+def _read_files(files):
+ """
+ Read the contents of a list of files
+ """
+ file_contents = [
+ (
+ _parse_title(file_path),
+ _read_file(file_path),
+ )
+ for file_path in files
+ ]
+ return file_contents
+
+
+def _find_files(directory):
+ """
+ Find XML files in the directory
+ """
+ pattern = "{directory}/*.xml".format(
+ directory=directory,
+ )
+ files = glob(pattern)
+ return files
+
+
+class XBlockWorkbenchMixin:
+ """
+ Provide a default test workbench for the XBlock
+ """
+
+ @classmethod
+ def workbench_scenarios(cls):
+ """
+ Gather scenarios to be displayed in the workbench
+ """
+ module = cls.__module__
+ module = module.split('.', maxsplit=1)[0]
+ directory = pkg_resources.resource_filename(module, 'scenarios')
+ files = _find_files(directory)
+ scenarios = _read_files(files)
+ return scenarios
diff --git a/build/lib/submit_and_compare/models.py b/build/lib/submit_and_compare/models.py
new file mode 100644
index 0000000..7935b47
--- /dev/null
+++ b/build/lib/submit_and_compare/models.py
@@ -0,0 +1,118 @@
+"""
+Handle data access logic for the XBlock
+"""
+import textwrap
+
+from xblock.fields import Float
+from xblock.fields import Integer
+from xblock.fields import List
+from xblock.fields import Scope
+from xblock.fields import String
+
+
+class SubmitAndCompareModelMixin:
+ """
+ Handle data access logic for the XBlock
+ """
+
+ has_score = True
+ display_name = String(
+ display_name='Display Name',
+ default='Submit and Compare',
+ scope=Scope.settings,
+ help=(
+ 'This name appears in the horizontal'
+ ' navigation at the top of the page'
+ ),
+ )
+ student_answer = String(
+ default='',
+ scope=Scope.user_state,
+ help='This is the student\'s answer to the question',
+ )
+ max_attempts = Integer(
+ default=0,
+ scope=Scope.settings,
+ )
+ count_attempts = Integer(
+ default=0,
+ scope=Scope.user_state,
+ )
+ your_answer_label = String(
+ default='Your Answer:',
+ scope=Scope.settings,
+ help='Label for the text area containing the student\'s answer',
+ )
+ our_answer_label = String(
+ default='Our Answer:',
+ scope=Scope.settings,
+ help='Label for the \'expert\' answer',
+ )
+ submit_button_label = String(
+ default='Submit and Compare',
+ scope=Scope.settings,
+ help='Label for the submit button',
+ )
+ hints = List(
+ default=[],
+ scope=Scope.content,
+ help='Hints for the question',
+ )
+ question_string = String(
+ help='Default question content ',
+ scope=Scope.content,
+ multiline_editor=True,
+ default=textwrap.dedent("""
+
+
+
+ Before you begin the simulation,
+ think for a minute about your hypothesis.
+ What do you expect the outcome of the simulation
+ will be? What data do you need to gather in order
+ to prove or disprove your hypothesis?
+
+
+
+
+ We would expect the simulation to show that
+ there is no difference between the two scenarios.
+ Relevant data to gather would include time and
+ temperature.
+
+
+
+
+ A hypothesis is a proposed explanation for a
+ phenomenon. In this case, the hypothesis is what
+ we think the simulation will show.
+
+
+ Once you've decided on your hypothesis, which data
+ would help you determine if that hypothesis is
+ correct or incorrect?
+
+
+
+ """))
+ score = Float(
+ default=0.0,
+ scope=Scope.user_state,
+ )
+ weight = Integer(
+ display_name='Weight',
+ help='This assigns an integer value representing '
+ 'the weight of this problem',
+ default=0,
+ scope=Scope.settings,
+ )
+
+ def max_score(self):
+ """
+ Returns the configured number of possible points for this component.
+ Arguments:
+ None
+ Returns:
+ float: The number of possible points for this component
+ """
+ return self.weight
diff --git a/build/lib/submit_and_compare/public/edit.js b/build/lib/submit_and_compare/public/edit.js
new file mode 100644
index 0000000..2a22067
--- /dev/null
+++ b/build/lib/submit_and_compare/public/edit.js
@@ -0,0 +1,36 @@
+/* Javascript for Submit and Compare XBlock. */
+function SubmitAndCompareXBlockInitEdit(runtime, element) {
+
+ var xmlEditorTextarea = $('.block-xml-editor', element),
+ xmlEditor = CodeMirror.fromTextArea(xmlEditorTextarea[0], { mode: 'xml', lineWrapping: true });
+
+ $(element).find('.action-cancel').bind('click', function() {
+ runtime.notify('cancel', {});
+ });
+
+ $(element).find('.action-save').bind('click', function() {
+ var data = {
+ 'display_name': $('#submit_and_compare_edit_display_name').val(),
+ 'weight': $('#submit_and_compare_edit_weight').val(),
+ 'max_attempts': $('#submit_and_compare_edit_max_attempts').val(),
+ 'your_answer_label': $('#submit_and_compare_edit_your_answer_label').val(),
+ 'our_answer_label': $('#submit_and_compare_edit_our_answer_label').val(),
+ 'submit_button_label': $('#submit_and_compare_edit_submit_button_label').val(),
+ 'data': xmlEditor.getValue(),
+ };
+
+ runtime.notify('save', {state: 'start'});
+
+ var handlerUrl = runtime.handlerUrl(element, 'studio_submit');
+ $.post(handlerUrl, JSON.stringify(data)).done(function(response) {
+ if (response.result === 'success') {
+ runtime.notify('save', {state: 'end'});
+ //Reload the page
+ //window.location.reload(false);
+ } else {
+ runtime.notify('error', {msg: response.message})
+ }
+ });
+ });
+}
+
diff --git a/build/lib/submit_and_compare/public/view.css b/build/lib/submit_and_compare/public/view.css
new file mode 100644
index 0000000..ce77d0d
--- /dev/null
+++ b/build/lib/submit_and_compare/public/view.css
@@ -0,0 +1,65 @@
+.submit_and_compare .question_prompt {
+ text-align: left;
+}
+.submit_and_compare .student_answer {
+ color: #3399cc;
+ font-weight: 600;
+ padding-bottom: 20px;
+ padding-top: 20px;
+}
+.submit_and_compare .answer {
+ border: 3px solid #cccccc;
+ height: 120px;
+ max-width: 100%;
+ padding: 5px;
+ width: 100%;
+}
+.submit_and_compare .expert_answer {
+ padding-bottom: 20px;
+ text-align: left;
+}
+.submit_and_compare .our_answer {
+ color: #009900;
+ font-weight: 600;
+ text-align: left;
+}
+.submit_and_compare .hint {
+ padding-bottom: 20px;
+ text-align: left;
+}
+.submit_and_compare .reset_button {
+ font-weight: 600;
+ height: 40px;
+ vertical-align: middle;
+}
+.submit_and_compare .hint_button {
+ font-weight: 600;
+ height: 40px;
+ vertical-align: middle;
+}
+.submit_and_compare .submit_button {
+ font-weight: 600;
+ height: 40px;
+ vertical-align: middle;
+}
+.submit_and_compare .problem_progress {
+ color: #666;
+ display: inline-block;
+ font-size: 1em;
+ font-weight: 100;
+ padding-left: 5px;
+}
+.submit_and_compare .problem_header {
+ display: inline-block;
+}
+.submit_and_compare .used_attempts_feedback {
+ color: #666;
+ font-style: italic;
+ margin-top: 8px;
+}
+.submit_and_compare .nodisplay {
+ display: none;
+}
+.submit_and_compare .inline {
+ display: inline;
+}
diff --git a/build/lib/submit_and_compare/public/view.js b/build/lib/submit_and_compare/public/view.js
new file mode 100644
index 0000000..4babad2
--- /dev/null
+++ b/build/lib/submit_and_compare/public/view.js
@@ -0,0 +1,161 @@
+/* Javascript for submitcompareXBlock. */
+/* eslint-disable no-unused-vars */
+/* eslint-disable require-jsdoc */
+/**
+ * Initialize the student view
+ * @param {Object} runtime - The XBlock JS Runtime
+ * @param {Object} element - The containing DOM element for this instance of the XBlock
+ * @returns {undefined} nothing
+ */
+function SubmitAndCompareXBlockInitView(runtime, element) {
+ 'use strict';
+ /* eslint-disable camelcase */
+ /* eslint-enable no-unused-vars */
+
+ var $ = window.jQuery;
+ var handlerUrl = runtime.handlerUrl(element, 'student_submit');
+ var hintUrl = runtime.handlerUrl(element, 'send_hints');
+ var publishUrl = runtime.handlerUrl(element, 'publish_event');
+ var $element = $(element);
+ var $xblocksContainer = $('#seq_content');
+ var submit_button = $element.find('.submit_button');
+ var hint_button = $element.find('hint_button');
+ var reset_button = $element.find('.reset_button');
+ var problem_progress = $element.find('.problem_progress');
+ var used_attempts_feedback = $element.find('.used_attempts_feedback');
+ var button_holder = $element.find('.button_holder');
+ var answer_textarea = $element.find('.answer');
+ var your_answer = $element.find('.your_answer');
+ var expert_answer = $element.find('.expert_answer');
+ var hint_div = $element.find('.hint');
+ var hint_button_holder = $element.find('.hint_button_holder');
+ var submit_button_label = $element.find('.submit_button').attr('value');
+ var hint;
+ var hints;
+ var hint_counter = 0;
+ var xblock_id = $element.attr('data-usage-id');
+ var cached_answer_id = xblock_id + '_cached_answer';
+ var problem_progress_id = xblock_id + '_problem_progress';
+ var used_attempts_feedback_id = xblock_id + '_used_attempts_feedback';
+ if (typeof $xblocksContainer.data(cached_answer_id) !== 'undefined') {
+ answer_textarea.text($xblocksContainer.data(cached_answer_id));
+ problem_progress.text($xblocksContainer.data(problem_progress_id));
+ used_attempts_feedback.text($xblocksContainer.data(used_attempts_feedback_id));
+ }
+
+ /**
+ * Parse and display hints
+ * @param {Object} result - The result payload
+ * @returns {undefined} nothing
+ */
+ function set_hints(result) {
+ hints = result.hints;
+ if (hints.length > 0) {
+ hint_button.css('display', 'inline');
+ hint_button_holder.css('display', 'inline');
+ }
+ }
+
+ $.ajax({
+ type: 'POST',
+ url: hintUrl,
+ data: JSON.stringify({ requested: true, }),
+ success: set_hints,
+ });
+
+ function publish_event(data) {
+ $.ajax({
+ type: 'POST',
+ url: publishUrl,
+ data: JSON.stringify(data),
+ });
+ }
+
+ function pre_submit() {
+ problem_progress.text('(Loading...)');
+ }
+
+ function post_submit(result) {
+ $xblocksContainer.data(cached_answer_id, $('.answer', element).val());
+ $xblocksContainer.data(problem_progress_id, result.problem_progress);
+ $xblocksContainer.data(used_attempts_feedback_id, result.used_attempts_feedback);
+ problem_progress.text(result.problem_progress);
+ button_holder.addClass(result.submit_class);
+ used_attempts_feedback.text(result.used_attempts_feedback);
+ }
+
+ function show_answer() {
+ your_answer.css('display', 'block');
+ expert_answer.css('display', 'block');
+ submit_button.val('Resubmit');
+
+ }
+
+ function reset_answer() {
+ your_answer.css('display', 'none');
+ expert_answer.css('display', 'none');
+ submit_button.val(submit_button_label);
+ }
+
+ function reset_hint() {
+ hint_counter = 0;
+ hint_div.css('display', 'none');
+ }
+
+ function show_hint() {
+ hint = hints[hint_counter];
+ hint_div.html(hint);
+ hint_div.css('display', 'block');
+ publish_event({
+ event_type: 'hint_button',
+ next_hint_index: hint_counter,
+ });
+ if (hint_counter === hints.length - 1) {
+ hint_counter = 0;
+ } else {
+ hint_counter++;
+ }
+ }
+
+ $('.submit_button', element).click(function () {
+ pre_submit();
+ $.ajax({
+ type: 'POST',
+ url: handlerUrl,
+ data: JSON.stringify(
+ {
+ answer: $('.answer', element).val(),
+ action: 'submit',
+ }
+ ),
+ success: post_submit,
+ });
+ show_answer();
+ });
+
+ reset_button.click(function () {
+ $('.answer', element).val('');
+ $.ajax({
+ type: 'POST',
+ url: handlerUrl,
+ data: JSON.stringify(
+ {
+ answer: '',
+ action: 'reset',
+ }
+ ),
+ success: post_submit,
+ });
+ reset_answer();
+ reset_hint();
+ });
+
+ $('.hint_button', element).click(function () {
+ show_hint();
+ });
+
+ if ($('.answer', element).val() !== '') {
+ show_answer();
+ }
+ /* eslint-enable camelcase */
+}
diff --git a/build/lib/submit_and_compare/public/view.less b/build/lib/submit_and_compare/public/view.less
new file mode 100644
index 0000000..ed401fa
--- /dev/null
+++ b/build/lib/submit_and_compare/public/view.less
@@ -0,0 +1,67 @@
+.submit_and_compare {
+ .question_prompt {
+ text-align: left;
+ }
+ .student_answer {
+ color: #3399cc;
+ font-weight: 600;
+ padding-bottom: 20px;
+ padding-top: 20px;
+ }
+ .answer {
+ border: 3px solid #cccccc;
+ height: 120px;
+ max-width: 100%;
+ padding: 5px;
+ width: 100%;
+ }
+ .expert_answer {
+ padding-bottom: 20px;
+ text-align: left;
+ }
+ .our_answer {
+ color: #009900;
+ font-weight: 600;
+ text-align: left;
+ }
+ .hint {
+ padding-bottom: 20px;
+ text-align: left;
+ }
+ .reset_button {
+ font-weight: 600;
+ height: 40px;
+ vertical-align: middle;
+ }
+ .hint_button {
+ font-weight: 600;
+ height: 40px;
+ vertical-align: middle;
+ }
+ .submit_button {
+ font-weight: 600;
+ height: 40px;
+ vertical-align: middle;
+ }
+ .problem_progress {
+ color: #666;
+ display: inline-block;
+ font-size: 1em;
+ font-weight: 100;
+ padding-left: 5px;
+ }
+ .problem_header {
+ display: inline-block;
+ }
+ .used_attempts_feedback {
+ color: #666;
+ font-style: italic;
+ margin-top: 8px;
+ }
+ .nodisplay {
+ display: none;
+ }
+ .inline {
+ display: inline;
+ }
+}
diff --git a/build/lib/submit_and_compare/scenarios/submit-and-compare-single.xml b/build/lib/submit_and_compare/scenarios/submit-and-compare-single.xml
new file mode 100644
index 0000000..34ce0fe
--- /dev/null
+++ b/build/lib/submit_and_compare/scenarios/submit-and-compare-single.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/build/lib/submit_and_compare/settings.py b/build/lib/submit_and_compare/settings.py
new file mode 100644
index 0000000..a48cc2d
--- /dev/null
+++ b/build/lib/submit_and_compare/settings.py
@@ -0,0 +1,16 @@
+"""
+Settings for submit_and_compare xblock
+"""
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ # 'NAME': 'intentionally-omitted',
+ },
+}
+LOCALE_PATHS = [
+ 'submit_and_compare/translations',
+]
+SECRET_KEY = 'submit_and_compare_SECRET_KEY'
+
+DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
diff --git a/build/lib/submit_and_compare/templates/edit.html b/build/lib/submit_and_compare/templates/edit.html
new file mode 100644
index 0000000..4e11ba2
--- /dev/null
+++ b/build/lib/submit_and_compare/templates/edit.html
@@ -0,0 +1,78 @@
+{% load i18n %}
+
+
+
+
diff --git a/build/lib/submit_and_compare/templates/view.html b/build/lib/submit_and_compare/templates/view.html
new file mode 100644
index 0000000..7c31be8
--- /dev/null
+++ b/build/lib/submit_and_compare/templates/view.html
@@ -0,0 +1,37 @@
+
+
+
{{ problem_progress }}
+
{{ prompt }}
+
+
{{ your_answer_label }}
+
+
+
+
{{ our_answer_label }}
+
{{ explanation }}
+
+
{{hint}}
+
+
{{ used_attempts_feedback }}
+
diff --git a/build/lib/submit_and_compare/tests/__init__.py b/build/lib/submit_and_compare/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/build/lib/submit_and_compare/tests/test_all.py b/build/lib/submit_and_compare/tests/test_all.py
new file mode 100644
index 0000000..bd3ae5b
--- /dev/null
+++ b/build/lib/submit_and_compare/tests/test_all.py
@@ -0,0 +1,207 @@
+"""
+Tests for xblock-submit-and-compare
+"""
+import re
+import unittest
+from xml.sax.saxutils import escape
+
+from unittest import mock
+from django.test.client import Client
+from opaque_keys.edx.locations import SlashSeparatedCourseKey
+from xblock.field_data import DictFieldData
+
+from ..xblocks import SubmitAndCompareXBlock
+from ..views import get_body
+
+
+class SubmitAndCompareXblockTestCase(unittest.TestCase):
+ # pylint: disable=too-many-instance-attributes, too-many-public-methods
+ """
+ A complete suite of unit tests for the Submit-and-compare XBlock
+ """
+ @classmethod
+ def make_an_xblock(cls, **kw):
+ """
+ Helper method that creates a Free-text Response XBlock
+ """
+ course_id = SlashSeparatedCourseKey('foo', 'bar', 'baz')
+ runtime = mock.Mock(course_id=course_id)
+ scope_ids = mock.Mock()
+ field_data = DictFieldData(kw)
+ xblock = SubmitAndCompareXBlock(runtime, field_data, scope_ids)
+ xblock.xmodule_runtime = runtime
+ return xblock
+
+ def setUp(self):
+ self.xblock = SubmitAndCompareXblockTestCase.make_an_xblock()
+ self.client = Client()
+
+ def test_student_view(self):
+ # pylint: disable=protected-access
+ """
+ Checks the student view for student specific instance variables.
+ """
+ student_view_html = self.student_view_html()
+ self.assertIn(self.xblock.display_name, student_view_html)
+ self.assertIn(
+ get_body(self.xblock.question_string),
+ student_view_html,
+ )
+ self.assertIn(self.xblock._get_problem_progress(), student_view_html)
+
+ def test_studio_view(self):
+ """
+ Checks studio view for instance variables specified by the instructor.
+ """
+ with mock.patch(
+ "submit_and_compare.mixins.fragment.XBlockFragmentBuilderMixin.get_i18n_service",
+ return_value=None
+ ):
+ studio_view_html = self.studio_view_html()
+ self.assertIn(self.xblock.display_name, studio_view_html)
+ xblock_body = get_body(
+ self.xblock.question_string
+ )
+ studio_view_html = re.sub(r'\W+', ' ', studio_view_html.strip())
+ xblock_body = re.sub(r'\W+', ' ', xblock_body.strip())
+ self.assertIn(
+ escape(xblock_body),
+ studio_view_html,
+ )
+ self.assertIn(str(self.xblock.max_attempts), studio_view_html)
+
+ def test_initialization_variables(self):
+ """
+ Checks that all instance variables are initialized correctly
+ """
+ self.assertEqual('Submit and Compare', self.xblock.display_name)
+ self.assertIn(
+ 'Before you begin the simulation',
+ self.xblock.question_string,
+ )
+ self.assertEqual(0.0, self.xblock.score)
+ self.assertEqual(0, self.xblock.max_attempts)
+ self.assertEqual('', self.xblock.student_answer)
+ self.assertEqual(0, self.xblock.count_attempts)
+
+ def student_view_html(self):
+ """
+ Helper method that returns the html of student_view
+ """
+ return self.xblock.student_view().content
+
+ def studio_view_html(self):
+ """
+ Helper method that returns the html of studio_view
+ """
+ return self.xblock.studio_view().content
+
+ def test_problem_progress_weight_zero(self):
+ # pylint: disable=invalid-name, protected-access
+ """
+ Tests that the the string returned by get_problem_progress
+ is blank when the weight of the problem is zero
+ """
+ self.xblock.score = 1
+ self.xblock.weight = 0
+ self.assertEqual('', self.xblock._get_problem_progress())
+
+ def test_problem_progress_score_zero_weight_singular(self):
+ # pylint: disable=invalid-name, protected-access
+ """
+ Tests that the the string returned by get_problem_progress
+ when the weight of the problem is singular, and the score is zero
+ """
+ self.xblock.score = 0
+ self.xblock.weight = 1
+ self.assertEqual(
+ '(1 point possible)',
+ self.xblock._get_problem_progress(),
+ )
+
+ def test_problem_progress_score_zero_weight_plural(self):
+ # pylint: disable=invalid-name, protected-access
+ """
+ Tests that the the string returned by get_problem_progress
+ when the weight of the problem is plural, and the score is zero
+ """
+ self.xblock.score = 0
+ self.xblock.weight = 3
+ self.assertEqual(
+ '(3 points possible)',
+ self.xblock._get_problem_progress(),
+ )
+
+ def test_problem_progress_score_positive_weight_singular(self):
+ # pylint: disable=invalid-name, protected-access
+ """
+ Tests that the the string returned by get_problem_progress
+ when the weight of the problem is singular, and the score is positive
+ """
+ self.xblock.score = 1
+ self.xblock.weight = 1
+ self.assertEqual(
+ '(1/1 point)',
+ self.xblock._get_problem_progress(),
+ )
+
+ def test_problem_progress_score_positive_weight_plural(self):
+ # pylint: disable=invalid-name, protected-access
+ """
+ Tests that the the string returned by get_problem_progress
+ when the weight of the problem is plural, and the score is positive
+ """
+ self.xblock.score = 1
+ self.xblock.weight = 3
+ self.assertEqual(
+ '(3/3 points)',
+ self.xblock._get_problem_progress(),
+ )
+
+ def test_used_attempts_feedback_blank(self):
+ # pylint: disable=invalid-name, protected-access
+ """
+ Tests that get_used_attempts_feedback returns no feedback when
+ appropriate
+ """
+ self.xblock.max_attempts = 0
+ self.assertEqual('', self.xblock._get_used_attempts_feedback())
+
+ def test_used_attempts_feedback_normal(self):
+ # pylint: disable=invalid-name, protected-access
+ """
+ Tests that get_used_attempts_feedback returns the expected feedback
+ """
+ self.xblock.max_attempts = 5
+ self.xblock.count_attempts = 3
+ self.assertEqual(
+ 'You have used 3 of 5 submissions',
+ self.xblock._get_used_attempts_feedback(),
+ )
+
+ def test_submit_class_blank(self):
+ # pylint: disable=protected-access
+ """
+ Tests that get_submit_class returns a blank value when appropriate
+ """
+ self.xblock.max_attempts = 0
+ self.assertEqual('', self.xblock._get_submit_class())
+
+ def test_submit_class_nodisplay(self):
+ # pylint: disable=protected-access
+ """
+ Tests that get_submit_class returns the appropriate class
+ when the number of attempts has exceeded the maximum number of
+ permissable attempts
+ """
+ self.xblock.max_attempts = 5
+ self.xblock.count_attempts = 6
+ self.assertEqual('nodisplay', self.xblock._get_submit_class())
+
+ def test_max_score(self):
+ """
+ Tests max_score function
+ Should return the weight
+ """
+ self.xblock.weight = 4
+ self.assertEqual(self.xblock.weight, self.xblock.max_score())
diff --git a/build/lib/submit_and_compare/views.py b/build/lib/submit_and_compare/views.py
new file mode 100644
index 0000000..7025dc9
--- /dev/null
+++ b/build/lib/submit_and_compare/views.py
@@ -0,0 +1,303 @@
+"""
+Handle view logic for the XBlock
+"""
+import logging
+
+from django.utils.translation import ngettext
+from django.utils.translation import gettext as _
+from lxml import etree
+from six import StringIO
+from xblock.core import XBlock
+from xblockutils.resources import ResourceLoader
+
+from .mixins.fragment import XBlockFragmentBuilderMixin
+
+
+LOG = logging.getLogger(__name__)
+
+
+def _convert_to_int(value_string):
+ """
+ Convert a string to integer
+
+ Default to 0
+ """
+ try:
+ value = int(value_string)
+ except ValueError:
+ value = 0
+ return value
+
+
+def get_body(xmlstring):
+ """
+ Helper method
+ """
+ # pylint: disable=no-member
+ tree = etree.parse(StringIO(xmlstring))
+ body = tree.xpath('/submit_and_compare/body')
+ body_string = etree.tostring(body[0], method='text', encoding='unicode')
+ return body_string
+
+
+def _get_explanation(xmlstring):
+ # pylint: disable=no-member
+ """
+ Helper method
+ """
+ tree = etree.parse(StringIO(xmlstring))
+ explanation = tree.xpath('/submit_and_compare/explanation')
+ explanation_string = etree.tostring(
+ explanation[0],
+ method='text',
+ encoding='unicode',
+ )
+ return explanation_string
+
+
+class SubmitAndCompareViewMixin(
+ XBlockFragmentBuilderMixin,
+):
+ """
+ Handle view logic for Image Modal XBlock instances
+ """
+
+ loader = ResourceLoader(__name__)
+ static_js_init = 'SubmitAndCompareXBlockInitView'
+ icon_class = 'problem'
+ editable_fields = [
+ 'display_name',
+ 'weight',
+ 'max_attempts',
+ 'your_answer_label',
+ 'our_answer_label',
+ 'submit_button_label',
+ 'question_string',
+ ]
+ show_in_read_only_mode = True
+
+ def provide_context(self, context=None):
+ """
+ Build a context dictionary to render the student view
+ """
+ context = context or {}
+ context = dict(context)
+ problem_progress = self._get_problem_progress()
+ used_attempts_feedback = self._get_used_attempts_feedback()
+ submit_class = self._get_submit_class()
+ prompt = get_body(self.question_string)
+ explanation = _get_explanation(
+ self.question_string
+ )
+ attributes = ''
+ context.update({
+ 'display_name': self.display_name,
+ 'problem_progress': problem_progress,
+ 'used_attempts_feedback': used_attempts_feedback,
+ 'submit_class': submit_class,
+ 'prompt': prompt,
+ 'student_answer': self.student_answer,
+ 'explanation': explanation,
+ 'your_answer_label': self.your_answer_label,
+ 'our_answer_label': self.our_answer_label,
+ 'submit_button_label': self.submit_button_label,
+ 'attributes': attributes,
+ 'is_past_due': self.is_past_due(),
+ })
+ return context
+
+ def studio_view(self, context=None):
+ """
+ Build the fragment for the edit/studio view
+
+ Implementation is optional.
+ """
+ context = context or {}
+ context.update({
+ 'display_name': self.display_name,
+ 'weight': self.weight,
+ 'max_attempts': self.max_attempts,
+ 'xml_data': self.question_string,
+ 'your_answer_label': self.your_answer_label,
+ 'our_answer_label': self.our_answer_label,
+ 'submit_button_label': self.submit_button_label,
+ })
+ template = 'edit.html'
+ fragment = self.build_fragment(
+ template=template,
+ context=context,
+ js_init='SubmitAndCompareXBlockInitEdit',
+ css=[
+ 'edit.css',
+ ],
+ js=[
+ 'edit.js',
+ ],
+ )
+ return fragment
+
+ @XBlock.json_handler
+ def studio_submit(self, data, *args, **kwargs):
+ """
+ Save studio edits
+ """
+ # pylint: disable=unused-argument
+ self.display_name = data['display_name']
+ self.weight = _convert_to_int(data['weight'])
+ max_attempts = _convert_to_int(data['max_attempts'])
+ if max_attempts >= 0:
+ self.max_attempts = max_attempts
+ self.your_answer_label = data['your_answer_label']
+ self.our_answer_label = data['our_answer_label']
+ self.submit_button_label = data['submit_button_label']
+ xml_content = data['data']
+ # pylint: disable=no-member
+ try:
+ etree.parse(StringIO(xml_content))
+ self.question_string = xml_content
+ except etree.XMLSyntaxError as error:
+ return {
+ 'result': 'error',
+ 'message': error.message,
+ }
+
+ return {
+ 'result': 'success',
+ }
+
+ @XBlock.json_handler
+ def student_submit(self, data, *args, **kwargs):
+ """
+ Save student answer
+ """
+ # pylint: disable=unused-argument
+ # when max_attempts == 0, the user can make unlimited attempts
+ success = False
+ # pylint: disable=no-member
+ if self.max_attempts > 0 and self.count_attempts >= self.max_attempts:
+ # pylint: enable=no-member
+ LOG.error(
+ 'User has already exceeded the maximum '
+ 'number of allowed attempts',
+ )
+ elif self.is_past_due():
+ LOG.debug(
+ 'This problem is past due',
+ )
+ else:
+ self.student_answer = data['answer']
+ if data['action'] == 'submit':
+ self.count_attempts += 1 # pylint: disable=no-member
+ if self.student_answer:
+ self.score = 1.0
+ else:
+ self.score = 0.0
+ self._publish_grade()
+ self._publish_problem_check()
+ success = True
+ result = {
+ 'success': success,
+ 'problem_progress': self._get_problem_progress(),
+ 'submit_class': self._get_submit_class(),
+ 'used_attempts_feedback': self._get_used_attempts_feedback(),
+ }
+ return result
+
+ @XBlock.json_handler
+ def send_hints(self, data, *args, **kwargs):
+ """
+ Build hints once for user
+ This is called once on page load and
+ js loop through hints on button click
+ """
+ # pylint: disable=unused-argument
+ # pylint: disable=no-member
+ tree = etree.parse(StringIO(self.question_string))
+ raw_hints = tree.xpath('/submit_and_compare/demandhint/hint')
+ decorated_hints = []
+ total_hints = len(raw_hints)
+ for i, raw_hint in enumerate(raw_hints, 1):
+ hint = _('Hint ({number} of {total}): {hint}').format(
+ number=i,
+ total=total_hints,
+ hint=etree.tostring(raw_hint, encoding='unicode'),
+ )
+ decorated_hints.append(hint)
+ hints = decorated_hints
+ return {
+ 'result': 'success',
+ 'hints': hints,
+ }
+
+ def _get_used_attempts_feedback(self):
+ """
+ Returns the text with feedback to the user about the number of attempts
+ they have used if applicable
+ """
+ result = ''
+ if self.max_attempts > 0:
+ # pylint: disable=no-member
+ result = ngettext(
+ 'You have used {count_attempts} of {max_attempts} submission',
+ 'You have used {count_attempts} of {max_attempts} submissions',
+ self.max_attempts,
+ ).format(
+ count_attempts=self.count_attempts,
+ max_attempts=self.max_attempts,
+ )
+ # pylint: enable=no-member
+ return result
+
+ def _can_submit(self):
+ """
+ Determine if a user can submit a response
+ """
+ if self.is_past_due():
+ return False
+ if self.max_attempts == 0:
+ return True
+ # pylint: disable=no-member
+ if self.count_attempts < self.max_attempts:
+ return True
+ # pylint: enable=no-member
+ return False
+
+ def _get_submit_class(self):
+ """
+ Returns the css class for the submit button
+ """
+ result = ''
+ if not self._can_submit():
+ result = 'nodisplay'
+ return result
+
+ def _get_problem_progress(self):
+ """
+ Returns a statement of progress for the XBlock, which depends
+ on the user's current score
+ """
+ if self.weight == 0:
+ result = ''
+ elif self.score == 0.0:
+ result = "({})".format(
+ ngettext(
+ '{weight} point possible',
+ '{weight} points possible',
+ self.weight,
+ ).format(
+ weight=self.weight,
+ )
+ )
+ else:
+ scaled_score = self.score * self.weight
+ score_string = f'{scaled_score:g}'
+ result = "({})".format(
+ ngettext(
+ score_string + '/' + "{weight} point",
+ score_string + '/' + "{weight} points",
+ self.weight,
+ ).format(
+ weight=self.weight,
+ )
+ )
+ return result
diff --git a/build/lib/submit_and_compare/xblocks.py b/build/lib/submit_and_compare/xblocks.py
new file mode 100644
index 0000000..f21b0f9
--- /dev/null
+++ b/build/lib/submit_and_compare/xblocks.py
@@ -0,0 +1,24 @@
+"""
+This is the core logic for the XBlock
+"""
+from xblock.core import XBlock
+
+from .mixins.dates import EnforceDueDates
+from .mixins.events import EventableMixin
+from .mixins.scenario import XBlockWorkbenchMixin
+from .models import SubmitAndCompareModelMixin
+from .views import SubmitAndCompareViewMixin
+
+
+@XBlock.needs('i18n')
+class SubmitAndCompareXBlock(
+ EnforceDueDates,
+ EventableMixin,
+ SubmitAndCompareModelMixin,
+ SubmitAndCompareViewMixin,
+ XBlockWorkbenchMixin,
+ XBlock,
+):
+ """
+ A Submit-And-Compare XBlock
+ """
diff --git a/setup.py b/setup.py
index 610a2dc..67fba92 100644
--- a/setup.py
+++ b/setup.py
@@ -24,20 +24,48 @@ def load_requirements(*requirements_paths):
"""
# UPDATED VIA SEMGREP - if you need to remove/modify this method remove this line and add a comment specifying why.
+ # e.g. {"django": "Django", "confluent-kafka": "confluent_kafka[avro]"}
+ by_canonical_name = {}
+
+ def check_name_consistent(package):
+ """
+ Raise exception if package is named different ways.
+
+ This ensures that packages are named consistently so we can match
+ constraints to packages. It also ensures that if we require a package
+ with extras we don't constrain it without mentioning the extras (since
+ that too would interfere with matching constraints.)
+ """
+ canonical = package.lower().replace('_', '-').split('[')[0]
+ seen_spelling = by_canonical_name.get(canonical)
+ if seen_spelling is None:
+ by_canonical_name[canonical] = package
+ elif seen_spelling != package:
+ raise Exception(
+ f'Encountered both "{seen_spelling}" and "{package}" in requirements '
+ 'and constraints files; please use just one or the other.'
+ )
+
requirements = {}
constraint_files = set()
- # groups "my-package-name<=x.y.z,..." into ("my-package-name", "<=x.y.z,...")
- requirement_line_regex = re.compile(r"([a-zA-Z0-9-_.]+)([<>=][^#\s]+)?")
+ # groups "pkg<=x.y.z,..." into ("pkg", "<=x.y.z,...")
+ re_package_name_base_chars = r"a-zA-Z0-9\-_." # chars allowed in base package name
+ # Two groups: name[maybe,extras], and optionally a constraint
+ requirement_line_regex = re.compile(
+ r"([%s]+(?:\[[%s,\s]+\])?)([<>=][^#\s]+)?"
+ % (re_package_name_base_chars, re_package_name_base_chars)
+ )
def add_version_constraint_or_raise(current_line, current_requirements, add_if_not_present):
regex_match = requirement_line_regex.match(current_line)
if regex_match:
package = regex_match.group(1)
version_constraints = regex_match.group(2)
+ check_name_consistent(package)
existing_version_constraints = current_requirements.get(package, None)
- # it's fine to add constraints to an unconstrained package, but raise an error if there are already
- # constraints in place
+ # It's fine to add constraints to an unconstrained package,
+ # but raise an error if there are already constraints in place.
if existing_version_constraints and existing_version_constraints != version_constraints:
raise BaseException(f'Multiple constraint definitions found for {package}:'
f' "{existing_version_constraints}" and "{version_constraints}".'
@@ -46,7 +74,8 @@ def add_version_constraint_or_raise(current_line, current_requirements, add_if_n
if add_if_not_present or package in current_requirements:
current_requirements[package] = version_constraints
- # process .in files and store the path to any constraint files that are pulled in
+ # Read requirements from .in files and store the path to any
+ # constraint files that are pulled in.
for path in requirements_paths:
with open(path) as reqs:
for line in reqs:
@@ -55,7 +84,7 @@ def add_version_constraint_or_raise(current_line, current_requirements, add_if_n
if line and line.startswith('-c') and not line.startswith('-c http'):
constraint_files.add(os.path.dirname(path) + '/' + line.split('#')[0].replace('-c', '').strip())
- # process constraint files and add any new constraints found to existing requirements
+ # process constraint files: add constraints to existing requirements
for constraint_file in constraint_files:
with open(constraint_file) as reader:
for line in reader: