diff --git a/.mise.toml b/.mise.toml index cbac010..8752f01 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,2 +1,6 @@ +[env] +TUTOR_ROOT = "{{env.PWD}}" +_.python.venv = { path = ".venv", create = true } # create the venv if it doesn't exist + [tools] -python = "3.11" # [optional] will be used for the venv +python = "3.8" # [optional] will be used for the venv diff --git a/Makefile b/Makefile index 3e71d86..8e84ba1 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ extract_translations: ## extract strings to be translated, outputting .po files compile_translations: ## compile translation files, outputting .mo files for each supported language cd $(WORKING_DIR) && i18n_tool generate -v - python manage.py compilejsi18n --namespace DragAndDropI18N --output $(JS_TARGET) + python manage.py compilejsi18n --namespace MultiProblemI18N --output $(JS_TARGET) detect_changed_source_translations: cd $(WORKING_DIR) && i18n_tool changed @@ -71,7 +71,7 @@ test.python: ## run python unit tests in the local virtualenv pytest --cov multi_problem_xblock $(TEST) test.unit: ## run all unit tests - tox $(TEST) + tox -- $(TEST) test: test.unit test.quality ## Run all tests tox -e translations diff --git a/README.md b/README.md index de04b21..b1026a1 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,39 @@ root folder: ```bash $ pip install -r requirements.txt + ``` +#### Install in development mode in tutor + +* Clone this repository somewhere locally, for example: `/path/to/multi-problem-xblock`. +* Mount this directory in tutor using `tutor mounts add /path/to/multi-problem-xblock` +* Run `tutor dev launch` + ### Enabling in Studio -Go to `Settings -> Advanced` Settings and add `multi-problem` to `Advanced Module List`. +Go to `Settings -> Advanced` Settings and add `multi_problem` to `Advanced Module List`. ### Usage -*TBD* +* Click on `Advanced block` in studio unit authoring page and select `Multi Problem Block`. +* Click on `Edit` button to select a library from which child problems needs to be fetched. +* You can update the number of problems user will see using `Count` field, update cut-off score, display name etc. +* `Display feedback` field allows authors to control when users can see problem answers, this updates `show_correctness` of all the child problems. + +#### Screenshots + +![image](https://github.com/user-attachments/assets/b6cec90d-307b-43f8-856f-6cd54f28918a) + +![image](https://github.com/user-attachments/assets/645b5ab4-74e9-4237-be87-c81b3d432fdf) + +![image](https://github.com/user-attachments/assets/be11fe56-8c90-4f51-bce1-aa20ad852718) + +![image](https://github.com/user-attachments/assets/f4243f26-c73a-4ebd-afbe-7e5bc84a9617) + +![image](https://github.com/user-attachments/assets/64074714-33cb-4bcb-a03f-c141113288df) + + ### Testing with tox @@ -30,7 +54,6 @@ Inside a fresh virtualenv, `cd` into the root folder of this repository $ make requirements ``` - You can then run the entire test suite via: ```bash @@ -69,11 +92,10 @@ To comply with l10n requirements, XBlock is supposed to provide translations in [edx-docs-i18n]: http://edx.readthedocs.io/projects/xblock-tutorial/en/latest/edx_platform/edx_lms.html#internationalization-support -Drag and Drop v2 XBlock aims to comply with i18n requirements for Open edX platform, including a stricter set of -requirements for `edx.org` itself, thus providing the required files. So far only two translations are available: +Multi Problem XBlock aims to comply with i18n requirements for Open edX platform, including a stricter set of +requirements for `edx.org` itself, thus providing the required files. So far only one translation is available: * Default English translation -* Fake "Esperanto" translation used to test i18n/l10n. Updates to translated strings are supposed to be propagated to `text.po` files. EdX [i18n_tools][edx-i18n-tools] is used here along GNU Gettext and a Makefile for automation. @@ -107,7 +129,3 @@ translator from edX i18n-tools. ```bash $ make dummy_translations ``` - -## Releasing - -To release a new version, update .travis.yml and setup.py to point to your new intended version number and create a new release with that version tag via Github. diff --git a/multi_problem_xblock/compat.py b/multi_problem_xblock/compat.py index 5b88e2a..16505c9 100644 --- a/multi_problem_xblock/compat.py +++ b/multi_problem_xblock/compat.py @@ -4,41 +4,89 @@ import logging +from xblock.core import XBlock + log = logging.getLogger(__name__) def getLibraryContentBlock(): + """Get LibraryContentBlock from edx-platform if possible""" try: - from xmodule.library_content_block import LibraryContentBlock + from xmodule.library_content_block import LibraryContentBlock # pylint: disable=import-outside-toplevel except ModuleNotFoundError: - log.warning('LibraryContentBlock not found, using empty object') - LibraryContentBlock = object + try: + # Support for olive version + from xmodule.library_content_module import LibraryContentBlock # pylint: disable=import-outside-toplevel + except ModuleNotFoundError: + log.warning('LibraryContentBlock not found, using empty object') + LibraryContentBlock = XBlock return LibraryContentBlock -class SHOWANSWER: +class L_SHOWANSWER: """ Local copy of SHOWANSWER from xmodule/capa_block.py in edx-platform """ - ALWAYS = "always" - ANSWERED = "answered" - ATTEMPTED = "attempted" - CLOSED = "closed" - FINISHED = "finished" - CORRECT_OR_PAST_DUE = "correct_or_past_due" - PAST_DUE = "past_due" - NEVER = "never" - AFTER_SOME_NUMBER_OF_ATTEMPTS = "after_attempts" - AFTER_ALL_ATTEMPTS = "after_all_attempts" - AFTER_ALL_ATTEMPTS_OR_CORRECT = "after_all_attempts_or_correct" - ATTEMPTED_NO_PAST_DUE = "attempted_no_past_due" + + ALWAYS = 'always' + ANSWERED = 'answered' + ATTEMPTED = 'attempted' + CLOSED = 'closed' + FINISHED = 'finished' + CORRECT_OR_PAST_DUE = 'correct_or_past_due' + PAST_DUE = 'past_due' + NEVER = 'never' + AFTER_SOME_NUMBER_OF_ATTEMPTS = 'after_attempts' + AFTER_ALL_ATTEMPTS = 'after_all_attempts' + AFTER_ALL_ATTEMPTS_OR_CORRECT = 'after_all_attempts_or_correct' + ATTEMPTED_NO_PAST_DUE = 'attempted_no_past_due' + + +class L_ShowCorrectness: + """ + Local copy of ShowCorrectness from xmodule/graders.py in edx-platform + """ + + # Constants used to indicate when to show correctness + ALWAYS = 'always' + PAST_DUE = 'past_due' + NEVER = 'never' def getShowAnswerOptions(): """Get SHOWANSWER constant from xmodule/capa_block.py""" try: - from xmodule.capa_block import SHOWANSWER + from xmodule.capa_block import SHOWANSWER # pylint: disable=import-outside-toplevel + return SHOWANSWER except ModuleNotFoundError: - log.warning('SHOWANSWER not found, using local copy') - return SHOWANSWER + try: + # Support for olive version + from xmodule.capa_module import SHOWANSWER # pylint: disable=import-outside-toplevel + + return SHOWANSWER + except ModuleNotFoundError: + log.warning('SHOWANSWER not found, using local copy') + return L_SHOWANSWER + + +def getShowCorrectnessOptions(): + """Get ShowCorrectness constant from xmodule/graders.py""" + try: + from xmodule.graders import ShowCorrectness # pylint: disable=import-outside-toplevel + + return ShowCorrectness + except ModuleNotFoundError: + log.warning('ShowCorrectness not found, using local copy') + return L_ShowCorrectness + + +def getStudentView(): + """Get STUDENT_VIEW constant from xmodule/x_module.py""" + try: + from xmodule.x_module import STUDENT_VIEW # pylint: disable=import-outside-toplevel + + return STUDENT_VIEW + except ModuleNotFoundError: + log.warning('STUDENT_VIEW not found, using raw string') + return 'student_view' diff --git a/multi_problem_xblock/multi_problem_xblock.py b/multi_problem_xblock/multi_problem_xblock.py index 491596d..7a26c44 100644 --- a/multi_problem_xblock/multi_problem_xblock.py +++ b/multi_problem_xblock/multi_problem_xblock.py @@ -1,19 +1,26 @@ -# -""" Multi Problem XBlock """ +"""Multi Problem XBlock""" # Imports ########################################################### +import json import logging +import math +from copy import copy +from lxml import etree +from lxml.etree import XMLSyntaxError +from web_fragments.fragment import Fragment +from webob import Response +from xblock.completable import XBlockCompletionMode from xblock.core import XBlock -from xblock.fields import Float, Integer, Scope, String +from xblock.fields import Boolean, Float, Integer, Scope, String try: from xblock.utils.resources import ResourceLoader except ModuleNotFoundError: # For backward compatibility with releases older than Quince. from xblockutils.resources import ResourceLoader -from .compat import getLibraryContentBlock, getShowAnswerOptions +from .compat import getLibraryContentBlock, getShowAnswerOptions, getShowCorrectnessOptions, getStudentView from .utils import _ # Globals ########################################################### @@ -22,6 +29,8 @@ logger = logging.getLogger(__name__) LibraryContentBlock = getLibraryContentBlock() SHOWANSWER = getShowAnswerOptions() +ShowCorrectness = getShowCorrectnessOptions() +STUDENT_VIEW = getStudentView() # Classes ########################################################### @@ -31,103 +40,401 @@ class DISPLAYFEEDBACK: """ Constants for when to show feedback """ - IMMEDIATELY = "immediately" - END_OF_TEST = "end_of_test" - NEVER = "never" + + IMMEDIATELY = 'immediately' + END_OF_TEST = 'end_of_test' + NEVER = 'never' class SCORE_DISPLAY_FORMAT: """ Constants for how score is displayed """ - PERCENTAGE = "percentage" - X_OUT_OF_Y = "x_out_of_y" + PERCENTAGE = 'percentage' + X_OUT_OF_Y = 'x_out_of_y' + + +@XBlock.wants('library_tools', 'studio_user_permissions', 'user', 'completion', 'bookmarks') +class MultiProblemBlock(LibraryContentBlock): + """ + Multi problem xblock using LibraryContentBlock as base. + """ + + # Override LibraryContentBlock resources_dir + resources_dir = '' + + has_custom_completion = True + completion_mode = XBlockCompletionMode.COMPLETABLE -@XBlock.wants('library_tools') -@XBlock.wants('studio_user_permissions') # Only available in CMS. -@XBlock.wants('user') -@XBlock.needs('mako') -class MultiProblemBlock( - LibraryContentBlock -): display_name = String( - display_name=_("Display Name"), - help=_("The display name for this component."), - default="Multi Problem Block", + display_name=_('Display Name'), + help=_('The display name for this component.'), + default='Multi Problem Block', scope=Scope.settings, ) showanswer = String( - display_name=_("Show Answer"), - help=_("Defines when to show the answer to the problem. " - "Acts as default value for showanswer field in each problem under this block"), + display_name=_('Show Answer'), + help=_( + 'Defines when to show the answer to the problem. ' + 'Acts as default value for showanswer field in each problem under this block. ' + 'NOTE: Do not change this field and `Library` field together, child show answer field ' + 'will not be updated.' + ), scope=Scope.settings, default=SHOWANSWER.FINISHED, values=[ - {"display_name": _("Always"), "value": SHOWANSWER.ALWAYS}, - {"display_name": _("Answered"), "value": SHOWANSWER.ANSWERED}, - {"display_name": _("Attempted or Past Due"), "value": SHOWANSWER.ATTEMPTED}, - {"display_name": _("Closed"), "value": SHOWANSWER.CLOSED}, - {"display_name": _("Finished"), "value": SHOWANSWER.FINISHED}, - {"display_name": _("Correct or Past Due"), "value": SHOWANSWER.CORRECT_OR_PAST_DUE}, - {"display_name": _("Past Due"), "value": SHOWANSWER.PAST_DUE}, - {"display_name": _("Never"), "value": SHOWANSWER.NEVER}, - {"display_name": _("After Some Number of Attempts"), "value": SHOWANSWER.AFTER_SOME_NUMBER_OF_ATTEMPTS}, - {"display_name": _("After All Attempts"), "value": SHOWANSWER.AFTER_ALL_ATTEMPTS}, - {"display_name": _("After All Attempts or Correct"), "value": SHOWANSWER.AFTER_ALL_ATTEMPTS_OR_CORRECT}, - {"display_name": _("Attempted"), "value": SHOWANSWER.ATTEMPTED_NO_PAST_DUE}, - ] - ) - - weight = Float( - display_name=_("Problem Weight"), - help=_("Defines the number of points each problem is worth. " - "If the value is not set, each response field in each problem is worth one point."), - values={"min": 0, "step": .1}, - scope=Scope.settings + {'display_name': _('Always'), 'value': SHOWANSWER.ALWAYS}, + {'display_name': _('Answered'), 'value': SHOWANSWER.ANSWERED}, + {'display_name': _('Attempted or Past Due'), 'value': SHOWANSWER.ATTEMPTED}, + {'display_name': _('Closed'), 'value': SHOWANSWER.CLOSED}, + {'display_name': _('Finished'), 'value': SHOWANSWER.FINISHED}, + {'display_name': _('Correct or Past Due'), 'value': SHOWANSWER.CORRECT_OR_PAST_DUE}, + {'display_name': _('Past Due'), 'value': SHOWANSWER.PAST_DUE}, + {'display_name': _('Never'), 'value': SHOWANSWER.NEVER}, + {'display_name': _('After Some Number of Attempts'), 'value': SHOWANSWER.AFTER_SOME_NUMBER_OF_ATTEMPTS}, + {'display_name': _('After All Attempts'), 'value': SHOWANSWER.AFTER_ALL_ATTEMPTS}, + {'display_name': _('After All Attempts or Correct'), 'value': SHOWANSWER.AFTER_ALL_ATTEMPTS_OR_CORRECT}, + {'display_name': _('Attempted'), 'value': SHOWANSWER.ATTEMPTED_NO_PAST_DUE}, + ], ) display_feedback = String( - display_name=_("Display feedback"), - help=_("Defines when to show feedback i.e. correctness in the problem slides."), + display_name=_('Display feedback'), + help=_( + 'Defines when to show feedback i.e. correctness in the problem slides. ' + 'NOTE: Do not change this field and `Library` field together, child show correctness field ' + 'will not be updated.' + ), scope=Scope.settings, default=DISPLAYFEEDBACK.IMMEDIATELY, values=[ - {"display_name": _("Immediately"), "value": DISPLAYFEEDBACK.IMMEDIATELY}, - {"display_name": _("End of test"), "value": DISPLAYFEEDBACK.END_OF_TEST}, - {"display_name": _("Never"), "value": DISPLAYFEEDBACK.NEVER}, - ] + {'display_name': _('Immediately'), 'value': DISPLAYFEEDBACK.IMMEDIATELY}, + {'display_name': _('End of test'), 'value': DISPLAYFEEDBACK.END_OF_TEST}, + {'display_name': _('Never'), 'value': DISPLAYFEEDBACK.NEVER}, + ], ) score_display_format = String( - display_name=_("Score display format"), - help=_("Defines how score will be displayed to students."), + display_name=_('Score display format'), + help=_('Defines how score will be displayed to students.'), scope=Scope.settings, default=SCORE_DISPLAY_FORMAT.X_OUT_OF_Y, values=[ - {"display_name": _("Percentage"), "value": SCORE_DISPLAY_FORMAT.PERCENTAGE}, - {"display_name": _("X out of Y"), "value": SCORE_DISPLAY_FORMAT.X_OUT_OF_Y}, - ] + {'display_name': _('Percentage'), 'value': SCORE_DISPLAY_FORMAT.PERCENTAGE}, + {'display_name': _('X out of Y'), 'value': SCORE_DISPLAY_FORMAT.X_OUT_OF_Y}, + ], ) cut_off_score = Float( - display_name=_("Cut-off score"), - help=_("Defines min score for successful completion of the test"), + display_name=_('Cut-off score'), + help=_('Defines min score for successful completion of the test'), scope=Scope.settings, - values={"min": 0, "step": .1, "max": 1}, + values={'min': 0, 'step': 0.1, 'max': 1}, + default=0, ) - current_slide = Integer( - help=_("Stores current slide/problem number for a user"), - scope=Scope.user_state, - default=0 + next_page_on_submit = Boolean( + display_name=_('Next page on submit'), + help=_( + 'If true and display feedback is set to End of test or Never,' + ' next problem will be displayed automatically on submit.' + ), + scope=Scope.settings, + default=False, ) + current_slide = Integer(help=_('Stores current slide/problem number for a user'), scope=Scope.user_state, default=0) + @property def non_editable_metadata_fields(self): - non_editable_fields = super().non_editable_metadata_fields - non_editable_fields.extend([ - MultiProblemBlock.current_slide - ]) + """ + Set current_slide as non editable field + """ + non_editable_fields = [] + if hasattr(super(), 'non_editable_metadata_fields'): + non_editable_fields = super().non_editable_metadata_fields + non_editable_fields.extend([MultiProblemBlock.current_slide]) return non_editable_fields + + def _process_display_feedback(self, child): + """ + Set child correctness based on parent display_feedback + """ + if not hasattr(child, 'show_correctness'): + return + # If display_feedback is IMMEDIATELY, show answers immediately after submission as well as at the end + # In other cases i.e., END_OF_TEST & NEVER, set show_correctness to never + # and display correctness via force argument in the last slide if display_feedback set to END_OF_TEST + # HACK: For some reason, child.show_correctness is not saved if self.show_correctness is not updated. + self.show_correctness = child.show_correctness = ( # pylint: disable=attribute-defined-outside-init + ShowCorrectness.ALWAYS if self.display_feedback == DISPLAYFEEDBACK.IMMEDIATELY else ShowCorrectness.NEVER + ) + + def editor_saved(self, user, old_metadata, old_content): + """ + Update child field values based on parent block. + child.showanswer <- self.showanswer + child.weight <- self.weight + child.show_correctness <- ALWAYS if display_feedback == IMMEDIATELY else NEVER + """ + if hasattr(super(), 'editor_saved'): + super().editor_saved(user, old_metadata, old_content) + for child in self.get_children(): + if hasattr(child, 'showanswer'): + child.showanswer = self.showanswer + self._process_display_feedback(child) + child.save() + + @XBlock.json_handler + def handle_slide_change(self, data, suffix=None): + """ + Handle slide change request, triggered when user clicks on next or previous button. + """ + self.current_slide = data.get('current_slide') + return Response() + + @staticmethod + def _calculate_progress_percentage(completed_problems, total_problems): + return int((completed_problems / (total_problems or 1)) * 100) + + def _children_iterator(self, filter_block_type=None): + """ + Generator to yield child problem blocks. + """ + # use selected_children method from LibraryContentBlock to get child xblocks. + for index, (block_type, block_id) in enumerate(self.selected_children()): + if filter_block_type and (block_type != filter_block_type): + continue + child = self.runtime.get_block(self.usage_key.course_key.make_usage_key(block_type, block_id)) + yield (index, block_type, child) + + def _get_problem_stats(self): + """ + Get completed_problems and total_problems in the current test. + """ + total_problems = 0 + completed_problems = 0 + for _index, _block_type, child in self._children_iterator(filter_block_type='problem'): + if hasattr(child, 'is_submitted'): + total_problems += 1 + if child.is_submitted(): + completed_problems += 1 + return completed_problems, total_problems + + @XBlock.handler + def get_overall_progress(self, _data, _suffix=None): + """ + Fetch status of all child problem xblocks to get overall progress and updates completion percentage. + """ + completed_problems, total_problems = self._get_problem_stats() + progress = self._calculate_progress_percentage(completed_problems, total_problems) + completion = progress / 100 + if completion == 1: + _, student_score, total_possible_score = self._prepare_user_score() + if student_score / total_possible_score < self.cut_off_score: + # Reserve 10% if user score is less than self.cut_off_score + completion = 0.9 + self.publish_completion(completion) + return Response(json.dumps({'overall_progress': progress})) + + def _prepare_user_score(self, include_question_answers=False): + """ + Calculate total user score and prepare list of question answers with user response. + + Args: + include_question_answers (bool): Includes question and correct answers with user response. + """ + question_answers = [] + student_score = 0 + total_possible_score = 0 + for _index, _block_type, child in self._children_iterator(filter_block_type='problem'): + lcp = child.lcp + correct_map = lcp.correct_map + for answer_id, student_answer in lcp.student_answers.items(): + # Check is_correct before fetching score as lcp is initialized here + is_correct = child.is_correct() + score = child.score + student_score += score.raw_earned + total_possible_score += score.raw_possible + if include_question_answers: + question_answers.append( + { + 'question': lcp.find_question_label(answer_id), + 'answer': lcp.find_answer_text(answer_id, current_answer=student_answer), + 'correct_answer': lcp.find_correct_answer_text(answer_id), + 'is_correct': is_correct, + 'msg': correct_map.get_msg(answer_id), + } + ) + return question_answers, student_score, total_possible_score + + @XBlock.handler + def get_test_scores(self, _data, _suffix): + """ + Get test score slide content + """ + if self.display_feedback == DISPLAYFEEDBACK.NEVER: + return Response(_('Not allowed to see results'), 400) + completed_problems, total_problems = self._get_problem_stats() + if completed_problems != total_problems and total_problems > 0: + return Response(_('All problems need to be completed before checking test results!'), status=400) + question_answers, student_score, total_possible_score = self._prepare_user_score(include_question_answers=True) + passed = False + allow_back_button = True + + if self.score_display_format == SCORE_DISPLAY_FORMAT.X_OUT_OF_Y: + score_display = f'{student_score}/{total_possible_score}' + cut_off_score = f'{math.ceil(self.cut_off_score * total_possible_score)}/{total_possible_score}' + else: + score_display = f'{(student_score / total_possible_score):.0%}' + cut_off_score = f'{self.cut_off_score:.0%}' + + if (student_score / total_possible_score) >= self.cut_off_score: + self.publish_completion(1) + passed = True + + if self.display_feedback != DISPLAYFEEDBACK.IMMEDIATELY: + allow_back_button = False + self.current_slide = -1 + + template = loader.render_django_template( + '/templates/html/multi_problem_xblock_test_scores.html', + { + 'cut_off_score': cut_off_score if self.cut_off_score else '', + 'question_answers': question_answers, + 'score': score_display, + 'passed': passed, + 'allow_back_button': allow_back_button, + }, + ) + return Response(template, content_type='text/html') + + @XBlock.handler + def reset_selected_children(self, data, suffix=None): + # reset current_slide field + self.current_slide = 0 + return super().reset_selected_children(data, suffix) + + def student_view_context(self, context=None): + """ + Student view data for templates and javascript initialization + """ + fragment = Fragment() + items = [] + child_context = {} if not context else copy(context) + jump_to_id = child_context.get('jumpToId') + bookmarks_service = self.runtime.service(self, 'bookmarks') + total_problems = 0 + completed_problems = 0 + + if 'username' not in child_context: + user_service = self.runtime.service(self, 'user') + child_context['username'] = user_service.get_current_user().opt_attrs.get('edx-platform.username') + + for index, block_type, child in self._children_iterator(): + child_id = str(child.usage_key) + if child is None: + # https://github.com/openedx/edx-platform/blob/448acc95f6296c72097102441adc4e1f79a7444f/xmodule/library_content_block.py#L391-L396 + logger.error('Skipping display for child block that is None') + continue + if jump_to_id == child_id: + self.current_slide = index + + if block_type == 'problem' and hasattr(child, 'is_submitted'): + # set current progress on first load + total_problems += 1 + if child.is_submitted(): + completed_problems += 1 + + rendered_child = child.render(STUDENT_VIEW, child_context) + fragment.add_fragment_resources(rendered_child) + items.append( + { + 'id': child_id, + 'content': rendered_child.content, + 'bookmark_id': '{},{}'.format(child_context['username'], child_id), + 'is_bookmarked': ( + bookmarks_service.is_bookmarked(usage_key=child.usage_key) if bookmarks_service else False + ), + } + ) + + next_page_on_submit = self.next_page_on_submit and self.display_feedback != DISPLAYFEEDBACK.IMMEDIATELY + overall_progress = self._calculate_progress_percentage(completed_problems, total_problems) + + # Reset current_slide field if display_feedback is set to never after user completes all problems. + if overall_progress == 100 and self.current_slide == -1 and self.display_feedback == DISPLAYFEEDBACK.NEVER: + self.current_slide = 0 + + template_context = { + 'items': items, + 'self': self, + 'watched_completable_blocks': set(), + 'completion_delay_ms': None, + 'reset_button': self.allow_resetting_children, + 'show_results': self.display_feedback != DISPLAYFEEDBACK.NEVER, + 'next_page_on_submit': next_page_on_submit, + 'overall_progress': overall_progress, + 'bookmarks_service_enabled': bookmarks_service is not None, + } + js_context = { + 'current_slide': self.current_slide, + 'next_page_on_submit': next_page_on_submit, + } + return fragment, template_context, js_context + + def student_view(self, context): + """ + Student view + """ + fragment, template_context, js_context = self.student_view_context(context) + fragment.add_content( + loader.render_django_template('/templates/html/multi_problem_xblock.html', template_context) + ) + fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/multi_problem_xblock.css')) + fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/multi_problem_xblock.js')) + fragment.initialize_js('MultiProblemBlock', js_context) + return fragment + + def publish_completion(self, progress: float): + """ + Update block completion status. + """ + completion_service = self.runtime.service(self, 'completion') + if completion_service and completion_service.completion_tracking_enabled(): + self.runtime.publish(self, 'completion', {'completion': progress}) + + @classmethod + def definition_from_xml(cls, xml_object, system): + """Generate object from xml""" + children = [] + + for child in xml_object.getchildren(): + try: + children.append(system.process_xml(etree.tostring(child)).scope_ids.usage_id) + except (XMLSyntaxError, AttributeError): + msg = ( + 'Unable to load child when parsing Multi Problem Block. ' + 'This can happen when a comment is manually added to the course export.' + ) + logger.error(msg) + if system.error_tracker is not None: + system.error_tracker(msg) + + definition = dict(xml_object.attrib.items()) + return definition, children + + def definition_to_xml(self, resource_fs): + """Exports Library Content Block to XML""" + xml_object = etree.Element('multi_problem') + for child in self.get_children(): + self.runtime.add_block_as_child_node(child, xml_object) + # Set node attributes based on our fields. + for field_name, field in self.fields.items(): + if field_name in ('children', 'parent', 'content'): + continue + if field.is_set_on(self): + xml_object.set(field_name, str(field.read_from(self))) + return xml_object diff --git a/multi_problem_xblock/public/css/multi_problem_xblock.css b/multi_problem_xblock/public/css/multi_problem_xblock.css new file mode 100644 index 0000000..2480126 --- /dev/null +++ b/multi_problem_xblock/public/css/multi_problem_xblock.css @@ -0,0 +1,193 @@ +/* Hide all slides by default: */ +:root { + --bookmark-icon: "\f097"; /* .fa-bookmark-o */ + --bookmarked-icon: "\f02e"; /* .fa-bookmark */ + --correct-svg: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10 0C4.48 0 0 4.48 0 10C0 15.52 4.48 20 10 20C15.52 20 20 15.52 20 10C20 4.48 15.52 0 10 0ZM8 15L3 10L4.41 8.59L8 12.17L15.59 4.58L17 6L8 15Z' fill='%23028100'/%3E%3C/svg%3E%0A"); + --incorrect-svg: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10 0C4.48 0 0 4.48 0 10C0 15.52 4.48 20 10 20C15.52 20 20 15.52 20 10C20 4.48 15.52 0 10 0Z' fill='%23B20710'/%3E%3Cpath d='M13.9093 15L15 13.9093L11.0907 10L15 6.09074L13.9093 5L10 8.90926L6.09074 5L5 6.09074L8.90926 10L5 13.9093L6.09074 15L10 11.0907L13.9093 15Z' fill='white' stroke='white'/%3E%3C/svg%3E%0A"); +} + +.slide { + display: none; +} + +.btn-primary-outline { + border: 1px solid #15376D !important; + background-color: #FFFFFF; + border-radius: 4px; + height: fit-content; +} + +.problem-slide-header { + display: flex; + justify-content: space-between; + margin-bottom: 1.5rem; + align-items: end; +} + +.multi-problem-container { + box-shadow: 0px 0px 10px 0px #0000001A; + border-radius: 4px; +} + +.progress { + display: flex; + height: 5px; + overflow: hidden; + background-color: white; + border-radius: 4px 4px 0 0; +} + +.progress-bar { + display: flex; + flex-direction: column; + justify-content: center; + color: white; + text-align: center; + background-color: #00262B; + transition: width .6s ease; +} + +.problem-result-wrapper .redo-test:hover, +.problem-result-wrapper .redo-test:focus, +.problem-result-wrapper .see-test-results:hover, +.problem-result-wrapper .see-test-results:focus { + background-color: #065683 !important; + background-image: none !important; + box-shadow: none !important; +} + +.problem-result-wrapper .see-test-results { + margin-left: auto; +} + +.problem-result-wrapper .redo-test { + display: none; + margin-left: auto; +} + +.multi-problem-bookmark-buttons::before { + padding-right: 2px; + content: var(--bookmark-icon); + font-family: FontAwesome; +} + +.multi-problem-bookmark-buttons.bookmarked::before { + padding-right: 2px; + content: var(--bookmarked-icon); + font-family: FontAwesome; +} + +.bookmark-button-wrapper { + display: flex; + justify-content: end; +} + +@media only screen and (min-width: 768px) { + .bookmark-button-wrapper { + margin-right: -2.5rem; + } +} + +.multi-problem-test-results .accordion-container { + border-radius: 10px; + border: 1px solid #E5E7EB; +} + +.multi-problem-test-results .accordion-container .accordion:first-child { + border-top-left-radius: 10px; + border-top-right-radius: 10px; +} + +.multi-problem-test-results .accordion { + all: unset; + color: #454545; + width: 100%; + cursor: pointer; + border: none; + text-align: left; + outline: none; + transition: 0.4s; + box-sizing: border-box; +} + +.multi-problem-test-results .accordion-container > .accordion:nth-of-type(even) { + background-color: #FBFAF9; +} + +.multi-problem-test-results .accordion:hover, +.multi-problem-test-results .accordion:focus, +.multi-problem-test-results .accordion:active { + background-color: #E5E7EB; + background-image: none; + box-shadow: none; +} + +.multi-problem-test-results .accordion:before { + content: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10.59 0.590088L6 5.17009L1.41 0.590088L0 2.00009L6 8.00009L12 2.00009L10.59 0.590088Z' fill='black'/%3E%3C/svg%3E%0A"); + color: #777; + font-weight: bold; + float: left; + padding-left: 5px; + padding-right: 10px; + margin-right: 10px; +} + +.multi-problem-test-results .text-red { + color: #b4131b; +} + +.multi-problem-test-results .accordion.correct:after { + content: var(--correct-svg); + margin-left: auto; +} + +.multi-problem-test-results .accordion.incorrect:after { + content: var(--incorrect-svg); + margin-left: auto; +} + +.multi-problem-test-results .panel .correct:before { + content: var(--correct-svg); + padding-right: 10px; +} + +.multi-problem-test-results .panel .incorrect:before { + content: var(--incorrect-svg); + padding-right: 10px; +} + +.multi-problem-test-results .active:before { + content: url("data:image/svg+xml,%3Csvg width='13' height='8' viewBox='0 0 13 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1.74301 7.40991L6.33301 2.82991L10.923 7.40991L12.333 5.99991L6.33301 -8.82626e-05L0.333008 5.99991L1.74301 7.40991Z' fill='black'/%3E%3C/svg%3E%0A"); +} + +.multi-problem-test-results .active { + border-bottom: 1px solid #E5E7EB; +} + +.multi-problem-test-results .panel { + max-height: 0; + overflow: hidden; + transition: max-height 0.2s ease-out; +} + +.multi-problem-test-results .test-score-row { + background-color: #F2F7F8; + margin-bottom: 0; + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; +} + +.multi-problem-test-results .test-score { + float: right; +} + +.multi-problem-test-results .explanation-title, +.multi-problem-test-results .hint-label { + display: none; +} + +.multi-problem-test-results .score-header-text { + position: absolute; + left: 50%; + transform: translateX(-50%); +} diff --git a/multi_problem_xblock/public/js/library_content_edit.js b/multi_problem_xblock/public/js/library_content_edit.js new file mode 100644 index 0000000..ec03742 --- /dev/null +++ b/multi_problem_xblock/public/js/library_content_edit.js @@ -0,0 +1,55 @@ +// Exact copy of +// https://github.com/open-craft/edx-platform/blob/9a97ea78d9774f969dcafeda24af137e2055b669/xmodule/assets/library_content/public/js/library_content_edit.js +// for local_resource_url to work in studio and author view. +/* JavaScript for special editing operations that can be done on LibraryContentXBlock */ +window.LibraryContentAuthorView = function(runtime, element) { + 'use strict'; + var $element = $(element); + var usage_id = $element.data('usage-id'); + // The "Update Now" button is not a child of 'element', as it is in the validation message area + // But it is still inside this xblock's wrapper element, which we can easily find: + var $wrapper = $element.parents('*[data-locator="' + usage_id + '"]'); + + $wrapper.on('click', '.library-update-btn', function(e) { + e.preventDefault(); + // Update the XBlock with the latest matching content from the library: + runtime.notify('save', { + state: 'start', + element: element, + message: gettext('Updating with latest library content') + }); + $.post(runtime.handlerUrl(element, 'upgrade_and_sync')).done(function() { + runtime.notify('save', { + state: 'end', + element: element + }); + if ($element.closest('.wrapper-xblock').is(':not(.level-page)')) { + // We are on a course unit page. The notify('save') should refresh this block, + // but that is only working on the container page view of this block. + // Why? On the unit page, this XBlock's runtime has no reference to the + // XBlockContainerPage - only the top-level XBlock (a vertical) runtime does. + // But unfortunately there is no way to get a reference to our parent block's + // JS 'runtime' object. So instead we must refresh the whole page: + location.reload(); + } + }); + }); + // Hide loader and show element when update task finished. + var $loader = $wrapper.find('.ui-loading'); + var $xblockHeader = $wrapper.find('.xblock-header'); + if (!$loader.hasClass('is-hidden')) { + var timer = setInterval(function() { + $.get(runtime.handlerUrl(element, 'children_are_syncing'), function( data ) { + if (data !== true) { + $loader.addClass('is-hidden'); + $xblockHeader.removeClass('is-hidden'); + clearInterval(timer); + runtime.notify('save', { + state: 'end', + element: element + }); + } + }) + }, 1000); + } +}; diff --git a/multi_problem_xblock/public/js/library_content_edit_helpers.js b/multi_problem_xblock/public/js/library_content_edit_helpers.js new file mode 100644 index 0000000..eb8f8b6 --- /dev/null +++ b/multi_problem_xblock/public/js/library_content_edit_helpers.js @@ -0,0 +1,58 @@ +// Exact copy of +// https://github.com/open-craft/edx-platform/blob/9a97ea78d9774f969dcafeda24af137e2055b669/xmodule/assets/library_content/public/js/library_content_edit_helpers.js +// for local_resource_url to work in studio and author view. + +/* JavaScript for special editing operations that can be done on LibraryContentXBlock */ +// This is a temporary UI improvements that will be removed when V2 content libraries became +// fully functional + +/** + * Toggle the "Problem Type" settings section depending on selected library type. + * As for now, the V2 libraries don't support different problem types, so they can't be + * filtered by it. We're hiding the Problem Type field for them. + */ +function checkProblemTypeShouldBeVisible(editor) { + var libraries = editor.find('.wrapper-comp-settings.metadata_edit.is-active') + .data().metadata.source_library_id.options; + var selectedIndex = $("select[name='Library']", editor)[0].selectedIndex; + var libraryKey = libraries[selectedIndex].value; + var url = URI('/xblock') + .segment(editor.find('.xblock.xblock-studio_view.xblock-studio_view-library_content.xblock-initialized') + .data('usage-id')) + .segment('handler') + .segment('is_v2_library'); + + $.ajax({ + type: 'POST', + url: url, + data: JSON.stringify({'library_key': libraryKey}), + success: function(data) { + var problemTypeSelect = editor.find("select[name='Problem Type']") + .parents("li.field.comp-setting-entry.metadata_entry"); + data.is_v2 ? problemTypeSelect.hide() : problemTypeSelect.show(); + } + }); +} + +/** + * Waits untill editor html loaded, than calls checks for Program Type field toggling. + */ +function waitForEditorLoading() { + var checkContent = setInterval(function() { + var $modal = $('.xblock-editor'); + var content = $modal.html(); + if (content) { + clearInterval(checkContent); + checkProblemTypeShouldBeVisible($modal); + } + }, 10); +} +// Initial call +waitForEditorLoading(); + +var $librarySelect = $("select[name='Library']"); +$(document).on('change', $librarySelect, waitForEditorLoading) + +var $libraryContentEditors = $('.xblock-header.xblock-header-library_content'); +var $editBtns = $libraryContentEditors.find('.action-item.action-edit'); +$(document).on('click', $editBtns, waitForEditorLoading) diff --git a/multi_problem_xblock/public/js/multi_problem_xblock.js b/multi_problem_xblock/public/js/multi_problem_xblock.js new file mode 100644 index 0000000..7d8c2a7 --- /dev/null +++ b/multi_problem_xblock/public/js/multi_problem_xblock.js @@ -0,0 +1,193 @@ +function MultiProblemBlock(runtime, element, initArgs) { + "use strict"; + var $element = $(element); + var bookmarkButtonHandlers = []; + + var gettext; + var ngettext; + if ('gettext' in window) { + // Use edxapp's global translations + gettext = window.gettext; + ngettext = window.ngettext; + } + if (typeof gettext == "undefined") { + // No translations -- used by test environment + gettext = function(string) { return string; }; + ngettext = function(strA, strB, n) { return n == 1 ? strA : strB; }; + } + + + var { + current_slide: currentSlide = 0, + next_page_on_submit: nextPageOnSubmit = false, + } = initArgs; + + function showSlide(num) { + var slides = $('.slide', element); + slides[num].style.display = "block"; + //... and fix the Previous/Next buttons: + if (num == 0) { + $(".prevBtn", element).prop('disabled', true); + } else { + $(".prevBtn", element).prop('disabled', false); + } + if (num >= (slides.length - 1)) { + $(".nextBtn", element).prop('disabled', true); + } else { + $(".nextBtn", element).prop('disabled', false); + } + //... and run a function that will display the correct step indicator: + updateStepIndicator(num, slides.length) + } + + function updateStepIndicator(num, total) { + $('.slide-position', element).text( + gettext('{current_position} of {total}').replace('{current_position}', num + 1).replace('{total}', total) + ); + $.post({ + url: runtime.handlerUrl(element, 'handle_slide_change'), + data: JSON.stringify({ current_slide: num }), + }); + } + + function nextPrev(num) { + // This function will figure out which tab to display + var slides = $('.slide', element); + // Calculate next slide position + var nextSlide = currentSlide + num; + // if you have reached the end of the form... + if (nextSlide >= slides.length) { + return false; + } + // Hide the current tab: + slides[currentSlide].style.display = "none"; + currentSlide = nextSlide; + // Otherwise, display the correct tab: + showSlide(nextSlide); + } + + $('.nextBtn', element).click((e) => nextPrev(1)); + $('.prevBtn', element).click((e) => nextPrev(-1)); + + /** + * Reset problems in the given block + * @param {click even} e + */ + function resetProblems(e) { + e.preventDefault(); + // remove all bookmarks under this block as it is possible that a + // bookmarked block is not selected on reset + bookmarkButtonHandlers.forEach(function (bookmarkButtonHander) { + bookmarkButtonHander.removeBookmark(); + }); + + $.post({ + url: runtime.handlerUrl(element, 'reset_selected_children'), + success(data) { + edx.HtmlUtils.setHtml(element, edx.HtmlUtils.HTML(data)); + // Rebind the reset button for the block + XBlock.initializeBlock(element); + // Render the new set of problems (XBlocks) + $(".xblock", element).each(function(i, child) { + XBlock.initializeBlock(child); + }); + }, + }); + } + + $('.problem-reset-btn', element).click(resetProblems.bind(this)); + $('.redo-test', element).click(resetProblems.bind(this)); + + var $problems = $(element).find('.problems-wrapper'); + var $progressBar = $(element).find('.progress-bar'); + var $resultsBtn = $(element).find('.see-test-results'); + + $problems.each(function() { + $(this).on("progressChanged", function() { + $.get(runtime.handlerUrl(element, 'get_overall_progress'), function( data ) { + $progressBar.css('width', data.overall_progress + '%'); + $progressBar.attr('aria-valuenow', data.overall_progress); + if (data.overall_progress < 100) { + $resultsBtn.prop('disabled', true); + } else { + $resultsBtn.prop('disabled', false); + } + }); + // initArgs.nextPageOnSubmit loose value on reset, so confirm value from html template + if ((nextPageOnSubmit || $('.multi-problem-container', element).data('nextPageOnSubmit'))) { + nextPrev(1); + } + }); + }); + + $('.see-test-results', element).click((e) => { + e.preventDefault(); + $.ajax({ + url: runtime.handlerUrl(element, 'get_test_scores'), + type: 'GET', + dataType: 'html', + success: function( data ) { + $('.problem-test-score-container', element).show(); + $('.problem-test-score-container', element).html(data); + if ($('.back-to-problems', element).length) { + $('.problem-slides-container', element).hide(); + } else { + $('.problem-slides-container', element).remove(); + } + var $accordions = $(element).find('.accordion'); + + $('.back-to-problems', element).click((e) => { + $('.problem-test-score-container', element).hide(); + $('.problem-slides-container', element).show(); + $('.see-test-results', element).show(); + $('.problem-reset-btn', element).show(); + $('.redo-test', element).hide(); + }); + + $accordions.each(function() { + $(this).click(function() { + var $that = $(this); + $accordions.each(function() { + if (!$(this).is($that)) { + $(this).removeClass("active"); + this.nextElementSibling.style.maxHeight = null; + } + }); + $(this).toggleClass("active"); + var panel = this.nextElementSibling; + if (panel.style.maxHeight) { + panel.style.maxHeight = null; + } else { + panel.style.maxHeight = panel.scrollHeight + "px"; + } + }); + }); + + $('.see-test-results', element).hide(); + $('.problem-reset-btn', element).hide(); + $('.redo-test', element).show(); + } + }); + }) + + // If user has already completed all problems, display test score slide + if (currentSlide === -1) { + $('.see-test-results', element).trigger('click'); + } else { + showSlide(currentSlide) + } + + window.RequireJS.require(['course_bookmarks/js/views/bookmark_button'], function(BookmarkButton) { + var $bookmarkButtonElements = $element.find('.multi-problem-bookmark-buttons'); + $bookmarkButtonElements.each(function() { + bookmarkButtonHandlers.push(new BookmarkButton({ + el: $(this), + bookmarkId: $(this).data('bookmarkId'), + usageId: $(this).parent().parent().data('id'), + bookmarked: $(this).data('isBookmarked'), + apiUrl: $(this).data('bookmarksApiUrl'), + bookmarkText: gettext('Bookmark this question'), + })); + }); + }); +} diff --git a/multi_problem_xblock/public/js/translations/en/text.js b/multi_problem_xblock/public/js/translations/en/text.js index d8dca51..e36ca92 100644 --- a/multi_problem_xblock/public/js/translations/en/text.js +++ b/multi_problem_xblock/public/js/translations/en/text.js @@ -1,6 +1,6 @@ (function(global){ - var DragAndDropI18N = { + var MultiProblemI18N = { init: function() { @@ -10,14 +10,7 @@ const django = globals.django || (globals.django = {}); - django.pluralidx = function(n) { - const v = (n != 1); - if (typeof v === 'boolean') { - return v ? 1 : 0; - } else { - return v; - } - }; + django.pluralidx = function(count) { return (count == 1) ? 0 : 1; }; /* gettext library */ @@ -91,7 +84,15 @@ "DATE_INPUT_FORMATS": [ "%Y-%m-%d", "%m/%d/%Y", - "%m/%d/%y" + "%m/%d/%y", + "%b %d %Y", + "%b %d, %Y", + "%d %b %Y", + "%d %b, %Y", + "%B %d %Y", + "%B %d, %Y", + "%d %B %Y", + "%d %B, %Y" ], "DECIMAL_SEPARATOR": ".", "FIRST_DAY_OF_WEEK": 0, @@ -135,7 +136,7 @@ } }; - DragAndDropI18N.init(); - global.DragAndDropI18N = DragAndDropI18N; + MultiProblemI18N.init(); + global.MultiProblemI18N = MultiProblemI18N; }(this)); \ No newline at end of file diff --git a/multi_problem_xblock/templates/html/multi_problem_xblock.html b/multi_problem_xblock/templates/html/multi_problem_xblock.html new file mode 100644 index 0000000..20cb973 --- /dev/null +++ b/multi_problem_xblock/templates/html/multi_problem_xblock.html @@ -0,0 +1,81 @@ +{% load i18n %} + +
+ +
+
+
+ + {% if self.current_slide != -1 %} +
+
+ +
+ + {% blocktrans with items_length=items|length current_slide=self.current_slide %} + {{ current_slide }} of {{ items_length }} + {% endblocktrans %} + + {% if self.display_name %} +

