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 @@ +
+

{{ display_name }}

+
{{ problem_progress }}
+
{{ prompt }}
+
+
{{ your_answer_label }}
+ +
+
+
{{ our_answer_label }}
+
{{ explanation }}
+
+
{{hint}}
+
+ {% if not is_past_due %} +
+ +
+
+ +
+
+ +
+ {% endif %} +
+
{{ 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: