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 %} + +