diff --git a/README.md b/README.md index 908b79b..fcdb29c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ The following XBlocks have been migrated here from their respective repositories - [AudioXBlock](src/audio/README.md) migrated [from](https://github.com/openedx-unsupported/AudioXBlock) - [FeedbackXBlock](src/feedback/README.rst) migrated [from](https://github.com/openedx/FeedbackXBlock) +- [Free Text Response XBlock](src/freetextresponse/README.rst) migrated [from](https://github.com/openedx/xblock-free-text-response) - [xblock-image-modal](src/imagemodal/README.rst) migrated [from](https://github.com/openedx-unsupported/xblock-image-modal) - [xblock-qualtrics-survey](src/qualtricssurvey/README.rst) migrated [from](https://github.com/openedx-unsupported/xblock-qualtrics-survey) - [xblock-sql-grader](src/sql_grader/README.rst) migrated [from](https://github.com/openedx/xblock-sql-grader) diff --git a/docs/how-tos/migrating-process-operator-guidelines.rst b/docs/how-tos/migrating-process-operator-guidelines.rst index 8a16de6..bad7978 100644 --- a/docs/how-tos/migrating-process-operator-guidelines.rst +++ b/docs/how-tos/migrating-process-operator-guidelines.rst @@ -45,8 +45,9 @@ Step-by-Step Migration Check your requirements files and installed environment:: - pip show audio-xblock feedback-xblock openedx-xblock-image-modal \ - xblock_qualtrics_survey xblock-sql-grader xblock-submit-and-compare + pip show audio-xblock feedback-xblock xblock-free-text-response \ + openedx-xblock-image-modal xblock_qualtrics_survey \ + xblock-sql-grader xblock-submit-and-compare Make a note of which packages are present so you know what to remove. @@ -56,8 +57,9 @@ Make a note of which packages are present so you know what to remove. Remove every standalone package you identified. You can remove them all at once:: - pip uninstall -y audio-xblock feedback-xblock openedx-xblock-image-modal \ - xblock_qualtrics_survey xblock-sql-grader xblock-submit-and-compare + pip uninstall -y audio-xblock feedback-xblock xblock-free-text-response \ + openedx-xblock-image-modal xblock_qualtrics_survey \ + xblock-sql-grader xblock-submit-and-compare 3. Install xblocks-extra ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/pyproject.toml b/pyproject.toml index 5cdc0ad..a1a2cbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,9 +51,10 @@ dev = [ ] test = [ "coverage", + "ddt", "edx-lint", "edx-opaque-keys", - "edx-opaque-keys", + "lazy", "mock", "pytest-cov", "pytest-django", @@ -68,6 +69,7 @@ docs = [ [project.entry-points."xblock.v1"] audio = "audio:AudioXBlock" feedback = "feedback.feedback:FeedbackXBlock" +freetextresponse = "freetextresponse.xblocks:FreeTextResponse" imagemodal = "imagemodal.xblocks:ImageModal" qualtricssurvey = "qualtricssurvey.xblocks:QualtricsSurvey" sql_grader = "sql_grader:SqlGrader" @@ -105,6 +107,7 @@ exclude = ["tests*", "*.tests", "*.tests.*"] [tool.ruff] line-length = 120 target-version = "py312" +src = ["src"] [tool.ruff.lint] select = [ @@ -122,7 +125,6 @@ ignore = [ ] [tool.ruff.lint.isort] -known-first-party = [] known-third-party = ["django", "xblock"] [tool.ruff.format] @@ -145,7 +147,7 @@ DJANGO_SETTINGS_MODULE = "feedback.settings.test" branch = true # Source paths will be added as xblocks are migrated # Example: source = ["foo_xblock", "bar_xblock"] -source = ["sql_grader"] +source = ["src"] omit = [ "*/tests/*", "*/migrations/*", diff --git a/src/feedback/settings/test.py b/src/feedback/settings/test.py index 0abfada..64f977b 100644 --- a/src/feedback/settings/test.py +++ b/src/feedback/settings/test.py @@ -14,6 +14,7 @@ "django.contrib.auth", "django.contrib.contenttypes", "feedback", + "freetextresponse", "workbench", ] diff --git a/src/freetextresponse/README.rst b/src/freetextresponse/README.rst new file mode 100644 index 0000000..527cf2c --- /dev/null +++ b/src/freetextresponse/README.rst @@ -0,0 +1,48 @@ +Free Text Response XBlock +================================ + +XBlock to capture a free-text response. + +|badge-ci| +|badge-coveralls| + +This package provides an XBlock for use with the EdX Platform and makes +it possible for instructors to create questions that expect a +free-text response. + + +Installation +------------ + + +System Administrator +~~~~~~~~~~~~~~~~~~~~ + +To install the XBlock on your platform, +add the following to your `requirements.txt` file: + + xblock-free-text-response + +You'll also need to add this to your `INSTALLED_APPS`: + + freetextresponse + + +Course Staff +~~~~~~~~~~~~ + +To install the XBlock in your course, +access your `Advanced Module List`: + + Settings -> Advanced Settings -> Advanced Module List + +and add the following: + + freetextresponse + + + +.. |badge-coveralls| image:: https://coveralls.io/repos/github/Stanford-Online/xblock-free-text-response/badge.svg?branch=master + :target: https://coveralls.io/github/Stanford-Online/xblock-free-text-response?branch=master +.. |badge-ci| image:: https://github.com/openedx/xblock-free-text-response/workflows/Python%20CI/badge.svg?branch=master + :target: https://github.com/openedx/xblock-free-text-response/actions?query=workflow%3A%22Python+CI%22 diff --git a/src/freetextresponse/__init__.py b/src/freetextresponse/__init__.py new file mode 100644 index 0000000..b5c3628 --- /dev/null +++ b/src/freetextresponse/__init__.py @@ -0,0 +1,5 @@ +""" +This is an XBlock that accepts a free-text response from students. +Instructors can specify a list of phrases, of which one must be +present in order for the student to receive credit. +""" diff --git a/src/freetextresponse/conf/locale/config.yaml b/src/freetextresponse/conf/locale/config.yaml new file mode 100644 index 0000000..cbc5564 --- /dev/null +++ b/src/freetextresponse/conf/locale/config.yaml @@ -0,0 +1,8 @@ +# Configuration for i18n workflow. + +locales: + - en # English - Source Language + +# The locales used for fake-accented English, for testing. +dummy_locales: + - eo # Esperanto diff --git a/src/freetextresponse/mixins/__init__.py b/src/freetextresponse/mixins/__init__.py new file mode 100644 index 0000000..ccb716c --- /dev/null +++ b/src/freetextresponse/mixins/__init__.py @@ -0,0 +1,3 @@ +""" +Mixin behavior to XBlocks +""" diff --git a/src/freetextresponse/mixins/dates.py b/src/freetextresponse/mixins/dates.py new file mode 100644 index 0000000..5bac1ac --- /dev/null +++ b/src/freetextresponse/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 UTC datetime as a timezone-naive object for comparison. + now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) + 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/src/freetextresponse/mixins/fragment.py b/src/freetextresponse/mixins/fragment.py new file mode 100644 index 0000000..74f1461 --- /dev/null +++ b/src/freetextresponse/mixins/fragment.py @@ -0,0 +1,98 @@ +""" +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 web_fragments.fragment import Fragment +from xblock.core import XBlock + +try: + from xblock.utils.resources import ResourceLoader +except ModuleNotFoundError: # pragma: no cover + from xblockutils.resources import ResourceLoader + +loader = ResourceLoader(__name__) + + +class XBlockFragmentBuilderMixin: + """ + Create a default XBlock fragment builder + """ + + static_css = [ + "view.css", + ] + static_js = [ + "view.js", + ] + static_js_init = None + template = "view.html" + + def provide_context(self, context): # pragma: no cover + """ + Build a context dictionary to render the student view + + This should generally be overridden 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 + + # pylint: disable=too-many-positional-arguments + 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, + i18n_service=self._i18n_service(), + ) + fragment = Fragment(rendered_template) + for item in css: + if item.startswith("/"): + url = item + else: + url = self.runtime.local_resource_url(self, "public/" + 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/src/freetextresponse/mixins/i18n.py b/src/freetextresponse/mixins/i18n.py new file mode 100644 index 0000000..917d97b --- /dev/null +++ b/src/freetextresponse/mixins/i18n.py @@ -0,0 +1,30 @@ +""" +Mixin i18n logic +""" + + +class I18nXBlockMixin: + """ + Make an XBlock translation-aware + """ + + def _i18n_service(self): + """ + Provide the XBlock runtime's i18n service + """ + service = self.runtime.service(self, "i18n") + return service + + def gettext(self, text): + """ + Call gettext from the XBlock i18n service + """ + text = self._i18n_service().gettext(text) + return text + + def ngettext(self, *args, **kwargs): + """ + Call ngettext from the XBlock i18n service + """ + text = self._i18n_service().ngettext(*args, **kwargs) + return text diff --git a/src/freetextresponse/mixins/scenario.py b/src/freetextresponse/mixins/scenario.py new file mode 100644 index 0000000..c60cbdd --- /dev/null +++ b/src/freetextresponse/mixins/scenario.py @@ -0,0 +1,45 @@ +""" +Mixin workbench behavior into XBlocks +""" + +try: + from xblock.utils.resources import ResourceLoader +except ModuleNotFoundError: + from xblockutils.resources import ResourceLoader + + +loader = ResourceLoader(__name__) + + +def _parse_title(title): + """ + Parse the title of the scenario + """ + title = title.split("-") + title = " ".join(title) + return title.title() + + +def _parse_scenarios(scenarios): + """ + Parse the content of the scenario files + """ + parsed_scenarios = [] + for scenario in scenarios: + title = _parse_title(scenario[0]) + parsed_scenarios.append((title, scenario[1])) + return parsed_scenarios + + +class XBlockWorkbenchMixin: + """ + Provide a default test workbench for the XBlock + """ + + @classmethod + def workbench_scenarios(cls): + """ + Gather scenarios to be displayed in the workbench + """ + scenarios = loader.load_scenarios_from_path("../scenarios") + return _parse_scenarios(scenarios) diff --git a/src/freetextresponse/mixins/user.py b/src/freetextresponse/mixins/user.py new file mode 100644 index 0000000..68ee0c5 --- /dev/null +++ b/src/freetextresponse/mixins/user.py @@ -0,0 +1,21 @@ +""" +Extend XBlock with additional user functionality +""" + + +# pylint: disable=too-few-public-methods +class MissingDataFetcherMixin: + """ + The mixin used for getting the student_id of the current user. + """ + + def get_student_id(self): + """ + Get the student id. + """ + if hasattr(self, "xmodule_runtime"): + student_id = self.xmodule_runtime.anonymous_student_id + # pylint:disable=E1101 + else: + student_id = str(self.scope_ids.user_id or "") + return student_id diff --git a/src/freetextresponse/models.py b/src/freetextresponse/models.py new file mode 100644 index 0000000..26bde57 --- /dev/null +++ b/src/freetextresponse/models.py @@ -0,0 +1,206 @@ +""" +Handle data access logic for the XBlock +""" + +import logging +from enum import Enum + +from django.db import IntegrityError +from django.utils.translation import gettext_lazy as _ +from xblock.fields import Boolean, Float, Integer, List, Scope, String + +log = logging.getLogger(__name__) + +MAX_RESPONSES = 3 + + +class FreeTextResponseModelMixin: + """ + Handle data access for Free Text Response XBlock instances + """ + + editable_fields = [ + "display_name", + "prompt", + "weight", + "max_attempts", + "display_correctness", + "min_word_count", + "max_word_count", + "fullcredit_keyphrases", + "halfcredit_keyphrases", + "submitted_message", + "display_other_student_responses", + "saved_message", + ] + + display_correctness = Boolean( + display_name=_("Display Correctness?"), + help=_( + "This is a flag that indicates if the indicator " + "icon should be displayed after a student enters " + "their response" + ), + default=True, + scope=Scope.settings, + ) + display_other_student_responses = Boolean( + display_name=_("Display Other Student Responses"), + help=_("This will display other student responses to the student after they submit their response."), + default=False, + scope=Scope.settings, + ) + displayable_answers = List( + default=[], + scope=Scope.user_state_summary, + help=_("System selected answers to give to students"), + ) + display_name = String( + display_name=_("Display Name"), + help=_("This is the title for this question type"), + default="Free-text Response", + scope=Scope.settings, + ) + fullcredit_keyphrases = List( + display_name=_("Full-Credit Key Phrases"), + help=_( + "This is a list of words or phrases, one of " + "which must be present in order for the student's answer " + "to receive full credit" + ), + default=[], + scope=Scope.settings, + ) + halfcredit_keyphrases = List( + display_name=_("Half-Credit Key Phrases"), + help=_( + "This is a list of words or phrases, one of " + "which must be present in order for the student's answer " + "to receive half credit" + ), + default=[], + scope=Scope.settings, + ) + max_attempts = Integer( + display_name=_("Maximum Number of Attempts"), + help=_("This is the maximum number of times a student is allowed to attempt the problem"), + default=0, + values={"min": 0}, + scope=Scope.settings, + ) + max_word_count = Integer( + display_name=_("Maximum Word Count"), + help=_("This is the maximum number of words allowed for this question"), + default=10000, + values={"min": 1}, + scope=Scope.settings, + ) + min_word_count = Integer( + display_name=_("Minimum Word Count"), + help=_("This is the minimum number of words required for this question"), + default=1, + values={"min": 1}, + scope=Scope.settings, + ) + prompt = String( + display_name=_("Prompt"), + help=_("This is the prompt students will see when asked to enter their response"), + default="Please enter your response within this text area", + scope=Scope.settings, + multiline_editor=True, + ) + submitted_message = String( + display_name=_("Submission Received Message"), + help=_("This is the message students will see upon submitting their response"), + default="Your submission has been received", + scope=Scope.settings, + ) + weight = Integer( + display_name=_("Weight"), + help=_("This assigns an integer value representing the weight of this problem"), + default=0, + values={"min": 0}, + scope=Scope.settings, + ) + saved_message = String( + display_name=_("Draft Received Message"), + help=_("This is the message students will see upon submitting a draft response"), + default=('Your answers have been saved but not graded. Click "Submit" to grade them.'), + scope=Scope.settings, + ) + count_attempts = Integer( + default=0, + scope=Scope.user_state, + ) + score = Float( + default=0.0, + scope=Scope.user_state, + ) + student_answer = String( + default="", + scope=Scope.user_state, + ) + has_score = True + show_in_read_only_mode = True + + def store_student_response(self): + """ + Submit a student answer to the answer pool by appending the given + answer to the end of the list. + """ + # if the answer is wrong, do not display it + if self.score != Credit.full.value: + return + + student_id = self.get_student_id() + # remove any previous answers the student submitted + for index, response in enumerate(self.displayable_answers): + if response["student_id"] == student_id: + del self.displayable_answers[index] + break + + self.displayable_answers.append( + { + "student_id": student_id, + "answer": self.student_answer, + } + ) + + # Want to store extra response so student can still see + # MAX_RESPONSES answers if their answer is in the pool. + response_index = -(MAX_RESPONSES + 1) + self.displayable_answers = self.displayable_answers[response_index:] + + 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 + + def _compute_score(self): + """ + Computes and publishes the user's score for the XBlock + based on their answer + """ + credit = self._determine_credit() + self.score = credit.value + try: + self.runtime.publish(self, "grade", {"value": self.score, "max_value": Credit.full.value}) + except IntegrityError: + log.warning("Failed to publish grade for block %s", self.scope_ids.usage_id, exc_info=True) + + +class Credit(Enum): + # pylint: disable=too-few-public-methods + """ + An enumeration of the different types of credit a submission can be + awarded: Zero Credit, Half Credit, and Full Credit + """ + + zero = 0.0 # pylint: disable=invalid-name + half = 0.5 # pylint: disable=invalid-name + full = 1.0 # pylint: disable=invalid-name diff --git a/src/freetextresponse/public/images/correct-icon.png b/src/freetextresponse/public/images/correct-icon.png new file mode 100644 index 0000000..5ae7de2 Binary files /dev/null and b/src/freetextresponse/public/images/correct-icon.png differ diff --git a/src/freetextresponse/public/images/incorrect-icon.png b/src/freetextresponse/public/images/incorrect-icon.png new file mode 100644 index 0000000..0926486 Binary files /dev/null and b/src/freetextresponse/public/images/incorrect-icon.png differ diff --git a/src/freetextresponse/public/images/unanswered-icon.png b/src/freetextresponse/public/images/unanswered-icon.png new file mode 100644 index 0000000..ff88b00 Binary files /dev/null and b/src/freetextresponse/public/images/unanswered-icon.png differ diff --git a/src/freetextresponse/public/view.css b/src/freetextresponse/public/view.css new file mode 100644 index 0000000..d480df6 --- /dev/null +++ b/src/freetextresponse/public/view.css @@ -0,0 +1,175 @@ +/* Reset platform theme overrides that bleed in via .problem / .xmodule_CapaModule classes */ +.freetextresponse { + background-color: transparent; + color: #333; +} + +.freetextresponse .user_input .student_answer { + background-color: #fff; + color: #333; +} + +.freetextresponse .user_input .status { + margin-left: 10px; + display: inline-block; +} + +.freetextresponse .user_input.unanswered .status { + width: 14px; + height: 14px; + background: url("images/unanswered-icon.png") center center no-repeat; +} + +.freetextresponse .user_input.correct .status { + width: 25px; + height: 20px; + background: url("images/correct-icon.png") center center no-repeat; +} + +.freetextresponse .user_input.incorrect .status { + width: 20px; + height: 20px; + background: url("images/incorrect-icon.png") center center no-repeat; +} + +.freetextresponse .user_input .student_answer { + height: 150px; + box-sizing: border-box; + border-radius: 3px; + border: 2px solid #e4e4e4; + min-width: 160px; + width: 85%; +} + +.freetextresponse .user_input.unanswered .student_answer { + border: 2px solid #e4e4e4; +} + +.freetextresponse .user_input.correct .student_answer { + border: 2px solid #1e9348; +} + +.freetextresponse .user_input.incorrect .student_answer { + border: 2px solid #b20610; +} + +.freetextresponse .action { + margin-top: 20px; +} + +.freetextresponse .action .save { + height: 40px; + vertical-align: middle; + font-weight: 600; +} + +.freetextresponse .action .check { + height: 40px; + vertical-align: middle; + font-weight: 600; +} + +.freetextresponse .action .used-attempts-feedback { + display: inline-block; + margin-top: 8px; + margin-left: 10px; + color: #666; + font-style: italic; +} + +.freetextresponse .action .nodisplay{ + display: none; +} + +.freetextresponse .problem-progress { + display: inline-block; + padding-left: 5px; + color: #666; + font-weight: 100; + font-size: 1em; +} + +.freetextresponse .word-count-message { + display: inline-block; + padding-left: 5px; + color: #666; + font-weight: 100; + font-size: 1em; +} + +.freetextresponse .hidden { + visibility: hidden; +} + +.freetextresponse .problem-header { + display: inline-block; +} + +.freetextresponse .capa_alert { + margin-top: 10px; + padding: 8px 12px; + border: 1px solid #ebe8bf; + border-radius: 3px; + background: #fffcdd; + color: #333; + font-size: 0.9em; +} + +.freetextresponse .responses-box { + margin-top: 10px; + padding: 10px; + background: #fbfbfb; + border: 1px solid #ddd; +} +.freetextresponse .responses-box.hidden { + display: none; +} + +.freetextresponse .responses-title { + margin-left: 15px; + margin-top: 10px; +} + +.freetextresponse .hide-button { + padding: 0; + float: right; + box-shadow: none; + background: none; + border: none; + color: #069; + text-decoration: underline; + text-shadow: none; +} +.freetextresponse .hide-button .show { + display: none; +} +.freetextresponse .hide-button .hide { + display: inline; +} + +.freetextresponse .response-list { + list-style: none; +} + +.freetextresponse .other-student-responses { + display: inline-block; + width: 100%; + margin-top: 10px; + padding: 10px; + box-sizing: border-box; + border: 1px dashed #ddd; + color: #414141; + font-size: 0.8em; + text-align: left; + list-style-type: none; +} + +.freetextresponse .no-response { + font-size: 0.8em; + text-align: left; + list-style-type: none; +} + +.freetextresponse div:empty { + display: none; +} diff --git a/src/freetextresponse/public/view.js b/src/freetextresponse/public/view.js new file mode 100644 index 0000000..370a053 --- /dev/null +++ b/src/freetextresponse/public/view.js @@ -0,0 +1,178 @@ +/* eslint-disable no-unused-vars */ +/** + * Initialize the FreeTextResponse 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 FreeTextResponseView(runtime, element) { + /* eslint-enable no-unused-vars */ + 'use strict'; + + var $ = window.jQuery; + var $element = $(element); + var $xblocksContainer = $('#seq_content'); + var buttonHide = $element.find('.hide-button'); + var buttonHideTextHide = $('.hide', buttonHide); + var buttonHideTextShow = $('.show', buttonHide); + var buttonSubmit = $element.find('.check.Submit'); + var buttonSave = $element.find('.save'); + var usedAttemptsFeedback = $element.find('.action .used-attempts-feedback'); + var problemProgress = $element.find('.problem-progress'); + var submissionReceivedMessage = $element.find('.submission-received'); + var userAlertMessage = $element.find('.user_alert'); + var textareaStudentAnswer = $element.find('.student_answer'); + var textareaParent = textareaStudentAnswer.parent(); + var responseList = $element.find('.response-list'); + var url = runtime.handlerUrl(element, 'submit'); + var urlSave = runtime.handlerUrl(element, 'save_response'); + var xblockId = $element.attr('data-usage-id'); + var cachedAnswerId = xblockId + '_cached_answer'; + var problemProgressId = xblockId + '_problem_progress'; + var usedAttemptsFeedbackId = xblockId + '_used_attempts_feedback'; + + if (typeof $xblocksContainer.data(cachedAnswerId) !== 'undefined') { + textareaStudentAnswer.val($xblocksContainer.data(cachedAnswerId)); + problemProgress.text($xblocksContainer.data(problemProgressId)); + usedAttemptsFeedback.text($xblocksContainer.data(usedAttemptsFeedbackId)); + } + + // POLYFILL notify if it does not exist. Like in the xblock workbench. + runtime.notify = runtime.notify || function () { + // eslint-disable-next-line prefer-rest-params, no-console + console.log('POLYFILL runtime.notify', arguments); + }; + + /** + * Update CSS classes + * @param {string} newClass - a CSS class name to be used + * @returns {undefined} nothing + */ + function setClassForTextAreaParent(newClass) { + textareaParent.removeClass('correct'); + textareaParent.removeClass('incorrect'); + textareaParent.removeClass('unanswered'); + textareaParent.addClass(newClass); + } + + /** + * Render peer responses into the list, escaping each answer to prevent XSS. + * @param {Array} responses - a list of Responses + * @returns {undefined} nothing + */ + function renderStudentResponses(responses) { + var noResponsesText = responseList.data('noresponse'); + responseList.empty(); + if (responses.length === 0) { + responseList.append($('
  • ').text(noResponsesText)); + } else { + responses.forEach(function (item) { + responseList.append($('
  • ').text(item.answer)); + }); + } + } + + /** + * Display responses, if applicable + * @param {Object} response - a jQuery HTTP response + * @returns {undefined} nothing + */ + function displayResponsesIfAnswered(response) { + if (!response.display_other_responses) { + $element.find('.responses-box').addClass('hidden'); + return; + } + renderStudentResponses(response.other_responses); + $element.find('.responses-box').removeClass('hidden'); + } + + buttonHide.on('click', function () { + responseList.toggle(); + buttonHideTextHide.toggle(); + buttonHideTextShow.toggle(); + }); + + buttonSubmit.on('click', function () { + buttonSubmit.text(buttonSubmit[0].dataset.checking); + runtime.notify('submit', { + message: 'Submitting...', + state: 'start', + }); + $.ajax(url, { + type: 'POST', + data: JSON.stringify({ + // eslint-disable-next-line camelcase + student_answer: $element.find('.student_answer').val(), + // eslint-disable-next-line camelcase + can_record_response: $element.find('.messageCheckbox').prop('checked'), + }), + success: function buttonSubmitOnSuccess(response) { + usedAttemptsFeedback.text(response.used_attempts_feedback); + buttonSubmit.addClass(response.nodisplay_class); + problemProgress.text(response.problem_progress); + submissionReceivedMessage.text(response.submitted_message); + buttonSubmit.text(buttonSubmit[0].dataset.value); + userAlertMessage.text(response.user_alert); + buttonSave.addClass(response.nodisplay_class); + setClassForTextAreaParent(response.indicator_class); + displayResponsesIfAnswered(response); + + $xblocksContainer.data(cachedAnswerId, $element.find('.student_answer').val()); + $xblocksContainer.data(problemProgressId, response.problem_progress); + $xblocksContainer.data(usedAttemptsFeedbackId, response.used_attempts_feedback); + + runtime.notify('submit', { + state: 'end', + }); + }, + error: function buttonSubmitOnError() { + runtime.notify('error', {}); + }, + }); + return false; + }); + + buttonSave.on('click', function () { + buttonSave.text(buttonSave[0].dataset.checking); + runtime.notify('save', { + message: 'Saving...', + state: 'start', + }); + $.ajax(urlSave, { + type: 'POST', + data: JSON.stringify({ + // eslint-disable-next-line camelcase + student_answer: $element.find('.student_answer').val(), + }), + success: function buttonSaveOnSuccess(response) { + buttonSubmit.addClass(response.nodisplay_class); + buttonSave.addClass(response.nodisplay_class); + usedAttemptsFeedback.text(response.used_attempts_feedback); + problemProgress.text(response.problem_progress); + submissionReceivedMessage.text(response.submitted_message); + buttonSave.text(buttonSave[0].dataset.value); + userAlertMessage.text(response.user_alert); + + $xblocksContainer.data(cachedAnswerId, $element.find('.student_answer').val()); + $xblocksContainer.data(problemProgressId, response.problem_progress); + $xblocksContainer.data(usedAttemptsFeedbackId, response.used_attempts_feedback); + + runtime.notify('save', { + state: 'end', + }); + }, + error: function buttonSaveOnError() { + runtime.notify('error', {}); + }, + }); + return false; + }); + + textareaStudentAnswer.on('keydown', function () { + + // Reset Messages + submissionReceivedMessage.text(''); + userAlertMessage.text(''); + setClassForTextAreaParent('unanswered'); + }); +} diff --git a/src/freetextresponse/scenarios/free-text-response-many.xml b/src/freetextresponse/scenarios/free-text-response-many.xml new file mode 100644 index 0000000..37c0df5 --- /dev/null +++ b/src/freetextresponse/scenarios/free-text-response-many.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + diff --git a/src/freetextresponse/scenarios/free-text-response-single.xml b/src/freetextresponse/scenarios/free-text-response-single.xml new file mode 100644 index 0000000..1891f46 --- /dev/null +++ b/src/freetextresponse/scenarios/free-text-response-single.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/freetextresponse/settings.py b/src/freetextresponse/settings.py new file mode 100644 index 0000000..31b720a --- /dev/null +++ b/src/freetextresponse/settings.py @@ -0,0 +1,14 @@ +""" +Settings for freetextresponse xblock +""" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + }, +} +LOCALE_PATHS = [ + "freetextresponse/conf/locale", +] +SECRET_KEY = "freetextresponse_SECRET_KEY" +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/src/freetextresponse/templates/view.html b/src/freetextresponse/templates/view.html new file mode 100644 index 0000000..c0e7f59 --- /dev/null +++ b/src/freetextresponse/templates/view.html @@ -0,0 +1,50 @@ +{% load i18n %} +
    +

    {{ display_name }}

    +
    {{ problem_progress }}
    +
    {{ prompt|safe }}
    +
    {{ word_count_message }}
    +
    +
    + + +
    +
    + {% if display_other_responses %} + +
    + {% endif %} +
    {{ submitted_message }}
    +
    + {% if not is_past_due %} + + + {% endif %} +
    {{ used_attempts_feedback }}
    +
    +
    {{ user_alert }}
    + {% if display_other_responses %} +
    + +

    {% trans "Submissions by others" %}

    + +
    + {% endif %} +
    diff --git a/src/freetextresponse/tests/__init__.py b/src/freetextresponse/tests/__init__.py new file mode 100644 index 0000000..179860a --- /dev/null +++ b/src/freetextresponse/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Tests for FTR XBlock. +""" diff --git a/src/freetextresponse/tests/determine_credit.json b/src/freetextresponse/tests/determine_credit.json new file mode 100644 index 0000000..0a27d3b --- /dev/null +++ b/src/freetextresponse/tests/determine_credit.json @@ -0,0 +1,75 @@ +{ + "zero_empty_answer": { + "word_count_valid": true, + "fullcredit": [ + "full answer one", + "full answer two" + ], + "halfcredit": [ + "half answer one", + "half answer two" + ], + "student_answer": "", + "credit": "zero" + }, + "zero_invalid_word_count": { + "word_count_valid": false, + "fullcredit": [ + "full answer one", + "full answer two" + ], + "halfcredit": [ + "half answer one", + "half answer two" + ], + "student_answer": "full answer one", + "credit": "zero" + }, + "zero_wrong_answer": { + "word_count_valid": true, + "fullcredit": [ + "full answer one", + "full answer two" + ], + "halfcredit": [ + "half answer one", + "half answer two" + ], + "student_answer": "double answer one", + "credit": "zero" + }, + "full_no_phrases": { + "word_count_valid": true, + "fullcredit": [], + "halfcredit": [], + "student_answer": "any thing will do", + "credit": "full" + }, + "full_right_answer": { + "word_count_valid": true, + "fullcredit": [ + "full answer one", + "full answer two" + ], + "halfcredit": [ + "half answer one", + "half answer two" + ], + "student_answer": "full answer two", + "credit": "full" + }, + "half_right_answer": { + "word_count_valid": true, + "fullcredit": [ + "full answer one", + "full answer two" + ], + "halfcredit": [ + "half answer one", + "half answer two" + ], + "student_answer": "half answer two", + "credit": "half" + } +} + diff --git a/src/freetextresponse/tests/indicator_class.json b/src/freetextresponse/tests/indicator_class.json new file mode 100644 index 0000000..bdb12e0 --- /dev/null +++ b/src/freetextresponse/tests/indicator_class.json @@ -0,0 +1,32 @@ +{ + "unanswered_word_invalid": { + "display_correctness": true, + "word_count_valid": false, + "credit": null, + "result": "unanswered" + }, + "unanswered_word_valid": { + "display_correctness": false, + "word_count_valid": true, + "credit": null, + "result": "unanswered" + }, + "incorrect_zero_credit": { + "display_correctness": true, + "word_count_valid": true, + "credit": "zero", + "result": "incorrect" + }, + "correct_half_credit": { + "display_correctness": true, + "word_count_valid": true, + "credit": "half", + "result": "correct" + }, + "correct_full_credit": { + "display_correctness": true, + "word_count_valid": true, + "credit": "full", + "result": "correct" + } +} diff --git a/src/freetextresponse/tests/invalid_word_count_message.json b/src/freetextresponse/tests/invalid_word_count_message.json new file mode 100644 index 0000000..6ee9a01 --- /dev/null +++ b/src/freetextresponse/tests/invalid_word_count_message.json @@ -0,0 +1,43 @@ +{ + "valid_zero_count_attempts": { + "word_count_valid": true, + "count_attempts": 0, + "min_word_count": 1, + "max_word_count": 10000, + "ignore_attempts": false, + "result": "" + }, + "valid_one_count_attempts": { + "word_count_valid": true, + "count_attempts": 1, + "min_word_count": 1, + "max_word_count": 10000, + "ignore_attempts": false, + "result": "" + }, + "valid_ignore_count_attempts": { + "word_count_valid": true, + "count_attempts": 1, + "min_word_count": 1, + "max_word_count": 10000, + "ignore_attempts": true, + "result": "" + }, + "plural_max_word_count": { + "word_count_valid": false, + "count_attempts": 1, + "min_word_count": 1, + "max_word_count": 10000, + "ignore_attempts": false, + "result": "Invalid Word Count. Your response must be between 1 and 10000 words." + }, + "single_max_word_count": { + "word_count_valid": false, + "count_attempts": 1, + "min_word_count": 1, + "max_word_count": 1, + "ignore_attempts": false, + "result": "Invalid Word Count. Your response must be between 1 and 1 word." + } +} + diff --git a/src/freetextresponse/tests/problem_progress.json b/src/freetextresponse/tests/problem_progress.json new file mode 100644 index 0000000..b48654d --- /dev/null +++ b/src/freetextresponse/tests/problem_progress.json @@ -0,0 +1,52 @@ +{ + "no_weight_zero_score": { + "weight": 0, + "score": 0, + "result": "" + }, + "one_weight_zero_score": { + "weight": 1, + "score": 0, + "result": "(1 point possible)" + }, + "one_weight_full_score": { + "weight": 1, + "score": 1, + "result": "(1/1 point)" + }, + "plural_weight_zero_score": { + "weight": 5, + "score": 0, + "result": "(5 points possible)" + }, + "plural_weight_half_score": { + "weight": 5, + "score": 0.5, + "result": "(2.5/5 points)" + }, + "plural_weight_full_score": { + "weight": 5, + "score": 1, + "result": "(5/5 points)" + }, + "large_even_weight_zero_score": { + "weight": 975312468, + "score": 0, + "result": "(975312468 points possible)" + }, + "large_even_weight_half_score": { + "weight": 975312468, + "score": 0.5, + "result": "(487656234/975312468 points)" + }, + "large_even_weight_full_score": { + "weight": 975312468, + "score": 1, + "result": "(975312468/975312468 points)" + }, + "large_odd_weight_half_score": { + "weight": 7919, + "score": 0.5, + "result": "(3959.5/7919 points)" + } +} diff --git a/src/freetextresponse/tests/submitdisplay_class.json b/src/freetextresponse/tests/submitdisplay_class.json new file mode 100644 index 0000000..86b7d88 --- /dev/null +++ b/src/freetextresponse/tests/submitdisplay_class.json @@ -0,0 +1,27 @@ +{ + "empty_null_max": { + "max_attempts": null, + "count_attempts": 1, + "result": "" + }, + "empty_zero_max": { + "max_attempts": 0, + "count_attempts": 1, + "result": "" + }, + "empty_under_max": { + "max_attempts": 3, + "count_attempts": 2, + "result": "" + }, + "nodisplay_at_max": { + "max_attempts": 3, + "count_attempts": 3, + "result": "nodisplay" + }, + "nodisplay_over_max": { + "max_attempts": 3, + "count_attempts": 4, + "result": "nodisplay" + } +} diff --git a/src/freetextresponse/tests/test_all.py b/src/freetextresponse/tests/test_all.py new file mode 100644 index 0000000..1fe8664 --- /dev/null +++ b/src/freetextresponse/tests/test_all.py @@ -0,0 +1,560 @@ +""" +Module To Test FreeTextResponse XBlock +""" + +import json +import unittest +from datetime import UTC, timedelta +from datetime import datetime as dt +from os import path +from unittest.mock import MagicMock + +import ddt +from django.db import IntegrityError +from xblock.validation import ValidationMessage + +from freetextresponse.models import Credit +from freetextresponse.views import _is_at_least_one_phrase_present # noqa +from freetextresponse.xblocks import FreeTextResponse + +from .tests_utils import make_xblock + +tests_dir = path.dirname(__file__) + + +class TestData: + # pylint: disable=too-few-public-methods + """ + Module helper for validate_field_data + """ + + weight = 0 + max_attempts = 0 + max_word_count = 0 + min_word_count = 0 + submitted_message = None + + +class TestRequest: + # pylint: disable=too-few-public-methods + """ + Module helper for @json_handler + """ + + method = None + body = None + success = None + + +@ddt.ddt +class FreetextResponseXblockTestCase(unittest.TestCase): + # pylint: disable=too-many-instance-attributes, too-many-public-methods + """ + A complete suite of unit tests for the Free-text Response XBlock + """ + + def setUp(self): + """ + Creates an xblock + """ + self.xblock = make_xblock("freetextresponse", FreeTextResponse, {}) + + def test_workbench_scenarios(self): + """ + Checks workbench scenarios title and basic scenario + """ + result_title = "Free Text Response Many" + basic_scenario = "" + test_result = sorted(self.xblock.workbench_scenarios()) + self.assertEqual(result_title, test_result[0][0]) + self.assertIn(basic_scenario, test_result[0][1]) + + def test_generate_validation_message(self): + # pylint: disable=invalid-name, protected-access + """ + Checks classmethod _generate_validation_message + """ + msg = "weight attempts cannot be negative" + result = ValidationMessage(ValidationMessage.ERROR, msg) + test_result = self.xblock._generate_validation_message(msg) + self.assertEqual( + type(result), + type(test_result), + ) + self.assertEqual( + result.text, + test_result.text, + ) + + @ddt.file_data(path.join(tests_dir, "validate_field_data.json")) + def test_validate_field_data(self, **test_dict): + """ + Checks classmethod validate_field_data + tests the instructor values set in edit + """ + test_data = TestData() + test_data.weight = test_dict["weight"] + test_data.max_attempts = test_dict["max_attempts"] + test_data.max_word_count = test_dict["max_word_count"] + test_data.min_word_count = test_dict["min_word_count"] + test_data.submitted_message = test_dict["submitted_message"] + validation = set() + self.xblock.validate_field_data(validation, test_data) + validation_list = list(validation) + # Only one validation error should be in set + self.assertEqual(1, len(validation_list)) + self.assertEqual( + test_dict["result"], + validation_list[0].text, + ) + + def test_initialization_variables(self): + """ + Checks that instance variables are initialized correctly + """ + self.assertEqual("Free-text Response", self.xblock.display_name) + self.assertEqual( + "Please enter your response within this text area", + self.xblock.prompt, + ) + self.assertEqual(0.0, self.xblock.score) + self.assertEqual(0, self.xblock.max_attempts) + self.assertTrue(self.xblock.display_correctness) + self.assertEqual(1, self.xblock.min_word_count) + self.assertEqual(10000, self.xblock.max_word_count) + self.assertEqual( + [], + self.xblock.fullcredit_keyphrases, + ) + self.assertEqual( + [], + self.xblock.halfcredit_keyphrases, + ) + self.assertEqual("", self.xblock.student_answer) + self.assertEqual(0, self.xblock.count_attempts) + + # Default Views + def test_student_view(self): + # pylint: disable=protected-access + """ + Checks the student view for student specific instance variables. + """ + student_view = self.xblock.student_view() + student_view_html = student_view.content + self.assertIn(self.xblock.display_name, student_view_html) + self.assertIn(self.xblock.prompt, student_view_html) + + self.assertIn(self.xblock._get_word_count_message(), student_view_html) + self.assertIn(self.xblock._get_indicator_class(), student_view_html) + self.assertIn(self.xblock._get_problem_progress(), student_view_html) + self.assertIn(self.xblock._get_used_attempts_feedback(), student_view_html) + self.assertIn(self.xblock._get_nodisplay_class(), student_view_html) + self.assertIn(self.xblock._get_indicator_visibility_class(), student_view_html) + + def test_build_fragment_prompt_html(self): + """ + Checks that build_fragment allows html in the prompt variable + + if the 'safe' filter is not used then the django + template pipeline returns html tags like, + '<p>Please enter your response here</p>' + """ + studio_settings_prompt = "

    Please enter your response here

    " + context = { + "prompt": studio_settings_prompt, + } + fragment = self.xblock.build_fragment( + template="view.html", + context=context, + js_init="FreeTextResponseView", + css=[], + js=[], + ) + self.assertIn(studio_settings_prompt, fragment.content) + + 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()) + + def test_studio_view(self): + """ + Checks studio view for instance variables specified by the instructor. + """ + studio_view_html = self.xblock.studio_view(context=None).content + self.assertIn(self.xblock.display_name, studio_view_html) + self.assertIn(self.xblock.prompt, studio_view_html) + self.assertIn(str(self.xblock.weight), studio_view_html) + self.assertIn(str(self.xblock.max_attempts), studio_view_html) + self.assertIn(str(self.xblock.display_correctness), studio_view_html) + self.assertIn(str(self.xblock.min_word_count), studio_view_html) + self.assertIn(str(self.xblock.max_word_count), studio_view_html) + self.assertIn( + ", ".join( + self.xblock.fullcredit_keyphrases, + ), + studio_view_html, + ) + self.assertIn( + ", ".join( + self.xblock.halfcredit_keyphrases, + ), + studio_view_html, + ) + self.assertIn(str(self.xblock.submitted_message), studio_view_html) + + # Scoring + @ddt.file_data(path.join(tests_dir, "determine_credit.json")) + def test_determine_credit(self, **test_data): + # pylint: disable=protected-access + """ + Tests determine_credit + After a student response this function will + return the Credit enum full, half, or zero + """ + self.xblock._word_count_valid = MagicMock(return_value=test_data["word_count_valid"]) + self.xblock.fullcredit_keyphrases = test_data["fullcredit"] + self.xblock.halfcredit_keyphrases = test_data["halfcredit"] + self.xblock.student_answer = test_data["student_answer"] + credit = Credit[test_data["credit"]] + self.assertEqual(credit, self.xblock._determine_credit()) + + @ddt.data(Credit.zero, Credit.half, Credit.full) + def test_compute_score(self, credit): + # pylint: disable=protected-access + """ + Tests _compute_score + After a student response this function will + set the xblock score and publish the grade + """ + self.xblock.runtime.publish = MagicMock(return_value=None) + self.xblock._determine_credit = MagicMock(return_value=credit) + self.xblock._compute_score() + self.xblock.runtime.publish.assert_called_with( + self.xblock, + "grade", + {"value": credit.value, "max_value": Credit.full.value}, + ) + + def test_compute_score_integrity_error(self): + # pylint: disable=protected-access, invalid-name + """ + Tests that _compute_score gracefully handles IntegrityError exception. + + Tests to ensure that if an IntegrityError exception + is thrown by any of the methods/functions called in the + process of saving the score, the program handles it gracefully. + We force runtime.publish to throw an IntegrityError exception, + and expect _compute_score to be graceful about it. + """ + self.xblock.runtime.publish = MagicMock(return_value=None) + self.xblock.runtime.publish.side_effect = IntegrityError("Unique Key Violation") + self.xblock._determine_credit = MagicMock(return_value=Credit.zero) + self.xblock._compute_score() + + def test_is_at_least_one_phrase_present(self): + # pylint: disable=invalid-name, protected-access + """ + Tests _is_at_least_one_phrase_present + Helper method to match student response + with given phrase list + """ + keyphrases_list = ["do dict phrase", "re dict phrase", "mi dict phrase", "fa dict phrase"] + answer = keyphrases_list[1] + answer = "ajhsdfhjaefhaf " + answer + "jkfbaufebn; fuqv" + self.assertTrue( + _is_at_least_one_phrase_present( + keyphrases_list, + answer, + ), + ) + + def test_not_is_at_least_one_phrase_present(self): + # pylint: disable=invalid-name, protected-access + """ + Tests _is_at_least_one_phrase_present + Helper method to match student response + with given phrase list + """ + keyphrases_list = ["do dict phrase", "re dict phrase", "mi dict phrase", "fa dict phrase"] + answer = "so dict phrase" + answer = "ajhsdfhjaefhaf " + answer + "jkfbaufebn; fuqv" + self.assertFalse( + _is_at_least_one_phrase_present( + keyphrases_list, + answer, + ), + ) + + @ddt.file_data(path.join(tests_dir, "word_count_valid.json")) + def test_word_count_valid(self, **test_data): + # pylint: disable=protected-access + """ + Tests _word_count_valid + After a student response this will + determine if the response meets word + count criteria + """ + self.xblock.min_word_count = test_data["min_word_count"] + self.xblock.max_word_count = test_data["max_word_count"] + self.xblock.student_answer = test_data["student_answer"] + self.assertEqual(test_data["result"], self.xblock._word_count_valid()) + + # Messages + @ddt.data( + # max_attempts, count_attempts, result + (None, 4, ""), + (0, 4, ""), + (1, 0, "You have used 0 of 1 submission"), + (3, 2, "You have used 2 of 3 submissions"), + ) + @ddt.unpack + def test_used_attempts_feedback_normal(self, max_attempts, count_attempts, result): + # pylint: disable=invalid-name, protected-access + """ + Tests get_used_attempts_feedback + Returns the used attempts feedback message + after a student response + """ + self.xblock.max_attempts = max_attempts + self.xblock.count_attempts = count_attempts + self.assertEqual( + result, + self.xblock._get_used_attempts_feedback(), + ) + + @ddt.data( + # min_word_count, max_word_count, result + (0, 1, "Your response must be between 0 and 1 word."), + (2, 3, "Your response must be between 2 and 3 words."), + ) + @ddt.unpack + def test_get_word_count_message( + self, + min_word_count, + max_word_count, + result, + ): + # pylint: disable=protected-access + """ + Tests _get_word_count_message + Returns the word count message + based on instructor set word count + min and max + """ + self.xblock.min_word_count = min_word_count + self.xblock.max_word_count = max_word_count + self.assertEqual( + result, + self.xblock._get_word_count_message(), + ) + + # Tested from get_user_alert + @ddt.file_data(path.join(tests_dir, "invalid_word_count_message.json")) + def test_get_user_alert(self, **test_data): + # pylint: disable=protected-access + """ + Tests _get_user_alert + if the word count is invalid this will + return the invalid word count message + """ + self.xblock._word_count_valid = MagicMock(return_value=test_data["word_count_valid"]) + self.xblock.count_attempts = test_data["count_attempts"] + self.xblock.min_word_count = test_data["min_word_count"] + self.xblock.max_word_count = test_data["max_word_count"] + self.assertEqual( + str(test_data["result"]), + self.xblock._get_user_alert(ignore_attempts=test_data["ignore_attempts"]), + ) + + @ddt.data( + # word_count_valid, result + (False, ""), + (True, "test submission received message"), + ) + @ddt.unpack + def test_get_submitted_message(self, word_count_valid, result): + # pylint: disable=protected-access + """ + Tests _get_submitted_message + Returns a message to display to + the user after they submit a + response + """ + self.xblock._word_count_valid = MagicMock(return_value=word_count_valid) + self.xblock.submitted_message = "test submission received message" + self.assertEqual( + result, + self.xblock._get_submitted_message(), + ) + + @ddt.file_data(path.join(tests_dir, "problem_progress.json")) + def test_get_problem_progress(self, **test_data): + # pylint: disable=protected-access + """ + Tests _get_problem_progress + Score can be 0, 0.5, or 1 + Return a message for current + problem progress + """ + self.xblock.weight = test_data["weight"] + self.xblock.score = test_data["score"] + self.assertEqual( + test_data["result"], + self.xblock._get_problem_progress(), + ) + + # CSS Classes + @ddt.file_data(path.join(tests_dir, "indicator_class.json")) + def test_get_indicator_class(self, **test_data): + # pylint: disable=protected-access + """ + Test _get_indicator_class + Returns the correctness CCS class + to show correct/incorrect/unanswered + UI + """ + credit = None + if test_data["credit"]: + credit = Credit[test_data["credit"]] + self.xblock.display_correctness = test_data["display_correctness"] + self.xblock._word_count_valid = MagicMock(return_value=test_data["word_count_valid"]) + self.xblock._determine_credit = MagicMock(return_value=credit) + self.assertEqual(test_data["result"], self.xblock._get_indicator_class()) + + @ddt.data( + # display_correctness, result + (True, ""), + (False, "hidden"), + ) + @ddt.unpack + def test_get_indicator_visibility_class(self, display_correctness, result): + # pylint: disable=invalid-name, protected-access + """ + Tests _get_indicator_visibility_class + Return hidden or blank CCS class to + hide correctness UI + """ + self.xblock.display_correctness = display_correctness + self.assertEqual( + result, + self.xblock._get_indicator_visibility_class(), + ) + + @ddt.file_data(path.join(tests_dir, "submitdisplay_class.json")) + def test_get_submitdisplay_class(self, **test_data): + # pylint: disable=protected-access + """ + Tests _get_submitdisplay_class + Return blank or nodisplay CCS class + that hide the submit buttons after + a user has reached max_attempts + """ + self.xblock.max_attempts = test_data["max_attempts"] + self.xblock.count_attempts = test_data["count_attempts"] + self.assertEqual(test_data["result"], self.xblock._get_nodisplay_class()) + + @ddt.data( + ({"max_attempts": None, "count_attempts": 1}, True), + ({"max_attempts": None, "count_attempts": 3}, True), + ({"max_attempts": 0, "count_attempts": 3}, True), + ({"max_attempts": 3, "count_attempts": 2}, True), + ({"max_attempts": 3, "count_attempts": 3}, False), + ({"due": None, "graceperiod": None}, True), + ({"due": None, "graceperiod": timedelta(hours=1)}, True), + ( + { + "due": dt.now(UTC).replace(tzinfo=None) + timedelta(hours=1), + "graceperiod": None, + }, + True, + ), + ( + { + "due": dt.now(UTC).replace(tzinfo=None) - timedelta(hours=1), + "graceperiod": None, + }, + False, + ), + ( + { + "due": dt.now(UTC).replace(tzinfo=None) - timedelta(hours=1), + "graceperiod": timedelta(hours=2), + }, + True, + ), + ( + { + "due": dt.now(UTC).replace(tzinfo=None) - timedelta(hours=5), + "graceperiod": timedelta(hours=2), + }, + False, + ), + ) + @ddt.unpack + def test_submit(self, xblock_config, can_submit): + # pylint: disable=protected-access + """ + Tests save_response results + """ + student_answer = "asdf" + for key, value in xblock_config.items(): + setattr(self.xblock, key, value) + data = json.dumps({"student_answer": student_answer}) + request = TestRequest() + request.method = "POST" + request.body = data.encode("utf-8") + response = self.xblock.submit(request) + # Added for response json_body + # pylint: disable=no-member + self.assertEqual(response.json_body["status"], "success") + self.assertEqual(response.json_body["problem_progress"], self.xblock._get_problem_progress()) + self.assertEqual(response.json_body["indicator_class"], self.xblock._get_indicator_class()) + self.assertEqual(response.json_body["used_attempts_feedback"], self.xblock._get_used_attempts_feedback()) + self.assertEqual(response.json_body["nodisplay_class"], self.xblock._get_nodisplay_class()) + self.assertEqual(response.json_body["submitted_message"], self.xblock._get_submitted_message()) + self.assertEqual( + response.json_body["user_alert"], + self.xblock._get_user_alert( + ignore_attempts=True, + ), + ) + self.assertEqual(response.json_body["visibility_class"], self.xblock._get_indicator_visibility_class()) + self.assertEqual(self.xblock.student_answer, student_answer if can_submit else "") + + @ddt.data( + ({"max_attempts": None, "count_attempts": 1}, True), + ({"max_attempts": None, "count_attempts": 3}, True), + ({"max_attempts": 0, "count_attempts": 3}, True), + ({"max_attempts": 3, "count_attempts": 3}, False), + ) + @ddt.unpack + def test_save_response(self, xblock_config, can_save): + # pylint: disable=protected-access + """ + Tests save_response results + """ + student_answer = "asdf" + for key, value in xblock_config.items(): + setattr(self.xblock, key, value) + data = json.dumps({"student_answer": student_answer}) + request = TestRequest() + request.method = "POST" + request.body = data.encode("utf-8") + response = self.xblock.save_response(request) + # Added for response json_body + # pylint: disable=no-member + self.assertEqual(response.json_body["status"], "success") + self.assertEqual(response.json_body["problem_progress"], self.xblock._get_problem_progress()) + self.assertIsNone( + response.json_body.get("indicator_class", None), + ) + self.assertEqual(response.json_body["used_attempts_feedback"], self.xblock._get_used_attempts_feedback()) + self.assertEqual(response.json_body["nodisplay_class"], self.xblock._get_nodisplay_class()) + self.assertEqual(response.json_body["submitted_message"], "") + self.assertEqual(response.json_body["user_alert"], self.xblock.saved_message) + self.assertEqual(response.json_body["visibility_class"], self.xblock._get_indicator_visibility_class()) + self.assertEqual(self.xblock.student_answer, student_answer if can_save else "") diff --git a/src/freetextresponse/tests/test_i18n_resources.py b/src/freetextresponse/tests/test_i18n_resources.py new file mode 100644 index 0000000..4627ef9 --- /dev/null +++ b/src/freetextresponse/tests/test_i18n_resources.py @@ -0,0 +1,54 @@ +""" +Tests for gettext translations and XBlock i18n. +""" + +from unittest.mock import patch + +import django.utils.translation +from django.test import TestCase +from django.utils.translation.trans_real import get_language, translation + +from freetextresponse.xblocks import FreeTextResponse + +from .tests_utils import make_xblock + + +def mock_gettext(source, *_args): + """ + Mock gettext to avoid loading the .mo file. + """ + return { + "Submit": "Süßmït", + "Your response must be between {min} and {max} word.": "Ýöür réspönsé müst ßé ßétwéén {min} änd {max} wörd.", + "Your response must be between {min} and {max} words.": "Ýöür réspönsé müst ßé ßétwéén {min} änd {max} wörds.", + }.get(source, source) + + +class TestFreeTextResponseI18N(TestCase): + """ + Ensure the i18n is setup correctly for the XBlock. + """ + + def test_esperanto_translations_in_student_view(self): + """ + Checks if the template and its context are both correctly translated. + """ + xblock = make_xblock("freetextresponse", FreeTextResponse, {}) + current_translation = translation(get_language()) + + with ( + django.utils.translation.override("eo"), + patch.object(current_translation, "gettext") as gettext, + patch.object(current_translation, "ngettext") as ngettext, + ): + gettext.side_effect = mock_gettext + ngettext.side_effect = mock_gettext + student_view = xblock.student_view() + student_view_html = student_view.content + + self.assertIn("Süßmït", student_view_html) + + english_text = "Your response must be between" + translated_text = "Ýöür réspönsé müst ßé ßétwéén" + self.assertNotIn(english_text, student_view_html) + self.assertIn(translated_text, student_view_html) diff --git a/src/freetextresponse/tests/tests_utils.py b/src/freetextresponse/tests/tests_utils.py new file mode 100644 index 0000000..17b0782 --- /dev/null +++ b/src/freetextresponse/tests/tests_utils.py @@ -0,0 +1,38 @@ +""" +Test utilities for the FreeTextResponse XBlock tests. +""" + +from unittest.mock import Mock + +from workbench.runtime import WorkbenchRuntime # pylint: disable=all +from xblock.fields import ScopeIds +from xblock.runtime import DictKeyValueStore, KvsFieldData + + +def make_xblock(xblock_name, xblock_cls, attributes): + """ + Helper to construct XBlock objects + """ + runtime = WorkbenchRuntime() + key_store = DictKeyValueStore() + db_model = KvsFieldData(key_store) + ids = generate_scope_ids(runtime, xblock_name) + xblock = xblock_cls(runtime, db_model, scope_ids=ids) + xblock.category = Mock() + xblock.location = Mock( + html_id=Mock(return_value="sample_element_id"), + ) + xblock.runtime = runtime + xblock.course_id = "course-v1:foo+bar+baz" + for key, value in attributes.items(): + setattr(xblock, key, value) + return xblock + + +def generate_scope_ids(runtime, block_type): + """ + Helper to generate scope IDs for an XBlock + """ + def_id = runtime.id_generator.create_definition(block_type) + usage_id = runtime.id_generator.create_usage(def_id) + return ScopeIds("user", block_type, def_id, usage_id) diff --git a/src/freetextresponse/tests/validate_field_data.json b/src/freetextresponse/tests/validate_field_data.json new file mode 100644 index 0000000..f689954 --- /dev/null +++ b/src/freetextresponse/tests/validate_field_data.json @@ -0,0 +1,51 @@ +{ + "weight_attempts_negative": { + "weight": -1, + "max_attempts": 1, + "max_word_count": 1, + "min_word_count": 1, + "submitted_message": "s", + "result": "Weight cannot be negative" + }, + "max_attempts_negative": { + "weight": 0, + "max_attempts": -1, + "max_word_count": 1, + "min_word_count": 1, + "submitted_message": "s", + "result": "Maximum Attempts cannot be negative" + }, + "min_word_count_less_than_one": { + "weight": 0, + "max_attempts": 1, + "max_word_count": 1, + "min_word_count": 0, + "submitted_message": "s", + "result": "Minimum Word Count cannot be less than 1" + }, + "min_not_greater_than_max": { + "weight": 0, + "max_attempts": 1, + "max_word_count": 1, + "min_word_count": 2, + "submitted_message": "s", + "result": "Minimum Word Count cannot be greater than Max Word Count" + }, + "submission_message_blank": { + "weight": 0, + "max_attempts": 1, + "max_word_count": 3, + "min_word_count": 2, + "submitted_message": "", + "result": "Submission Received Message cannot be blank" + }, + "submission_message_blank_null_max_attempts": { + "weight": 0, + "max_attempts": null, + "max_word_count": 3, + "min_word_count": 2, + "submitted_message": "", + "result": "Submission Received Message cannot be blank" + } +} + diff --git a/src/freetextresponse/tests/word_count_valid.json b/src/freetextresponse/tests/word_count_valid.json new file mode 100644 index 0000000..3d376e6 --- /dev/null +++ b/src/freetextresponse/tests/word_count_valid.json @@ -0,0 +1,38 @@ +{ + "blank_answer": { + "min_word_count": 1, + "max_word_count": 2, + "student_answer": "", + "result": false + }, + "min_answer": { + "min_word_count": 1, + "max_word_count": 2, + "student_answer": "One", + "result": true + }, + "max_answer": { + "min_word_count": 1, + "max_word_count": 2, + "student_answer": "One Two", + "result": true + }, + "too_long_answer": { + "min_word_count": 2, + "max_word_count": 4, + "student_answer": "One Two Three Four Five", + "result": false + }, + "too_short_answer": { + "min_word_count": 2, + "max_word_count": 4, + "student_answer": "One", + "result": false + }, + "in_range_answer": { + "min_word_count": 3, + "max_word_count": 6, + "student_answer": "One Two Three Four Five", + "result": true + } +} diff --git a/src/freetextresponse/views.py b/src/freetextresponse/views.py new file mode 100644 index 0000000..a87c1a0 --- /dev/null +++ b/src/freetextresponse/views.py @@ -0,0 +1,328 @@ +""" +Handle view logic for the XBlock +""" + +from xblock.core import XBlock +from xblock.validation import ValidationMessage + +try: + from xblock.utils.resources import ResourceLoader + from xblock.utils.studio_editable import StudioEditableXBlockMixin +except ModuleNotFoundError: # pragma: no cover + # For backward compatibility with releases older than Quince. + from xblockutils.resources import ResourceLoader + from xblockutils.studio_editable import StudioEditableXBlockMixin + +from .mixins.dates import EnforceDueDates +from .mixins.fragment import XBlockFragmentBuilderMixin +from .mixins.i18n import I18nXBlockMixin +from .models import MAX_RESPONSES, Credit + + +# pylint: disable=no-member +class FreeTextResponseViewMixin( + I18nXBlockMixin, + EnforceDueDates, + XBlockFragmentBuilderMixin, + StudioEditableXBlockMixin, +): + """ + Handle view logic for FreeTextResponse XBlock instances + """ + + loader = ResourceLoader(__name__) + static_js_init = "FreeTextResponseView" + + def provide_context(self, context=None): + """ + Build a context dictionary to render the student view + """ + context = context or {} + context = dict(context) + context.update( + { + "display_name": self.display_name, + "usage_id": str(self.scope_ids.usage_id) + .replace(":", "_") + .replace("@", "_") + .replace("/", "_") + .replace("+", "_") + .replace(".", "_"), + "indicator_class": self._get_indicator_class(), + "nodisplay_class": self._get_nodisplay_class(), + "problem_progress": self._get_problem_progress(), + "prompt": self.prompt, + "student_answer": self.student_answer, + "is_past_due": self.is_past_due(), + "used_attempts_feedback": self._get_used_attempts_feedback(), + "visibility_class": self._get_indicator_visibility_class(), + "word_count_message": self._get_word_count_message(), + "display_other_responses": self.display_other_student_responses, + "other_responses": self.get_other_answers(), + "user_alert": "", + "submitted_message": "", + } + ) + return context + + def _get_indicator_class(self): + """ + Returns the class of the correctness indicator element + """ + result = "unanswered" + if self.display_correctness and self._word_count_valid(): + if self._determine_credit() == Credit.zero: + result = "incorrect" + else: + result = "correct" + return result + + def _get_nodisplay_class(self): + """ + Returns the css class for the submit button + """ + result = "" + if self.max_attempts and 0 < self.max_attempts <= self.count_attempts: + result = "nodisplay" + return result + + def _word_count_valid(self): + """ + Returns a boolean value indicating whether the current + word count of the user's answer is valid + """ + word_count = len(self.student_answer.split()) + result = self.max_word_count >= word_count >= self.min_word_count + return result + + def _determine_credit(self): + # Not a standard xblock pylint disable. + # This is a problem with pylint 'enums and R0204 in general' + """ + Helper Method that determines the level of credit that + the user should earn based on their answer + """ + result = None + if self.student_answer == "" or not self._word_count_valid(): + result = Credit.zero + elif not self.fullcredit_keyphrases and not self.halfcredit_keyphrases: + result = Credit.full + elif _is_at_least_one_phrase_present(self.fullcredit_keyphrases, self.student_answer): + result = Credit.full + elif _is_at_least_one_phrase_present(self.halfcredit_keyphrases, self.student_answer): + result = Credit.half + else: + result = Credit.zero + 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: + weight = self.weight + temp = self.ngettext(f"{weight} point possible", f"{weight} points possible", weight) + result = f"({temp})" + else: + scaled_score = self.score * self.weight + # No trailing zero and no scientific notation + score_string = f"{scaled_score:.15f}".rstrip("0").rstrip(".") + weight = self.weight + temp = self.ngettext(f"{score_string}/{weight} point", f"{score_string}/{weight} points", weight) + result = f"({temp})" + return result + + 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 and self.max_attempts > 0: + result = self.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, + ) + return result + + def _get_indicator_visibility_class(self): + """ + Returns the visibility class for the correctness indicator html element + """ + if self.display_correctness: + result = "" + else: + result = "hidden" + return result + + def _get_word_count_message(self): + """ + Returns the word count message + """ + result = self.ngettext( + "Your response must be between {min} and {max} word.", + "Your response must be between {min} and {max} words.", + self.max_word_count, + ).format( + min=self.min_word_count, + max=self.max_word_count, + ) + return result + + def get_other_answers(self): + """ + Returns at most MAX_RESPONSES answers from the pool. + + Does not return answers the student had submitted. + """ + student_id = self.get_student_id() + display_other_responses = self.display_other_student_responses + shouldnt_show_other_responses = not display_other_responses + student_answer_incorrect = self._determine_credit() == Credit.zero + if student_answer_incorrect or shouldnt_show_other_responses: + return [] + return_list = [response for response in self.displayable_answers if response["student_id"] != student_id] + return_list = return_list[-(MAX_RESPONSES):] + return return_list + + @XBlock.json_handler + def submit(self, data, suffix=""): + # pylint: disable=unused-argument + """ + Processes the user's submission + """ + # Fails if the UI submit/save buttons were shut + # down on the previous submission + if self._can_submit(): + self.student_answer = data["student_answer"] + # Counting the attempts and publishing a score + # even if word count is invalid. + self.count_attempts += 1 + self._compute_score() + display_other_responses = self.display_other_student_responses + if display_other_responses and data.get("can_record_response"): + self.store_student_response() + result = { + "status": "success", + "problem_progress": self._get_problem_progress(), + "indicator_class": self._get_indicator_class(), + "used_attempts_feedback": self._get_used_attempts_feedback(), + "nodisplay_class": self._get_nodisplay_class(), + "submitted_message": self._get_submitted_message(), + "user_alert": self._get_user_alert( + ignore_attempts=True, + ), + "other_responses": self.get_other_answers(), + "display_other_responses": self.display_other_student_responses, + "visibility_class": self._get_indicator_visibility_class(), + } + return result + + @XBlock.json_handler + def save_response(self, data, suffix=""): + # pylint: disable=unused-argument + """ + Processes the user's save + """ + # Fails if the UI submit/save buttons were shut + # down on the previous submission + if not self.max_attempts or self.count_attempts < self.max_attempts: + self.student_answer = data["student_answer"] + result = { + "status": "success", + "problem_progress": self._get_problem_progress(), + "used_attempts_feedback": self._get_used_attempts_feedback(), + "nodisplay_class": self._get_nodisplay_class(), + "submitted_message": "", + "user_alert": self.saved_message, + "visibility_class": self._get_indicator_visibility_class(), + } + return result + + def _get_invalid_word_count_message(self, ignore_attempts=False): + """ + Returns the invalid word count message + """ + result = "" + if (ignore_attempts or self.count_attempts > 0) and (not self._word_count_valid()): + word_count_message = self._get_word_count_message() + result = self.gettext("Invalid Word Count. {word_count_message}").format( + word_count_message=word_count_message, + ) + return result + + def _get_submitted_message(self): + """ + Returns the message to display in the submission-received div + """ + result = "" + if self._word_count_valid(): + result = self.submitted_message + return result + + def _get_user_alert(self, ignore_attempts=False): + """ + Returns the message to display in the user_alert div + depending on the student answer + """ + result = "" + if not self._word_count_valid(): + result = self._get_invalid_word_count_message(ignore_attempts) + return result + + def _can_submit(self): + """ + Determine if a user may submit a response + """ + if self.is_past_due(): + return False + if not self.max_attempts: + return True + if self.count_attempts < self.max_attempts: + return True + return False + + def _generate_validation_message(self, text): + """ + Helper method to generate a ValidationMessage from + the supplied string + """ + result = ValidationMessage(ValidationMessage.ERROR, self.gettext(str(text))) + return result + + def validate_field_data(self, validation, data): + """ + Validates settings entered by the instructor. + """ + if data.weight < 0: + msg = self._generate_validation_message("Weight cannot be negative") + validation.add(msg) + if data.max_attempts and data.max_attempts < 0: + msg = self._generate_validation_message("Maximum Attempts cannot be negative") + validation.add(msg) + if data.min_word_count < 1: + msg = self._generate_validation_message("Minimum Word Count cannot be less than 1") + validation.add(msg) + if data.min_word_count > data.max_word_count: + msg = self._generate_validation_message("Minimum Word Count cannot be greater than Max Word Count") + validation.add(msg) + if not data.submitted_message: + msg = self._generate_validation_message("Submission Received Message cannot be blank") + validation.add(msg) + + +def _is_at_least_one_phrase_present(phrases, answer): + """ + Determines if at least one of the supplied phrases is + present in the given answer + """ + answer = answer.lower() + matches = [phrase.lower() in answer for phrase in phrases] + return any(matches) diff --git a/src/freetextresponse/xblocks.py b/src/freetextresponse/xblocks.py new file mode 100644 index 0000000..bdfc577 --- /dev/null +++ b/src/freetextresponse/xblocks.py @@ -0,0 +1,23 @@ +""" +This is the core logic for the XBlock +""" + +from xblock.core import XBlock + +from .mixins.scenario import XBlockWorkbenchMixin +from .mixins.user import MissingDataFetcherMixin +from .models import FreeTextResponseModelMixin +from .views import FreeTextResponseViewMixin + + +@XBlock.needs("i18n") +class FreeTextResponse( + FreeTextResponseModelMixin, + FreeTextResponseViewMixin, + MissingDataFetcherMixin, + XBlockWorkbenchMixin, + XBlock, +): + """ + XBlock to capture a free-text response. + """ diff --git a/uv.lock b/uv.lock index 4ac4c23..83e028a 100644 --- a/uv.lock +++ b/uv.lock @@ -374,6 +374,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] +[[package]] +name = "ddt" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/d4/bdea45c5c1f1f0ae55844d841101b00905c9863ee1004da37d911253abb2/ddt-1.7.2.tar.gz", hash = "sha256:d215d6b083963013c4a19b1e4dcd6a96e80e43ab77519597a6acfcf2e9a3e04b", size = 13673, upload-time = "2024-02-26T01:36:33.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/7c/38d1aec205833096eddefcbb3492fbb2c886e74174c72bc160da9522b2f0/ddt-1.7.2-py2.py3-none-any.whl", hash = "sha256:6adcfaf9785f0a36f9e73a89b91e412de9ef8649e289b750e3683bc79d5e2354", size = 7065, upload-time = "2024-02-26T01:36:32.45Z" }, +] + [[package]] name = "dill" version = "0.4.1" @@ -1541,8 +1550,10 @@ docs = [ ] test = [ { name = "coverage" }, + { name = "ddt" }, { name = "edx-lint" }, { name = "edx-opaque-keys" }, + { name = "lazy" }, { name = "mock" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1554,12 +1565,14 @@ test = [ requires-dist = [ { name = "build", marker = "extra == 'dev'" }, { name = "coverage", marker = "extra == 'test'" }, + { name = "ddt", marker = "extra == 'test'" }, { name = "django", specifier = ">=4.2" }, { name = "django-crum" }, { name = "edx-codejail" }, { name = "edx-i18n-tools", marker = "extra == 'dev'" }, { name = "edx-lint", marker = "extra == 'test'" }, { name = "edx-opaque-keys", marker = "extra == 'test'" }, + { name = "lazy", marker = "extra == 'test'" }, { name = "mock", marker = "extra == 'test'" }, { name = "openedx-filters" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=7.0" },