{{ self.display_name }}

+ {% endif %} +
+ +
+
+ {% for item in items %} + {% if item.content %} +
+ {{ item.content|safe }} + {% if bookmarks_service_enabled %} +
+ +
+ {% endif %} +
+ {% endif %} + {% endfor %} +
+
+ {% endif %} + +
+
+
+ +
+ {% if reset_button %} + + + {% endif %} + {% if show_results %} + + {% endif %} +
diff --git a/multi_problem_xblock/templates/html/multi_problem_xblock_test_scores.html b/multi_problem_xblock/templates/html/multi_problem_xblock_test_scores.html new file mode 100644 index 0000000..f3c9b4c --- /dev/null +++ b/multi_problem_xblock/templates/html/multi_problem_xblock_test_scores.html @@ -0,0 +1,70 @@ +{% load i18n %} + + +
+
+ {% if allow_back_button %} + + {% else %} + +
+ {% endif %} +
+ + {% trans 'Complete!' %} + +

{% trans 'Test score' %}

+
+
+
+ {% for question_answer in question_answers %} + +
+ {% if question_answer.is_correct %} +
+ + {% trans 'Correct: ' %} {{ question_answer.answer }} + +
+ {{ question_answer.msg | safe }} +
+
+ {% else %} +
+ + {% trans 'Your Answer: ' %} {{ question_answer.answer }} + +
+ {% if question_answer.correct_answer %} +
+ + {% trans 'Correct Answer: ' %} {{ question_answer.correct_answer }} + +
+ {{ question_answer.msg | safe }} +
+
+ {% endif %} + {% endif %} +
+ {% endfor %} +
+ {% trans 'Test Score' %} + {% if cut_off_score != '' %}1{% endif %} + {{ score }} +
+
+ {% if cut_off_score != '' %} + + 1 + {% trans 'Minimum score required for this to be marked as complete: ' %} + {{ cut_off_score }} + + {% endif %} +
diff --git a/multi_problem_xblock/utils.py b/multi_problem_xblock/utils.py index 5410107..6f05301 100644 --- a/multi_problem_xblock/utils.py +++ b/multi_problem_xblock/utils.py @@ -1,8 +1,5 @@ """ Multi Problem XBlock - Utils """ -import copy -from collections import namedtuple - def _(text): """ Dummy `gettext` replacement to make string extraction tools scrape strings marked for translation """ @@ -23,293 +20,3 @@ class DummyTranslationService: """ gettext = _ ngettext = ngettext_fallback - - -class FeedbackMessages: - """ - Feedback messages collection - """ - class MessageClasses: - """ - Namespace for message classes - """ - CORRECT_SOLUTION = "correct" - PARTIAL_SOLUTION = "partial" - INCORRECT_SOLUTION = "incorrect" - - CORRECTLY_PLACED = CORRECT_SOLUTION - MISPLACED = INCORRECT_SOLUTION - NOT_PLACED = INCORRECT_SOLUTION - - INITIAL_FEEDBACK = "initial" - FINAL_FEEDBACK = "final" - - GRADE_FEEDBACK_TPL = _('Your highest score is {score}') - FINAL_ATTEMPT_TPL = _('Final attempt was used, highest score is {score}') - - @staticmethod - def correctly_placed(number, ngettext=ngettext_fallback): - """ - Formats "correctly placed items" message - """ - return ngettext( - 'Correctly placed {correct_count} item', - 'Correctly placed {correct_count} items', - number - ).format(correct_count=number) - - @staticmethod - def misplaced(number, ngettext=ngettext_fallback): - """ - Formats "misplaced items" message - """ - return ngettext( - 'Misplaced {misplaced_count} item', - 'Misplaced {misplaced_count} items', - number - ).format(misplaced_count=number) - - @staticmethod - def misplaced_returned(number, ngettext=ngettext_fallback): - """ - Formats "misplaced items returned to bank" message - """ - return ngettext( - 'Misplaced {misplaced_count} item (misplaced item was returned to the item bank)', - 'Misplaced {misplaced_count} items (misplaced items were returned to the item bank)', - number - ).format(misplaced_count=number) - - @staticmethod - def not_placed(number, ngettext=ngettext_fallback): - """ - Formats "did not place required items" message - """ - return ngettext( - 'Did not place {missing_count} required item', - 'Did not place {missing_count} required items', - number - ).format(missing_count=number) - - -FeedbackMessage = namedtuple("FeedbackMessage", ["message", "message_class"]) -ItemStats = namedtuple( - 'ItemStats', - ["required", "placed", "correctly_placed", "decoy", "decoy_in_bank"] -) - - -class Constants: - """ - Namespace class for various constants - """ - ALLOWED_ZONE_ALIGNMENTS = ['left', 'right', 'center'] - DEFAULT_ZONE_ALIGNMENT = 'center' - - STANDARD_MODE = "standard" - ASSESSMENT_MODE = "assessment" - ATTR_KEY_USER_IS_STAFF = "edx-platform.user_is_staff" - - -class SHOWANSWER: - """ - Constants for when to show answer - """ - AFTER_ALL_ATTEMPTS = "after_all_attempts" - AFTER_ALL_ATTEMPTS_OR_CORRECT = "after_all_attempts_or_correct" - ALWAYS = "always" - ANSWERED = "answered" - ATTEMPTED = "attempted" - ATTEMPTED_NO_PAST_DUE = "attempted_no_past_due" - CLOSED = "closed" - CORRECT_OR_PAST_DUE = "correct_or_past_due" - DEFAULT = "default" - FINISHED = "finished" - NEVER = "never" - PAST_DUE = "past_due" - - -class StateMigration: - """ - Helper class to apply zone data and item state migrations - """ - def __init__(self, block): - self._block = block - - @staticmethod - def _apply_migration(obj_id, obj, migrations): - """ - Applies migrations sequentially to a copy of an `obj`, to avoid updating actual data - """ - tmp = copy.deepcopy(obj) - for method in migrations: - tmp = method(obj_id, tmp) - - return tmp - - def apply_zone_migrations(self, zone): - """ - Applies zone migrations - """ - migrations = (self._zone_v1_to_v2, self._zone_v2_to_v2p1) - zone_id = zone.get('uid', zone.get('id')) - - return self._apply_migration(zone_id, zone, migrations) - - def apply_item_state_migrations(self, item_id, item_state): - """ - Applies item_state migrations - """ - migrations = (self._item_state_v1_to_v1p5, self._item_state_v1p5_to_v2, self._item_state_v2_to_v2p1) - - return self._apply_migration(item_id, item_state, migrations) - - @classmethod - def _zone_v1_to_v2(cls, unused_zone_id, zone): - """ - Migrates zone data from v1.0 format to v2.0 format. - - Changes: - * v1 used zone "title" as UID, while v2 zone has dedicated "uid" property - * "id" and "index" properties are no longer used - - In: {'id': 1, 'index': 2, 'title': "Zone", ...} - Out: {'uid': "Zone", ...} - """ - if "uid" not in zone: - zone["uid"] = zone.get("title") - zone.pop("id", None) - zone.pop("index", None) - - return zone - - @classmethod - def _zone_v2_to_v2p1(cls, unused_zone_id, zone): - """ - Migrates zone data from v2.0 to v2.1 - - Changes: - * Removed "none" zone alignment; default align is "center" - - In: { - 'uid': "Zone", "align": "none", - "x_percent": "10%", "y_percent": "10%", "width_percent": "10%", "height_percent": "10%" - } - Out: { - 'uid': "Zone", "align": "center", - "x_percent": "10%", "y_percent": "10%", "width_percent": "10%", "height_percent": "10%" - } - """ - if zone.get('align', None) not in Constants.ALLOWED_ZONE_ALIGNMENTS: - zone['align'] = Constants.DEFAULT_ZONE_ALIGNMENT - - return zone - - @classmethod - def _item_state_v1_to_v1p5(cls, unused_item_id, item): - """ - Migrates item_state from v1.0 to v1.5 - - Changes: - * Item state is now a dict instead of tuple - - In: ('100px', '120px') - Out: {'top': '100px', 'left': '120px'} - """ - if isinstance(item, dict): - return item - else: - return {'top': item[0], 'left': item[1]} - - @classmethod - def _item_state_v1p5_to_v2(cls, unused_item_id, item): - """ - Migrates item_state from v1.5 to v2.0 - - Changes: - * Item placement attributes switched from absolute (left-top) to relative (x_percent-y_percent) units - - In: {'zone': 'Zone", 'correct': True, 'top': '100px', 'left': '120px'} - Out: {'zone': 'Zone", 'correct': True, 'top': '100px', 'left': '120px'} - """ - # Conversion can't be made as parent dimensions are unknown to python - converted in JS - # Since 2.1 JS this conversion became unnecesary, so it was removed from JS code - return item - - def _item_state_v2_to_v2p1(self, item_id, item): - """ - Migrates item_state from v2.0 to v2.1 - - * Single item can correspond to multiple zones - "zone" key is added to each item - * Assessment mode - "correct" key is added to each item - * Removed "no zone align" option; only automatic alignment is now allowed - removes attributes related to - "absolute" placement of an item (relative to background image, as opposed to the zone) - """ - self._multiple_zones_migration(item_id, item) - self._assessment_mode_migration(item) - self._automatic_alignment_migration(item) - - return item - - def _multiple_zones_migration(self, item_id, item): - """ - Changes: - * Adds "zone" attribute - - In: {'item_id': 0} - Out: {'zone': 'Zone", 'item_id": 0} - - In: {'item_id': 1} - Out: {'zone': 'unknown", 'item_id": 1} - """ - if item.get('zone') is None: - valid_zones = self._block.get_item_zones(int(item_id)) - if valid_zones: - # If we get to this point, then the item was placed prior to support for - # multiple correct zones being added. As a result, it can only be correct - # on a single zone, and so we can trust that the item was placed on the - # zone with index 0. - item['zone'] = valid_zones[0] - else: - item['zone'] = 'unknown' - - @classmethod - def _assessment_mode_migration(cls, item): - """ - Changes: - * Adds "correct" attribute if missing - - In: {'item_id': 0} - Out: {'item_id': 'correct': True} - - In: {'item_id': 0, 'correct': True} - Out: {'item_id': 'correct': True} - - In: {'item_id': 0, 'correct': False} - Out: {'item_id': 'correct': False} - """ - # If correctness information is missing - # (because problem was completed before assessment mode was implemented), - # assume the item is in correct zone (in standard mode, only items placed - # into correct zone are stored in item state). - if item.get('correct') is None: - item['correct'] = True - - @classmethod - def _automatic_alignment_migration(cls, item): - """ - Changes: - * Removed old "absolute" placement attributes - * Removed "none" zone alignment, making "x_percent" and "y_percent" attributes obsolete - - In: {'zone': 'Zone", 'correct': True, 'top': '100px', 'left': '120px', 'absolute': true} - Out: {'zone': 'Zone", 'correct': True} - - In: {'zone': 'Zone", 'correct': True, 'x_percent': '90%', 'y_percent': '20%'} - Out: {'zone': 'Zone", 'correct': True} - """ - attributes_to_remove = ['x_percent', 'y_percent', 'left', 'top', 'absolute'] - for attribute in attributes_to_remove: - item.pop(attribute, None) - - return item diff --git a/requirements/base.txt b/requirements/base.txt index 91495d7..0abfafd 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -8,13 +8,17 @@ appdirs==1.4.4 # via fs asgiref==3.8.1 # via django -boto3==1.34.130 +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -c requirements/constraints.txt + # django +boto3==1.34.156 # via fs-s3fs -botocore==1.34.130 +botocore==1.34.156 # via # boto3 # s3transfer -django==4.2.13 +django==4.2.15 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # django-appconf @@ -53,9 +57,9 @@ python-dateutil==2.9.0.post0 # xblock pytz==2024.1 # via xblock -pyyaml==6.0.1 +pyyaml==6.0.2 # via xblock -s3transfer==0.10.1 +s3transfer==0.10.2 # via boto3 simplejson==3.19.2 # via xblock @@ -64,16 +68,22 @@ six==1.16.0 # fs # fs-s3fs # python-dateutil -sqlparse==0.5.0 +sqlparse==0.5.1 # via django -urllib3==2.2.2 - # via botocore +typing-extensions==4.12.2 + # via asgiref +urllib3==1.26.19 ; python_version < "3.10" + # via + # -c requirements/constraints.txt + # botocore web-fragments==2.2.0 # via xblock webob==1.8.7 # via xblock -xblock[django]==4.0.1 - # via -r requirements/base.in +xblock[django]==4.0.1 ; python_version < "3.10" + # via + # -c requirements/constraints.txt + # -r requirements/base.in # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/ci.txt b/requirements/ci.txt index d1996e4..c827579 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -1,10 +1,10 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade # -cachetools==5.3.3 +cachetools==5.4.0 # via tox chardet==5.2.0 # via tox @@ -12,7 +12,7 @@ colorama==0.4.6 # via tox distlib==0.3.8 # via virtualenv -filelock==3.15.3 +filelock==3.15.4 # via # tox # virtualenv @@ -26,9 +26,13 @@ platformdirs==4.2.2 # virtualenv pluggy==1.5.0 # via tox -pyproject-api==1.6.1 +pyproject-api==1.7.1 # via tox -tox==4.15.1 +tomli==2.0.1 + # via + # pyproject-api + # tox +tox==4.17.1 # via -r requirements/ci.in -virtualenv==20.26.2 +virtualenv==20.26.3 # via tox diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 5441db0..e58eb55 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -11,5 +11,9 @@ # Common constraints for edx repos -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt +# See https://github.com/openedx/i18n-tools/pull/148/files#diff-86d5fe588ff2fc7dccb1f4cdd8019d4473146536e88d7a9ede946ea962a91acbR23 +path<=16.16.0 +urllib3<1.27; python_version < "3.10" +xblock<5.0.0; python_version < "3.10" # For python greater than or equal to 3.9 backports.zoneinfo causing failures backports.zoneinfo; python_version<"3.9" diff --git a/requirements/dev.txt b/requirements/dev.txt index 99fde7c..5dfbf17 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -16,20 +16,25 @@ asgiref==3.8.1 # via # -r requirements/quality.txt # django -astroid==3.2.2 +astroid==3.2.4 # via # -r requirements/quality.txt # pylint # pylint-celery +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -c requirements/constraints.txt + # -r requirements/quality.txt + # django binaryornot==0.4.4 # via # -r requirements/quality.txt # cookiecutter -boto3==1.34.130 +boto3==1.34.156 # via # -r requirements/quality.txt # fs-s3fs -botocore==1.34.130 +botocore==1.34.156 # via # -r requirements/quality.txt # boto3 @@ -38,11 +43,11 @@ build==1.2.1 # via # -r requirements/pip-tools.txt # pip-tools -cachetools==5.3.3 +cachetools==5.4.0 # via # -r requirements/ci.txt # tox -certifi==2024.6.2 +certifi==2024.7.4 # via # -r requirements/quality.txt # requests @@ -81,7 +86,7 @@ cookiecutter==2.6.0 # via # -r requirements/quality.txt # xblock-sdk -coverage[toml]==7.5.3 +coverage[toml]==7.6.1 # via # -r requirements/quality.txt # pytest-cov @@ -95,7 +100,7 @@ distlib==0.3.8 # via # -r requirements/ci.txt # virtualenv -django==4.2.13 +django==4.2.15 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/quality.txt @@ -110,11 +115,15 @@ django-appconf==1.0.6 # django-statici18n django-statici18n==2.5.0 # via -r requirements/quality.txt -edx-i18n-tools==1.6.0 +edx-i18n-tools==1.6.2 # via -r requirements/quality.txt -edx-lint==5.3.6 +edx-lint==5.3.7 # via -r requirements/quality.txt -filelock==3.15.3 +exceptiongroup==1.2.2 + # via + # -r requirements/quality.txt + # pytest +filelock==3.15.4 # via # -r requirements/ci.txt # tox @@ -134,6 +143,11 @@ idna==3.7 # via # -r requirements/quality.txt # requests +importlib-metadata==6.11.0 + # via + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -r requirements/pip-tools.txt + # build iniconfig==2.0.0 # via # -r requirements/quality.txt @@ -156,15 +170,12 @@ lazy==1.6 # via # -r requirements/quality.txt # xblock -lxml[html-clean]==5.2.2 +lxml==5.2.2 # via # -r requirements/quality.txt # edx-i18n-tools - # lxml-html-clean # xblock # xblock-sdk -lxml-html-clean==0.1.1 - # via -r requirements/quality.txt mako==1.3.5 # via # -r requirements/quality.txt @@ -202,8 +213,9 @@ packaging==24.1 # pyproject-api # pytest # tox -path==16.14.0 +path==16.16.0 # via + # -c requirements/constraints.txt # -r requirements/quality.txt # edx-i18n-tools pbr==6.0.0 @@ -229,13 +241,13 @@ polib==1.2.0 # via # -r requirements/quality.txt # edx-i18n-tools -pycodestyle==2.12.0 +pycodestyle==2.12.1 # via -r requirements/quality.txt pygments==2.18.0 # via # -r requirements/quality.txt # rich -pylint==3.2.3 +pylint==3.2.6 # via # -r requirements/quality.txt # edx-lint @@ -259,7 +271,7 @@ pypng==0.20220715.0 # via # -r requirements/quality.txt # xblock-sdk -pyproject-api==1.6.1 +pyproject-api==1.7.1 # via # -r requirements/ci.txt # tox @@ -268,7 +280,7 @@ pyproject-hooks==1.1.0 # -r requirements/pip-tools.txt # build # pip-tools -pytest==8.2.2 +pytest==8.3.2 # via # -r requirements/quality.txt # pytest-cov @@ -292,7 +304,7 @@ pytz==2024.1 # via # -r requirements/quality.txt # xblock -pyyaml==6.0.1 +pyyaml==6.0.2 # via # -r requirements/quality.txt # code-annotations @@ -308,7 +320,7 @@ rich==13.7.1 # via # -r requirements/quality.txt # cookiecutter -s3transfer==0.10.1 +s3transfer==0.10.2 # via # -r requirements/quality.txt # boto3 @@ -324,7 +336,7 @@ six==1.16.0 # fs # fs-s3fs # python-dateutil -sqlparse==0.5.0 +sqlparse==0.5.1 # via # -r requirements/quality.txt # django @@ -336,22 +348,42 @@ text-unidecode==1.3 # via # -r requirements/quality.txt # python-slugify -tomlkit==0.12.5 +tomli==2.0.1 + # via + # -r requirements/ci.txt + # -r requirements/pip-tools.txt + # -r requirements/quality.txt + # build + # coverage + # pip-tools + # pylint + # pyproject-api + # pytest + # tox +tomlkit==0.13.0 # via # -r requirements/quality.txt # pylint -tox==4.15.1 +tox==4.17.1 # via -r requirements/ci.txt types-python-dateutil==2.9.0.20240316 # via # -r requirements/quality.txt # arrow -urllib3==2.2.2 +typing-extensions==4.12.2 # via # -r requirements/quality.txt + # asgiref + # astroid + # pylint + # rich +urllib3==1.26.19 ; python_version < "3.10" + # via + # -c requirements/constraints.txt + # -r requirements/quality.txt # botocore # requests -virtualenv==20.26.2 +virtualenv==20.26.3 # via # -r requirements/ci.txt # tox @@ -365,16 +397,21 @@ webob==1.8.7 # -r requirements/quality.txt # xblock # xblock-sdk -wheel==0.43.0 +wheel==0.44.0 # via # -r requirements/pip-tools.txt # pip-tools -xblock[django]==4.0.1 +xblock[django]==4.0.1 ; python_version < "3.10" # via + # -c requirements/constraints.txt # -r requirements/quality.txt # xblock-sdk xblock-sdk==0.11.0 # via -r requirements/quality.txt +zipp==3.19.2 + # via + # -r requirements/pip-tools.txt + # importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index b544e9f..d479a49 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -8,6 +8,10 @@ build==1.2.1 # via pip-tools click==8.1.7 # via pip-tools +importlib-metadata==6.11.0 + # via + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # build packaging==24.1 # via build pip-tools==7.4.1 @@ -16,8 +20,14 @@ pyproject-hooks==1.1.0 # via # build # pip-tools -wheel==0.43.0 +tomli==2.0.1 + # via + # build + # pip-tools +wheel==0.44.0 # via pip-tools +zipp==3.19.2 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/pip.txt b/requirements/pip.txt index a1e3371..a056b76 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -1,14 +1,14 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade # -wheel==0.43.0 +wheel==0.44.0 # via -r requirements/pip.in # The following packages are considered to be unsafe in a requirements file: -pip==24.0 +pip==24.2 # via -r requirements/pip.in -setuptools==70.1.0 +setuptools==72.1.0 # via -r requirements/pip.in diff --git a/requirements/quality.txt b/requirements/quality.txt index 8be459f..a216abf 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -16,24 +16,29 @@ asgiref==3.8.1 # via # -r requirements/test.txt # django -astroid==3.2.2 +astroid==3.2.4 # via # pylint # pylint-celery +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -c requirements/constraints.txt + # -r requirements/test.txt + # django binaryornot==0.4.4 # via # -r requirements/test.txt # cookiecutter -boto3==1.34.130 +boto3==1.34.156 # via # -r requirements/test.txt # fs-s3fs -botocore==1.34.130 +botocore==1.34.156 # via # -r requirements/test.txt # boto3 # s3transfer -certifi==2024.6.2 +certifi==2024.7.4 # via # -r requirements/test.txt # requests @@ -60,7 +65,7 @@ cookiecutter==2.6.0 # via # -r requirements/test.txt # xblock-sdk -coverage[toml]==7.5.3 +coverage[toml]==7.6.1 # via # -r requirements/test.txt # pytest-cov @@ -68,7 +73,7 @@ ddt==1.7.2 # via -r requirements/test.txt dill==0.3.8 # via pylint -django==4.2.13 +django==4.2.15 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt @@ -83,10 +88,14 @@ django-appconf==1.0.6 # django-statici18n django-statici18n==2.5.0 # via -r requirements/test.txt -edx-i18n-tools==1.6.0 +edx-i18n-tools==1.6.2 # via -r requirements/test.txt -edx-lint==5.3.6 +edx-lint==5.3.7 # via -r requirements/quality.in +exceptiongroup==1.2.2 + # via + # -r requirements/test.txt + # pytest fs==2.4.16 # via # -r requirements/test.txt @@ -122,15 +131,12 @@ lazy==1.6 # via # -r requirements/test.txt # xblock -lxml[html-clean]==5.2.2 +lxml==5.2.2 # via # -r requirements/test.txt # edx-i18n-tools - # lxml-html-clean # xblock # xblock-sdk -lxml-html-clean==0.1.1 - # via -r requirements/test.txt mako==1.3.5 # via # -r requirements/test.txt @@ -161,8 +167,9 @@ packaging==24.1 # via # -r requirements/test.txt # pytest -path==16.14.0 +path==16.16.0 # via + # -c requirements/constraints.txt # -r requirements/test.txt # edx-i18n-tools pbr==6.0.0 @@ -177,13 +184,13 @@ polib==1.2.0 # via # -r requirements/test.txt # edx-i18n-tools -pycodestyle==2.12.0 +pycodestyle==2.12.1 # via -r requirements/quality.in pygments==2.18.0 # via # -r requirements/test.txt # rich -pylint==3.2.3 +pylint==3.2.6 # via # edx-lint # pylint-celery @@ -201,7 +208,7 @@ pypng==0.20220715.0 # via # -r requirements/test.txt # xblock-sdk -pytest==8.2.2 +pytest==8.3.2 # via # -r requirements/test.txt # pytest-cov @@ -225,7 +232,7 @@ pytz==2024.1 # via # -r requirements/test.txt # xblock -pyyaml==6.0.1 +pyyaml==6.0.2 # via # -r requirements/test.txt # code-annotations @@ -241,7 +248,7 @@ rich==13.7.1 # via # -r requirements/test.txt # cookiecutter -s3transfer==0.10.1 +s3transfer==0.10.2 # via # -r requirements/test.txt # boto3 @@ -257,7 +264,7 @@ six==1.16.0 # fs # fs-s3fs # python-dateutil -sqlparse==0.5.0 +sqlparse==0.5.1 # via # -r requirements/test.txt # django @@ -267,14 +274,28 @@ text-unidecode==1.3 # via # -r requirements/test.txt # python-slugify -tomlkit==0.12.5 +tomli==2.0.1 + # via + # -r requirements/test.txt + # coverage + # pylint + # pytest +tomlkit==0.13.0 # via pylint types-python-dateutil==2.9.0.20240316 # via # -r requirements/test.txt # arrow -urllib3==2.2.2 +typing-extensions==4.12.2 + # via + # -r requirements/test.txt + # asgiref + # astroid + # pylint + # rich +urllib3==1.26.19 ; python_version < "3.10" # via + # -c requirements/constraints.txt # -r requirements/test.txt # botocore # requests @@ -288,8 +309,9 @@ webob==1.8.7 # -r requirements/test.txt # xblock # xblock-sdk -xblock[django]==4.0.1 +xblock[django]==4.0.1 ; python_version < "3.10" # via + # -c requirements/constraints.txt # -r requirements/test.txt # xblock-sdk xblock-sdk==0.11.0 diff --git a/requirements/test.txt b/requirements/test.txt index 93b1c99..c12d6a9 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -14,18 +14,23 @@ asgiref==3.8.1 # via # -r requirements/base.txt # django +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -c requirements/constraints.txt + # -r requirements/base.txt + # django binaryornot==0.4.4 # via cookiecutter -boto3==1.34.130 +boto3==1.34.156 # via # -r requirements/base.txt # fs-s3fs -botocore==1.34.130 +botocore==1.34.156 # via # -r requirements/base.txt # boto3 # s3transfer -certifi==2024.6.2 +certifi==2024.7.4 # via requests chardet==5.2.0 # via binaryornot @@ -35,7 +40,7 @@ click==8.1.7 # via cookiecutter cookiecutter==2.6.0 # via xblock-sdk -coverage[toml]==7.5.3 +coverage[toml]==7.6.1 # via pytest-cov ddt==1.7.2 # via -r requirements/test.in @@ -53,8 +58,10 @@ django-appconf==1.0.6 # django-statici18n django-statici18n==2.5.0 # via -r requirements/base.txt -edx-i18n-tools==1.6.0 +edx-i18n-tools==1.6.2 # via -r requirements/test.in +exceptiongroup==1.2.2 + # via pytest fs==2.4.16 # via # -r requirements/base.txt @@ -81,15 +88,12 @@ lazy==1.6 # via # -r requirements/base.txt # xblock -lxml[html-clean]==5.2.2 +lxml==5.2.2 # via # -r requirements/base.txt # edx-i18n-tools - # lxml-html-clean # xblock # xblock-sdk -lxml-html-clean==0.1.1 - # via lxml mako==1.3.5 # via # -r requirements/base.txt @@ -113,8 +117,10 @@ openedx-django-pyfs==3.6.0 # xblock packaging==24.1 # via pytest -path==16.14.0 - # via edx-i18n-tools +path==16.16.0 + # via + # -c requirements/constraints.txt + # edx-i18n-tools pluggy==1.5.0 # via pytest polib==1.2.0 @@ -123,7 +129,7 @@ pygments==2.18.0 # via rich pypng==0.20220715.0 # via xblock-sdk -pytest==8.2.2 +pytest==8.3.2 # via # pytest-cov # pytest-django @@ -143,7 +149,7 @@ pytz==2024.1 # via # -r requirements/base.txt # xblock -pyyaml==6.0.1 +pyyaml==6.0.2 # via # -r requirements/base.txt # cookiecutter @@ -155,7 +161,7 @@ requests==2.32.3 # xblock-sdk rich==13.7.1 # via cookiecutter -s3transfer==0.10.1 +s3transfer==0.10.2 # via # -r requirements/base.txt # boto3 @@ -170,16 +176,26 @@ six==1.16.0 # fs # fs-s3fs # python-dateutil -sqlparse==0.5.0 +sqlparse==0.5.1 # via # -r requirements/base.txt # django text-unidecode==1.3 # via python-slugify +tomli==2.0.1 + # via + # coverage + # pytest types-python-dateutil==2.9.0.20240316 # via arrow -urllib3==2.2.2 +typing-extensions==4.12.2 + # via + # -r requirements/base.txt + # asgiref + # rich +urllib3==1.26.19 ; python_version < "3.10" # via + # -c requirements/constraints.txt # -r requirements/base.txt # botocore # requests @@ -193,8 +209,9 @@ webob==1.8.7 # -r requirements/base.txt # xblock # xblock-sdk -xblock[django]==4.0.1 +xblock[django]==4.0.1 ; python_version < "3.10" # via + # -c requirements/constraints.txt # -r requirements/base.txt # xblock-sdk xblock-sdk==0.11.0 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_basics.py b/tests/unit/test_basics.py new file mode 100644 index 0000000..c523f94 --- /dev/null +++ b/tests/unit/test_basics.py @@ -0,0 +1,178 @@ +import unittest +from unittest import mock + +import ddt + +from multi_problem_xblock.compat import L_SHOWANSWER, L_ShowCorrectness +from multi_problem_xblock.multi_problem_xblock import DISPLAYFEEDBACK, SCORE_DISPLAY_FORMAT, MultiProblemBlock + +from ..utils import SampleProblemBlock, TestCaseMixin, instantiate_block + + +@ddt.ddt +class BasicTests(TestCaseMixin, unittest.TestCase): + """ Basic unit tests for the Multi-problem block, using its default settings """ + + def setUp(self): + self.children_ids = [] + self.children = {} + for i in range(3): + usage_key = f'block-v1:edx+cs1+test+type@problem+block@{i}' + problem_block = instantiate_block(SampleProblemBlock, fields={ + 'usage_key': usage_key, + }) + self.children[usage_key] = problem_block + self.children_ids.append(usage_key) + self.block = instantiate_block(MultiProblemBlock, fields={ + 'usage_key': 'block-v1:edx+cs1+test+type@multi_problem+block@1', + 'children': self.children, + }) + self.block.selected_children = lambda: [('problem', child) for child in self.children] + self.block.allow_resetting_children = True + self.patch_workbench() + + @staticmethod + def _make_submission(modify_submission=None): + modify = modify_submission if modify_submission else lambda x: x + + submission = { + 'display_name': "Multi Problem test block", + 'showanswer': L_SHOWANSWER.FINISHED, + 'display_feedback': DISPLAYFEEDBACK.IMMEDIATELY, + 'score_display_format': SCORE_DISPLAY_FORMAT.X_OUT_OF_Y, + 'cut_off_score': 0, + 'next_page_on_submit': False, + } + + modify(submission) + + return submission + + def assertPublishEvent(self, completion): + """ + Verify that publish event is fired with expected event data. + """ + with mock.patch('workbench.runtime.WorkbenchRuntime.publish', mock.Mock()) as patched_publish: + self.block.publish_completion(completion) + expected_calls = [mock.call(self.block, 'completion', {'completion': completion})] + self.assertEqual(patched_publish.mock_calls, expected_calls) + + def test_template_contents(self): + """Verify rendered template contents""" + context = {} + student_fragment = self.block.runtime.render(self.block, 'student_view', context) + self.assertIn( + '
', + student_fragment.content + ) + self.assertIn('
', student_fragment.content) + + def test_student_view_context(self): + """Verify student data used in templates""" + _, template_context, js_context = self.block.student_view_context({}) + items = template_context.pop('items') + self.assertEqual(template_context, { + 'self': self.block, + 'watched_completable_blocks': set(), + 'completion_delay_ms': None, + 'reset_button': True, + 'show_results': True, + 'next_page_on_submit': False, + 'overall_progress': 0, + 'bookmarks_service_enabled': False, + }) + self.assertEqual(js_context, { + 'current_slide': 0, + 'next_page_on_submit': False, + }) + for index, item in enumerate(items): + self.assertEqual(item['id'], self.children_ids[index]) + + def test_editor_saved(self): + """Verify whether child values are updated based on parent block""" + self.block.showanswer = L_SHOWANSWER.NEVER + self.block.display_feedback = DISPLAYFEEDBACK.END_OF_TEST + # Call editor_saved as this is called by cms xblock api before saving the block + self.block.editor_saved(None, None, None) + for child in self.block.get_children(): + self.assertEqual(child.showanswer, L_SHOWANSWER.NEVER) + self.assertEqual(child.show_correctness, L_ShowCorrectness.NEVER) + + # if display_feedback = immediately, child block showanswer should be set to always + self.block.display_feedback = DISPLAYFEEDBACK.IMMEDIATELY + self.block.editor_saved(None, None, None) + for child in self.block.get_children(): + self.assertEqual(child.show_correctness, L_ShowCorrectness.ALWAYS) + + def test_incomplete_overall_progress_handler(self): + """Check progress handler information when all problems are not completed""" + # Check progress handler when 2/3 problems are completed + self.block.children[self.children_ids[0]].is_submitted = lambda: True + self.block.children[self.children_ids[1]].is_submitted = lambda: True + self.block.children[self.children_ids[2]].is_submitted = lambda: False + res = self.call_handler('get_overall_progress', {}, method='GET') + self.assertEqual(res, {'overall_progress': int((2 / 3) * 100)}) + + def test_completed_overall_progress_handler(self): + """Check progress handler information when all problems are completed""" + self.block.publish_completion = mock.Mock() + # Set cut_off_score to 100% + self.block.cut_off_score = 1 + # Check progress handler when 3/3 problems are completed and all are correct + for child in self.block.get_children(): + child.is_submitted = lambda: True + child.is_correct = lambda: True + child.score = mock.Mock(raw_earned=1, raw_possible=1) + res = self.call_handler('get_overall_progress', {}, method='GET') + self.assertEqual(res, {'overall_progress': 100}) + self.block.publish_completion.assert_called_once_with(1) + + # Update one child to be incorrect + self.block.children[self.children_ids[2]].is_correct = lambda: False + self.block.children[self.children_ids[2]].score = mock.Mock(raw_earned=0, raw_possible=1) + res = self.call_handler('get_overall_progress', {}, method='GET') + self.assertEqual(res, {'overall_progress': 100}) + # Completion should be reduced to 0.9 as the student score was less than required cut_off_score + self.block.publish_completion.assert_called_with(0.9) + + def test_get_scores_when_incomplete(self): + """Test get_test_scores handler when all problems are not completed""" + for _, child in enumerate(self.block.get_children()): + child.is_submitted = lambda: False + res = self.call_handler('get_test_scores', {}, expect_json=False, method='GET') + self.assertEqual(res.status_code, 400) + + def test_get_scores(self): + """Test get_test_scores handler""" + for index, child in enumerate(self.block.get_children()): + child.is_submitted = lambda: True + # Set last problem incorrect + child.score = mock.Mock(raw_earned=1 if index < 2 else 0, raw_possible=1) + child.is_correct = lambda: index < 2 # pylint: disable=cell-var-from-loop + res = self.call_handler('get_test_scores', {}, expect_json=False, method='GET') + self.assertIn('question2', res.text) + self.assertIn('answer2', res.text) + self.assertIn('correct_answer2', res.text) + self.assertIn('question1', res.text) + self.assertIn('answer1', res.text) + self.assertIn('question0', res.text) + self.assertIn('answer0', res.text) + self.assertIn('2/3', res.text) + + def test_get_scores_in_percentage(self): + """Test get_test_scores handler returns percentage""" + self.block.score_display_format = SCORE_DISPLAY_FORMAT.PERCENTAGE + for index, child in enumerate(self.block.get_children()): + child.is_submitted = lambda: True + # Set last problem incorrect + child.score = mock.Mock(raw_earned=1 if index < 2 else 0, raw_possible=1) + child.is_correct = lambda: index < 2 # pylint: disable=cell-var-from-loop + res = self.call_handler('get_test_scores', {}, expect_json=False, method='GET') + self.assertIn('question2', res.text) + self.assertIn('answer2', res.text) + self.assertIn('correct_answer2', res.text) + self.assertIn('question1', res.text) + self.assertIn('answer1', res.text) + self.assertIn('question0', res.text) + self.assertIn('answer0', res.text) + self.assertIn('67%', res.text) diff --git a/tests/utils.py b/tests/utils.py index b57672b..1bf1430 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,8 +1,11 @@ - import json -import random +from unittest.mock import MagicMock, Mock, patch +from sample_xblocks.basic.problem import ProblemBlock, String from webob import Request +from workbench.runtime import WorkbenchRuntime +from xblock.core import Scope +from xblock.field_data import DictFieldData def make_request(data, method='POST'): @@ -15,9 +18,65 @@ def make_request(data, method='POST'): return request -def generate_max_and_attempts(count=100): - for _ in range(count): - max_attempts = random.randint(1, 100) - attempts = random.randint(0, 100) - expect_validation_error = max_attempts <= attempts - yield max_attempts, attempts, expect_validation_error +def instantiate_block(cls, fields=None): + """ + Instantiate the given XBlock in a mock runtime. + """ + fields = fields or {} + usage_key = fields.pop('usage_key') + children = fields.pop('children', {}) + field_data = DictFieldData(fields or {}) + block = cls( + runtime=WorkbenchRuntime(), + field_data=field_data, + scope_ids=MagicMock() + ) + block.children = children + block.runtime.get_block = lambda child_id: children[child_id] + block.usage_key.__str__.return_value = usage_key + block.usage_key.course_key.make_usage_key = lambda _, child_id: child_id + block.get_children = lambda: list(children.values()) + return block + + +class SampleProblemBlock(ProblemBlock): + question = String(scope=Scope.content) + showanswer = String(scope=Scope.settings, default="") + show_correctness = String(scope=Scope.settings, default="") + lcp = Mock(student_answers={1: 1}) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Return incremental mock question answer text each time it is called. + self.lcp.find_question_label.side_effect = [f'question{x}' for x in range(3)] + self.lcp.find_answer_text.side_effect = [f'answer{x}' for x in range(3)] + self.lcp.find_correct_answer_text.side_effect = [f'correct_answer{x}' for x in range(3)] + + +class TestCaseMixin: + """ Helpful mixins for unittest TestCase subclasses """ + maxDiff = None + + SLIDE_CHANGE_HANDLER = 'handle_slide_change' + GET_OVERALL_PROGRESS_HANDLER = 'get_overall_progress' + GET_TEST_SCORES = 'get_test_scores' + RESET_HANDLER = 'reset_selected_children' + + def patch_workbench(self): + self.apply_patch( + 'workbench.runtime.WorkbenchRuntime.local_resource_url', + lambda _, _block, path: '/expanded/url/to/multi_problem_xblock/' + path + ) + + def apply_patch(self, *args, **kwargs): + new_patch = patch(*args, **kwargs) + mock = new_patch.start() + self.addCleanup(new_patch.stop) + return mock + + def call_handler(self, handler_name, data=None, expect_json=True, method='POST'): + response = self.block.handle(handler_name, make_request(data, method=method)) + if expect_json: + self.assertEqual(response.status_code, 200) + return json.loads(response.body.decode('utf-8')) + return response