From 10eb0e36cfe83364fffc3181b5dc2cd3384ca188 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 20 Jun 2024 20:37:33 +0530 Subject: [PATCH 01/27] feat: handle post field edit in studio --- multi_problem_xblock/compat.py | 25 ++++++++++++-- multi_problem_xblock/multi_problem_xblock.py | 34 +++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/multi_problem_xblock/compat.py b/multi_problem_xblock/compat.py index 5b88e2a..1cfdd4c 100644 --- a/multi_problem_xblock/compat.py +++ b/multi_problem_xblock/compat.py @@ -16,7 +16,7 @@ def getLibraryContentBlock(): return LibraryContentBlock -class SHOWANSWER: +class L_SHOWANSWER: """ Local copy of SHOWANSWER from xmodule/capa_block.py in edx-platform """ @@ -34,6 +34,17 @@ class SHOWANSWER: 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: @@ -41,4 +52,14 @@ def getShowAnswerOptions(): return SHOWANSWER except ModuleNotFoundError: log.warning('SHOWANSWER not found, using local copy') - return SHOWANSWER + return L_SHOWANSWER + + +def getShowCorrectnessOptions(): + """Get ShowCorrectness constant from xmodule/graders.py""" + try: + from xmodule.graders import ShowCorrectness + return ShowCorrectness + except ModuleNotFoundError: + log.warning('ShowCorrectness not found, using local copy') + return L_ShowCorrectness diff --git a/multi_problem_xblock/multi_problem_xblock.py b/multi_problem_xblock/multi_problem_xblock.py index 491596d..ea1fdaf 100644 --- a/multi_problem_xblock/multi_problem_xblock.py +++ b/multi_problem_xblock/multi_problem_xblock.py @@ -13,7 +13,7 @@ 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 from .utils import _ # Globals ########################################################### @@ -22,6 +22,7 @@ logger = logging.getLogger(__name__) LibraryContentBlock = getLibraryContentBlock() SHOWANSWER = getShowAnswerOptions() +ShowCorrectness = getShowCorrectnessOptions() # Classes ########################################################### @@ -126,8 +127,39 @@ class MultiProblemBlock( @property def non_editable_metadata_fields(self): + """ + Set current_slide as non editable field + """ 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 + child.show_correctness = ( + ShowCorrectness.ALWAYS if self.display_feedback == DISPLAYFEEDBACK.IMMEDIATELY else ShowCorrectness.NEVER + ) + + def post_editor_saved(self, user, old_metadata, old_content): + """ + Update child field values based on parent block. + child.showanswer <- self.showanswer + child.showanswer <- self.showanswer + child.show_correctness <- ALWAYS if display_feedback == IMMEDIATELY else NEVER + """ + super().post_editor_saved(user, old_metadata, old_content) + for child in self.get_children(): + if hasattr(child, 'showanswer'): + child.showanswer = self.showanswer + if hasattr(child, 'weight'): + child.weight = self.weight + self._process_display_feedback(child) From 25913da086740afb4b98344af183bb1e13510ee2 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 8 Jul 2024 01:24:31 +0530 Subject: [PATCH 02/27] feat: basic html component --- multi_problem_xblock/compat.py | 10 ++ multi_problem_xblock/multi_problem_xblock.py | 168 ++++++++++++------ .../public/css/multi_problem_xblock.css | 16 ++ .../public/js/multi_problem_xblock.js | 81 +++++++++ .../templates/html/multi_problem_xblock.html | 43 +++++ 5 files changed, 262 insertions(+), 56 deletions(-) create mode 100644 multi_problem_xblock/public/css/multi_problem_xblock.css create mode 100644 multi_problem_xblock/public/js/multi_problem_xblock.js create mode 100644 multi_problem_xblock/templates/html/multi_problem_xblock.html diff --git a/multi_problem_xblock/compat.py b/multi_problem_xblock/compat.py index 1cfdd4c..d1f51f8 100644 --- a/multi_problem_xblock/compat.py +++ b/multi_problem_xblock/compat.py @@ -63,3 +63,13 @@ def getShowCorrectnessOptions(): 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 + 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 ea1fdaf..35e7fff 100644 --- a/multi_problem_xblock/multi_problem_xblock.py +++ b/multi_problem_xblock/multi_problem_xblock.py @@ -1,10 +1,13 @@ # -""" Multi Problem XBlock """ +"""Multi Problem XBlock""" # Imports ########################################################### import logging +from copy import copy +from web_fragments.fragment import Fragment +from webob import Response from xblock.core import XBlock from xblock.fields import Float, Integer, Scope, String @@ -13,7 +16,7 @@ except ModuleNotFoundError: # For backward compatibility with releases older than Quince. from xblockutils.resources import ResourceLoader -from .compat import getLibraryContentBlock, getShowAnswerOptions, getShowCorrectnessOptions +from .compat import getLibraryContentBlock, getShowAnswerOptions, getShowCorrectnessOptions, getStudentView from .utils import _ # Globals ########################################################### @@ -23,6 +26,7 @@ LibraryContentBlock = getLibraryContentBlock() SHOWANSWER = getShowAnswerOptions() ShowCorrectness = getShowCorrectnessOptions() +STUDENT_VIEW = getStudentView() # Classes ########################################################### @@ -32,98 +36,100 @@ 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') @XBlock.wants('studio_user_permissions') # Only available in CMS. @XBlock.wants('user') @XBlock.needs('mako') -class MultiProblemBlock( - LibraryContentBlock -): +class MultiProblemBlock(LibraryContentBlock): + # Override LibraryContentBlock resources_dir + resources_dir = '' 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' + ), 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}, - ] + {'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=_('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': 0.1}, + scope=Scope.settings, ) 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.'), 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}, ) - current_slide = Integer( - help=_("Stores current slide/problem number for a user"), - scope=Scope.user_state, - default=0 - ) + 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): @@ -131,9 +137,7 @@ def non_editable_metadata_fields(self): Set current_slide as non editable field """ non_editable_fields = super().non_editable_metadata_fields - non_editable_fields.extend([ - MultiProblemBlock.current_slide - ]) + non_editable_fields.extend([MultiProblemBlock.current_slide]) return non_editable_fields def _process_display_feedback(self, child): @@ -163,3 +167,55 @@ def post_editor_saved(self, user, old_metadata, old_content): if hasattr(child, 'weight'): child.weight = self.weight self._process_display_feedback(child) + + @XBlock.handler + def handle_slide_change(self, request, suffix=None): + """ + Handle slide change request, triggered when user clicks on next or previous button. + """ + try: + data = request.json + except ValueError: + return Response('Invalid request body', status=400) + + self.current_slide = data.get('current_slide') + return Response() + + def student_view(self, context): # lint-amnesty, pylint: disable=missing-function-docstring + fragment = Fragment() + contents = [] + child_context = {} if not context else copy(context) + + for child in self._get_selected_child_blocks(): + 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 + + rendered_child = child.render(STUDENT_VIEW, child_context) + fragment.add_fragment_resources(rendered_child) + contents.append( + { + 'id': str(child.location), + 'content': rendered_child.content, + } + ) + + fragment.add_content( + loader.render_django_template( + '/templates/html/multi_problem_xblock.html', + { + 'items': contents, + 'self': self, + 'show_bookmark_button': False, + 'watched_completable_blocks': set(), + 'completion_delay_ms': None, + 'reset_button': self.allow_resetting_children, + }, + ) + ) + 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') + fragment.json_init_args = {'current_slide': self.current_slide} + return fragment 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..ade4b2b --- /dev/null +++ b/multi_problem_xblock/public/css/multi_problem_xblock.css @@ -0,0 +1,16 @@ +/* Hide all slides by default: */ +.slide { + display: none; +} + +.btn-primary-outline { + border: 1px solid #15376D !important; + background: #FFFFFF; + border-radius: 4px; +} + +.problem-slide-header { + display: flex; + justify-content: space-between; + margin-bottom: 1.5rem; +} 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..48bde0d --- /dev/null +++ b/multi_problem_xblock/public/js/multi_problem_xblock.js @@ -0,0 +1,81 @@ +function MultiProblemBlock(runtime, element, initArgs) { + "use strict"; + + 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 } = initArgs; + showSlide(currentSlide) + + function showSlide(n) { + var slides = $('.slide', element); + slides[n].style.display = "block"; + //... and fix the Previous/Next buttons: + if (n == 0) { + $(".prevBtn", element).prop('disabled', true); + } else { + $(".prevBtn", element).prop('disabled', false); + } + if (n >= (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(n, slides.length) + } + + function updateStepIndicator(n, total) { + $('.slide-position', element).text( + gettext('{current_position} of {total}').replace('{current_position}', n + 1).replace('{total}', total) + ); + $.post({ + url: runtime.handlerUrl(element, 'handle_slide_change'), + data: JSON.stringify({ current_slide: n }), + }); + } + + function nextPrev(n) { + // This function will figure out which tab to display + var slides = $('.slide', element); + // Hide the current tab: + slides[currentSlide].style.display = "none"; + // Increase or decrease the current tab by 1: + currentSlide = currentSlide + n; + // if you have reached the end of the form... + if (currentSlide >= slides.length) { + return false; + } + // Otherwise, display the correct tab: + showSlide(currentSlide); + } + $('.nextBtn', element).click((e) => nextPrev(1)); + $('.prevBtn', element).click((e) => nextPrev(-1)); + + $('.problem-reset-btn', element).click((e) => { + e.preventDefault(); + $.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); + }); + }, + }); + }); +} 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..5a6e042 --- /dev/null +++ b/multi_problem_xblock/templates/html/multi_problem_xblock.html @@ -0,0 +1,43 @@ +{% load i18n %} + +{% if title and show_title %} +

${title}

+{% endif %} + +{% if show_bookmark_button %} + <%include file='bookmark_button.html' args="bookmark_id=bookmark_id, is_bookmarked=bookmarked"/> +{% endif %} + +
+ + + {% blocktrans %}{{ self.current_slide }} of {{ items|length }}{% endblocktrans %} + + +
+
+ {% for item in items %} + {% if item.content %} +
+ {{ item.content|safe }} +
+ {% endif %} + {% endfor %} +
+ +{% if reset_button %} +
+ +
+{% endif %} From bc14287a71e5ee59699a574359e7c6baf5c220e1 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 11 Jul 2024 13:16:08 +0200 Subject: [PATCH 03/27] feat: bookmarks --- multi_problem_xblock/multi_problem_xblock.py | 20 ++++- .../public/css/multi_problem_xblock.css | 41 ++++++++++ .../public/js/multi_problem_xblock.js | 15 ++++ .../templates/html/multi_problem_xblock.html | 79 +++++++++++-------- 4 files changed, 120 insertions(+), 35 deletions(-) diff --git a/multi_problem_xblock/multi_problem_xblock.py b/multi_problem_xblock/multi_problem_xblock.py index 35e7fff..3904d20 100644 --- a/multi_problem_xblock/multi_problem_xblock.py +++ b/multi_problem_xblock/multi_problem_xblock.py @@ -54,7 +54,7 @@ class SCORE_DISPLAY_FORMAT: @XBlock.wants('library_tools') @XBlock.wants('studio_user_permissions') # Only available in CMS. @XBlock.wants('user') -@XBlock.needs('mako') +@XBlock.needs('bookmarks') class MultiProblemBlock(LibraryContentBlock): # Override LibraryContentBlock resources_dir resources_dir = '' @@ -185,19 +185,32 @@ def student_view(self, context): # lint-amnesty, pylint: disable=missing-functi fragment = Fragment() contents = [] child_context = {} if not context else copy(context) + jump_to_id = context.get('jumpToId') + bookmarks_service = self.runtime.service(self, 'bookmarks') - for child in self._get_selected_child_blocks(): + 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, child in enumerate(self._get_selected_child_blocks()): + child_id = str(child.location) 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 rendered_child = child.render(STUDENT_VIEW, child_context) fragment.add_fragment_resources(rendered_child) contents.append( { - 'id': str(child.location), + 'id': child_id, 'content': rendered_child.content, + 'bookmark_id': "{},{}".format(child_context['username'], child_id), + 'is_bookmarked': bookmarks_service.is_bookmarked(usage_key=child.location), } ) @@ -207,7 +220,6 @@ def student_view(self, context): # lint-amnesty, pylint: disable=missing-functi { 'items': contents, 'self': self, - 'show_bookmark_button': False, 'watched_completable_blocks': set(), 'completion_delay_ms': None, 'reset_button': self.allow_resetting_children, diff --git a/multi_problem_xblock/public/css/multi_problem_xblock.css b/multi_problem_xblock/public/css/multi_problem_xblock.css index ade4b2b..400e1d5 100644 --- a/multi_problem_xblock/public/css/multi_problem_xblock.css +++ b/multi_problem_xblock/public/css/multi_problem_xblock.css @@ -1,4 +1,9 @@ /* Hide all slides by default: */ +:root { + --bookmark-icon: "\f097"; /* .fa-bookmark-o */ + --bookmarked-icon: "\f02e"; /* .fa-bookmark */ +} + .slide { display: none; } @@ -7,10 +12,46 @@ border: 1px solid #15376D !important; background: #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; + radius: 4px; +} + +.problem-result-wrapper { + display: flex; + justify-content: space-between; +} + +.see-test-results:hover { + background-color: #065683 !important; + background-image: none !important; +} + +.see-test-results:focus { + background-color: #065683 !important; + background-image: none !important; + box-shadow: none !important; +} + + +.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; } diff --git a/multi_problem_xblock/public/js/multi_problem_xblock.js b/multi_problem_xblock/public/js/multi_problem_xblock.js index 48bde0d..9472e0f 100644 --- a/multi_problem_xblock/public/js/multi_problem_xblock.js +++ b/multi_problem_xblock/public/js/multi_problem_xblock.js @@ -1,5 +1,6 @@ function MultiProblemBlock(runtime, element, initArgs) { "use strict"; + var $element = $(element); var gettext; var ngettext; @@ -78,4 +79,18 @@ function MultiProblemBlock(runtime, element, initArgs) { }, }); }); + + window.RequireJS.require(['course_bookmarks/js/views/bookmark_button'], function(BookmarkButton) { + var $bookmarkButtonElements = $element.find('.multi-problem-bookmark-buttons'); + $bookmarkButtonElements.each(function() { + return 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/templates/html/multi_problem_xblock.html b/multi_problem_xblock/templates/html/multi_problem_xblock.html index 5a6e042..dd64a3f 100644 --- a/multi_problem_xblock/templates/html/multi_problem_xblock.html +++ b/multi_problem_xblock/templates/html/multi_problem_xblock.html @@ -1,43 +1,60 @@ {% load i18n %} -{% if title and show_title %} -

${title}

-{% endif %} - -{% if show_bookmark_button %} - <%include file='bookmark_button.html' args="bookmark_id=bookmark_id, is_bookmarked=bookmarked"/> -{% endif %} - -
- - - {% blocktrans %}{{ self.current_slide }} of {{ items|length }}{% endblocktrans %} - - -
-
- {% for item in items %} - {% if item.content %} -
- {{ item.content|safe }} +
+
+ +
+ + {% blocktrans %}{{ self.current_slide }} of {{ items|length }}{% endblocktrans %} + + {% if self.display_name %} +

{{ self.display_name }}

+ {% endif %} +
+ +
+
+ {% for item in items %} + {% if item.content %} +
+ {{ item.content|safe }} +
+ +
+
+ {% endif %} + {% endfor %}
- {% endif %} - {% endfor %}
{% if reset_button %} -
+
+
{% endif %} From 4a615e1f1bae3abe36b1887cb2d0264b670e1cd2 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 11 Jul 2024 15:22:14 +0200 Subject: [PATCH 04/27] refactor: fix bookmark button alignment --- .../public/css/multi_problem_xblock.css | 11 +++++++++++ .../templates/html/multi_problem_xblock.html | 6 +++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/multi_problem_xblock/public/css/multi_problem_xblock.css b/multi_problem_xblock/public/css/multi_problem_xblock.css index 400e1d5..db09b11 100644 --- a/multi_problem_xblock/public/css/multi_problem_xblock.css +++ b/multi_problem_xblock/public/css/multi_problem_xblock.css @@ -55,3 +55,14 @@ 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; + } +} diff --git a/multi_problem_xblock/templates/html/multi_problem_xblock.html b/multi_problem_xblock/templates/html/multi_problem_xblock.html index dd64a3f..c52a582 100644 --- a/multi_problem_xblock/templates/html/multi_problem_xblock.html +++ b/multi_problem_xblock/templates/html/multi_problem_xblock.html @@ -1,6 +1,6 @@ {% load i18n %} -
+
-
+
{% for item in items %} {% if item.content %}
{{ item.content|safe }} -
+
-
- - {% blocktrans %}{{ self.current_slide }} of {{ items|length }}{% endblocktrans %} - - {% if self.display_name %} -

{{ self.display_name }}

- {% endif %} -
- +
+ +
+
-
- {% for item in items %} - {% if item.content %} -
- {{ item.content|safe }} -
- + +
+
+ +
+ + {% blocktrans %}{{ self.current_slide }} of {{ items|length }}{% endblocktrans %} + + {% if self.display_name %} +

{{ self.display_name }}

+ {% endif %}
+
- {% endif %} - {% endfor %} +
+ {% for item in items %} + {% if item.content %} +
+ {{ item.content|safe }} +
+ +
+
+ {% endif %} + {% endfor %} +
+
+ +
-{% if reset_button %} -
+
+ {% if reset_button %} - + {% endif %} +
-{% 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..3e6330f --- /dev/null +++ b/multi_problem_xblock/templates/html/multi_problem_xblock_test_scores.html @@ -0,0 +1,50 @@ +{% load i18n %} + + +
+
+ + {% 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' %} + {{ score }} +
+
+
From f995a0d580f7b205c23e62d54464d4ff267c5ebb Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 25 Jul 2024 16:06:58 +0530 Subject: [PATCH 06/27] fix: child show_correctness based on display_feedback --- multi_problem_xblock/multi_problem_xblock.py | 23 ++++++-------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/multi_problem_xblock/multi_problem_xblock.py b/multi_problem_xblock/multi_problem_xblock.py index 295bbcd..37e1fa7 100644 --- a/multi_problem_xblock/multi_problem_xblock.py +++ b/multi_problem_xblock/multi_problem_xblock.py @@ -90,16 +90,6 @@ class MultiProblemBlock(LibraryContentBlock): ], ) - 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': 0.1}, - scope=Scope.settings, - ) - display_feedback = String( display_name=_('Display feedback'), help=_('Defines when to show feedback i.e. correctness in the problem slides.'), @@ -150,24 +140,25 @@ def _process_display_feedback(self, child): # 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 - child.show_correctness = ( + # HACK: For some reason, child.show_correctness is not saved if self.show_correctness is not updated. + self.show_correctness = child.show_correctness = ( ShowCorrectness.ALWAYS if self.display_feedback == DISPLAYFEEDBACK.IMMEDIATELY else ShowCorrectness.NEVER ) - def post_editor_saved(self, user, old_metadata, old_content): + def editor_saved(self, user, old_metadata, old_content): """ Update child field values based on parent block. child.showanswer <- self.showanswer - child.showanswer <- self.showanswer + child.weight <- self.weight child.show_correctness <- ALWAYS if display_feedback == IMMEDIATELY else NEVER """ - super().post_editor_saved(user, old_metadata, old_content) + 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 - if hasattr(child, 'weight'): - child.weight = self.weight self._process_display_feedback(child) + child.save() @XBlock.json_handler def handle_slide_change(self, data, suffix=None): From c9eda7c7ac7d441f683e525dcbc6e8d58109a195 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 25 Jul 2024 16:14:24 +0530 Subject: [PATCH 07/27] feat: hide see test results button based on display_feedback --- multi_problem_xblock/multi_problem_xblock.py | 1 + multi_problem_xblock/templates/html/multi_problem_xblock.html | 2 ++ 2 files changed, 3 insertions(+) diff --git a/multi_problem_xblock/multi_problem_xblock.py b/multi_problem_xblock/multi_problem_xblock.py index 37e1fa7..f17a645 100644 --- a/multi_problem_xblock/multi_problem_xblock.py +++ b/multi_problem_xblock/multi_problem_xblock.py @@ -304,6 +304,7 @@ def student_view(self, context): 'watched_completable_blocks': set(), 'completion_delay_ms': None, 'reset_button': self.allow_resetting_children, + 'show_results': self.display_feedback != DISPLAYFEEDBACK.NEVER, 'overall_progress': self._calculate_progress_percentage(completed_problems, total_problems), }, ) diff --git a/multi_problem_xblock/templates/html/multi_problem_xblock.html b/multi_problem_xblock/templates/html/multi_problem_xblock.html index 2dd5c55..dc415f9 100644 --- a/multi_problem_xblock/templates/html/multi_problem_xblock.html +++ b/multi_problem_xblock/templates/html/multi_problem_xblock.html @@ -67,7 +67,9 @@

{{ self.display_name }}

{% trans 'Redo test' %} {% endif %} + {% if show_results %} + {% endif %}
From a4054882c6d19c8df52910f3cf4c49d649cf57f7 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 25 Jul 2024 20:24:05 +0530 Subject: [PATCH 08/27] fix: reset problem bookmark on reset --- .../public/js/multi_problem_xblock.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/multi_problem_xblock/public/js/multi_problem_xblock.js b/multi_problem_xblock/public/js/multi_problem_xblock.js index 7fcdc4e..286ce3f 100644 --- a/multi_problem_xblock/public/js/multi_problem_xblock.js +++ b/multi_problem_xblock/public/js/multi_problem_xblock.js @@ -1,6 +1,7 @@ function MultiProblemBlock(runtime, element, initArgs) { "use strict"; var $element = $(element); + var bookmarkButtonHandlers = []; var gettext; var ngettext; @@ -70,6 +71,12 @@ function MultiProblemBlock(runtime, element, initArgs) { */ 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) { @@ -146,14 +153,14 @@ function MultiProblemBlock(runtime, element, initArgs) { window.RequireJS.require(['course_bookmarks/js/views/bookmark_button'], function(BookmarkButton) { var $bookmarkButtonElements = $element.find('.multi-problem-bookmark-buttons'); $bookmarkButtonElements.each(function() { - return new BookmarkButton({ + 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'), - }); + })); }); }); } From 296112d49fdba8cd500d67a87105cecea238c176 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 26 Jul 2024 13:16:15 +0530 Subject: [PATCH 09/27] feat: auto advance on submit --- multi_problem_xblock/multi_problem_xblock.py | 18 +++++++++++++-- .../public/js/multi_problem_xblock.js | 23 +++++++++++++------ .../templates/html/multi_problem_xblock.html | 2 +- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/multi_problem_xblock/multi_problem_xblock.py b/multi_problem_xblock/multi_problem_xblock.py index f17a645..c5d5df2 100644 --- a/multi_problem_xblock/multi_problem_xblock.py +++ b/multi_problem_xblock/multi_problem_xblock.py @@ -10,7 +10,7 @@ from web_fragments.fragment import Fragment from webob import Response 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 @@ -120,6 +120,15 @@ class MultiProblemBlock(LibraryContentBlock): values={'min': 0, 'step': 0.1, 'max': 1}, ) + 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 @@ -295,6 +304,7 @@ def student_view(self, context): } ) + next_page_on_submit = self.next_page_on_submit and self.display_feedback != DISPLAYFEEDBACK.IMMEDIATELY fragment.add_content( loader.render_django_template( '/templates/html/multi_problem_xblock.html', @@ -305,6 +315,7 @@ def student_view(self, context): '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': self._calculate_progress_percentage(completed_problems, total_problems), }, ) @@ -312,5 +323,8 @@ def student_view(self, 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') - fragment.json_init_args = {'current_slide': self.current_slide} + fragment.json_init_args = { + 'current_slide': self.current_slide, + 'next_page_on_submit': next_page_on_submit, + } return fragment diff --git a/multi_problem_xblock/public/js/multi_problem_xblock.js b/multi_problem_xblock/public/js/multi_problem_xblock.js index 286ce3f..081db1d 100644 --- a/multi_problem_xblock/public/js/multi_problem_xblock.js +++ b/multi_problem_xblock/public/js/multi_problem_xblock.js @@ -17,7 +17,11 @@ function MultiProblemBlock(runtime, element, initArgs) { } - var { current_slide: currentSlide = 0 } = initArgs; + var { + current_slide: currentSlide = 0, + next_page_on_submit: nextPageOnSubmit = false, + } = initArgs; + showSlide(currentSlide) function showSlide(n) { @@ -51,16 +55,17 @@ function MultiProblemBlock(runtime, element, initArgs) { function nextPrev(n) { // This function will figure out which tab to display var slides = $('.slide', element); - // Hide the current tab: - slides[currentSlide].style.display = "none"; - // Increase or decrease the current tab by 1: - currentSlide = currentSlide + n; + // Calculate next slide position + var nextSlide = currentSlide + n; // if you have reached the end of the form... - if (currentSlide >= slides.length) { + if (nextSlide >= slides.length) { return false; } + // Hide the current tab: + slides[currentSlide].style.display = "none"; + currentSlide = nextSlide; // Otherwise, display the correct tab: - showSlide(currentSlide); + showSlide(nextSlide); } $('.nextBtn', element).click((e) => nextPrev(1)); $('.prevBtn', element).click((e) => nextPrev(-1)); @@ -109,6 +114,10 @@ function MultiProblemBlock(runtime, element, initArgs) { $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); + } }); }); diff --git a/multi_problem_xblock/templates/html/multi_problem_xblock.html b/multi_problem_xblock/templates/html/multi_problem_xblock.html index dc415f9..9935aba 100644 --- a/multi_problem_xblock/templates/html/multi_problem_xblock.html +++ b/multi_problem_xblock/templates/html/multi_problem_xblock.html @@ -1,6 +1,6 @@ {% load i18n %} -
+
From 8b104fef351dcadb06044cd9e306b9d2dd01a6a0 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 26 Jul 2024 18:57:31 +0530 Subject: [PATCH 10/27] feat: mark block complete based on cut off score --- multi_problem_xblock/multi_problem_xblock.py | 83 +++++++++++++------- 1 file changed, 56 insertions(+), 27 deletions(-) diff --git a/multi_problem_xblock/multi_problem_xblock.py b/multi_problem_xblock/multi_problem_xblock.py index c5d5df2..a47f063 100644 --- a/multi_problem_xblock/multi_problem_xblock.py +++ b/multi_problem_xblock/multi_problem_xblock.py @@ -9,6 +9,7 @@ from web_fragments.fragment import Fragment from webob import Response +from xblock.completable import XBlockCompletionMode from xblock.core import XBlock from xblock.fields import Boolean, Float, Integer, Scope, String @@ -52,13 +53,15 @@ class SCORE_DISPLAY_FORMAT: X_OUT_OF_Y = 'x_out_of_y' -@XBlock.wants('library_tools') -@XBlock.wants('studio_user_permissions') # Only available in CMS. -@XBlock.wants('user') +@XBlock.wants('library_tools', 'studio_user_permissions', 'user', 'completion') @XBlock.needs('bookmarks') class MultiProblemBlock(LibraryContentBlock): # Override LibraryContentBlock resources_dir resources_dir = '' + + has_custom_completion = True + completion_mode = XBlockCompletionMode.COMPLETABLE + display_name = String( display_name=_('Display Name'), help=_('The display name for this component.'), @@ -118,6 +121,7 @@ class MultiProblemBlock(LibraryContentBlock): help=_('Defines min score for successful completion of the test'), scope=Scope.settings, values={'min': 0, 'step': 0.1, 'max': 1}, + default=0, ) next_page_on_submit = Boolean( @@ -207,25 +211,26 @@ def _get_problem_stats(self): @XBlock.handler def get_overall_progress(self, _, __): """ - Fetch status of all child problem xblocks to get overall progress. + Fetch status of all child problem xblocks to get overall progress and updates completion percentage. """ completed_problems, total_problems = self._get_problem_stats() - return Response( - json.dumps( - { - 'overall_progress': self._calculate_progress_percentage(completed_problems, total_problems), - } - ) - ) - - @XBlock.handler - def get_test_scores(self, _data, _suffix): + 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) -> None: """ - Get test score slide content + 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. """ - 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 = 0 total_possible_score = 0 @@ -238,20 +243,36 @@ def get_test_scores(self, _data, _suffix): score = child.score student_score += score.raw_earned total_possible_score += score.raw_possible - 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), - } - ) + 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 + """ + 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) if self.score_display_format == SCORE_DISPLAY_FORMAT.X_OUT_OF_Y: score_display = f'{student_score}/{total_possible_score}' else: score_display = f'{(student_score / total_possible_score):.0%}' + + if (student_score / total_possible_score) >= self.cut_off_score: + self.publish_completion(1) + template = loader.render_django_template( '/templates/html/multi_problem_xblock_test_scores.html', { @@ -328,3 +349,11 @@ def student_view(self, context): 'next_page_on_submit': next_page_on_submit, } 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}) From 46187b0de76ee2348529e36e82d170316c36747d Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 29 Jul 2024 20:45:07 +0530 Subject: [PATCH 11/27] test: add test utils and basic tests --- .mise.toml | 4 + Makefile | 2 +- multi_problem_xblock/compat.py | 46 +-- multi_problem_xblock/multi_problem_xblock.py | 83 ++--- .../templates/html/multi_problem_xblock.html | 2 + multi_problem_xblock/utils.py | 293 ------------------ tests/unit/__init__.py | 0 tests/unit/test_basics.py | 88 ++++++ tests/utils.py | 58 +++- 9 files changed, 219 insertions(+), 357 deletions(-) create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_basics.py diff --git a/.mise.toml b/.mise.toml index cbac010..267fb8e 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 diff --git a/Makefile b/Makefile index 3e71d86..cec2f69 100644 --- a/Makefile +++ b/Makefile @@ -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/multi_problem_xblock/compat.py b/multi_problem_xblock/compat.py index d1f51f8..2dc61e5 100644 --- a/multi_problem_xblock/compat.py +++ b/multi_problem_xblock/compat.py @@ -4,15 +4,17 @@ import logging +from xblock.core import XBlock + log = logging.getLogger(__name__) def getLibraryContentBlock(): 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 + LibraryContentBlock = XBlock return LibraryContentBlock @@ -20,18 +22,19 @@ 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: @@ -40,15 +43,16 @@ class L_ShowCorrectness: """ # Constants used to indicate when to show correctness - ALWAYS = "always" - PAST_DUE = "past_due" - NEVER = "never" + 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') @@ -58,7 +62,8 @@ def getShowAnswerOptions(): def getShowCorrectnessOptions(): """Get ShowCorrectness constant from xmodule/graders.py""" try: - from xmodule.graders import ShowCorrectness + from xmodule.graders import ShowCorrectness # pylint: disable=import-outside-toplevel + return ShowCorrectness except ModuleNotFoundError: log.warning('ShowCorrectness not found, using local copy') @@ -68,7 +73,8 @@ def getShowCorrectnessOptions(): def getStudentView(): """Get STUDENT_VIEW constant from xmodule/x_module.py""" try: - from xmodule.x_module import STUDENT_VIEW + 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') diff --git a/multi_problem_xblock/multi_problem_xblock.py b/multi_problem_xblock/multi_problem_xblock.py index a47f063..e6c2e53 100644 --- a/multi_problem_xblock/multi_problem_xblock.py +++ b/multi_problem_xblock/multi_problem_xblock.py @@ -1,4 +1,3 @@ -# """Multi Problem XBlock""" # Imports ########################################################### @@ -53,9 +52,12 @@ class SCORE_DISPLAY_FORMAT: X_OUT_OF_Y = 'x_out_of_y' -@XBlock.wants('library_tools', 'studio_user_permissions', 'user', 'completion') -@XBlock.needs('bookmarks') +@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 = '' @@ -127,7 +129,8 @@ class MultiProblemBlock(LibraryContentBlock): 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.' + '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, @@ -140,7 +143,9 @@ def non_editable_metadata_fields(self): """ Set current_slide as non editable field """ - non_editable_fields = super().non_editable_metadata_fields + 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 @@ -154,7 +159,7 @@ def _process_display_feedback(self, child): # 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 = ( + self.show_correctness = child.show_correctness = ( # pylint: disable=attribute-defined-outside-init ShowCorrectness.ALWAYS if self.display_feedback == DISPLAYFEEDBACK.IMMEDIATELY else ShowCorrectness.NEVER ) @@ -192,7 +197,7 @@ def _children_iterator(self, filter_block_type=None): 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.location.course_key.make_usage_key(block_type, block_id)) + 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): @@ -201,7 +206,7 @@ def _get_problem_stats(self): """ total_problems = 0 completed_problems = 0 - for index, block_type, child in self._children_iterator(filter_block_type='problem'): + for __, ___, child in self._children_iterator(filter_block_type='problem'): if hasattr(child, 'is_submitted'): total_problems += 1 if child.is_submitted(): @@ -209,7 +214,7 @@ def _get_problem_stats(self): return completed_problems, total_problems @XBlock.handler - def get_overall_progress(self, _, __): + def get_overall_progress(self, __, ___): """ Fetch status of all child problem xblocks to get overall progress and updates completion percentage. """ @@ -224,7 +229,7 @@ def get_overall_progress(self, _, __): self.publish_completion(completion) return Response(json.dumps({'overall_progress': progress})) - def _prepare_user_score(self, include_question_answers=False) -> None: + def _prepare_user_score(self, include_question_answers=False) -> tuple[list, float, float]: """ Calculate total user score and prepare list of question answers with user response. @@ -234,7 +239,7 @@ def _prepare_user_score(self, include_question_answers=False) -> None: question_answers = [] student_score = 0 total_possible_score = 0 - for index, block_type, child in self._children_iterator(filter_block_type='problem'): + for __, ___, 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(): @@ -282,12 +287,12 @@ def get_test_scores(self, _data, _suffix): ) return Response(template, content_type='text/html') - def student_view(self, context): + def student_view_data(self, context): """ - Student view + Student view data for templates and javascript initialization """ fragment = Fragment() - contents = [] + items = [] child_context = {} if not context else copy(context) jump_to_id = context.get('jumpToId') bookmarks_service = self.runtime.service(self, 'bookmarks') @@ -300,7 +305,7 @@ def student_view(self, context): # use selected_children method from LibraryContentBlock to get child xblocks. for index, block_type, child in self._children_iterator(): - child_id = str(child.location) + 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') @@ -316,38 +321,46 @@ def student_view(self, context): rendered_child = child.render(STUDENT_VIEW, child_context) fragment.add_fragment_resources(rendered_child) - contents.append( + 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.location), + 'is_bookmarked': ( + bookmarks_service.is_bookmarked(usage_key=child.location) if bookmarks_service else False + ), } ) next_page_on_submit = self.next_page_on_submit and self.display_feedback != DISPLAYFEEDBACK.IMMEDIATELY + 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': self._calculate_progress_percentage(completed_problems, total_problems), + '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_data(context) fragment.add_content( - loader.render_django_template( - '/templates/html/multi_problem_xblock.html', - { - 'items': contents, - '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': self._calculate_progress_percentage(completed_problems, total_problems), - }, - ) + 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') - fragment.json_init_args = { - 'current_slide': self.current_slide, - 'next_page_on_submit': next_page_on_submit, - } + fragment.initialize_js('MultiProblemBlock', js_context) return fragment def publish_completion(self, progress: float): diff --git a/multi_problem_xblock/templates/html/multi_problem_xblock.html b/multi_problem_xblock/templates/html/multi_problem_xblock.html index 9935aba..bc69ce7 100644 --- a/multi_problem_xblock/templates/html/multi_problem_xblock.html +++ b/multi_problem_xblock/templates/html/multi_problem_xblock.html @@ -32,6 +32,7 @@

{{ self.display_name }}

{% if item.content %}
{{ item.content|safe }} + {% if bookmarks_service_enabled %}
+ {% endif %}
{% endif %} {% endfor %} 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/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..5bb419f --- /dev/null +++ b/tests/unit/test_basics.py @@ -0,0 +1,88 @@ +import unittest +from unittest import mock + +import ddt +from sample_xblocks.basic.problem import ProblemBlock + +from multi_problem_xblock.compat import L_SHOWANSWER +from multi_problem_xblock.multi_problem_xblock import DISPLAYFEEDBACK, SCORE_DISPLAY_FORMAT, MultiProblemBlock + +from ..utils import 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(ProblemBlock, 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): + 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_data(self): + _, template_context, js_context = self.block.student_view_data({}) + 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]) diff --git a/tests/utils.py b/tests/utils.py index b57672b..cf3ce89 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,8 +1,9 @@ - import json -import random +from unittest.mock import MagicMock, patch from webob import Request +from workbench.runtime import WorkbenchRuntime +from xblock.field_data import DictFieldData def make_request(data, method='POST'): @@ -15,9 +16,50 @@ 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 + return block + + +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 From 28abb0dafb0995fbf3264885cbea8028d54cba10 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 30 Jul 2024 19:24:58 +0530 Subject: [PATCH 12/27] test: add all basic tests --- Makefile | 2 +- multi_problem_xblock/compat.py | 1 + multi_problem_xblock/multi_problem_xblock.py | 41 +++++++- .../public/js/translations/en/text.js | 25 ++--- .../templates/html/multi_problem_xblock.html | 4 +- tests/unit/test_basics.py | 98 ++++++++++++++++++- tests/utils.py | 19 +++- 7 files changed, 168 insertions(+), 22 deletions(-) diff --git a/Makefile b/Makefile index cec2f69..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 diff --git a/multi_problem_xblock/compat.py b/multi_problem_xblock/compat.py index 2dc61e5..60b376f 100644 --- a/multi_problem_xblock/compat.py +++ b/multi_problem_xblock/compat.py @@ -10,6 +10,7 @@ def getLibraryContentBlock(): + """Get LibraryContentBlock from edx-platform if possible""" try: from xmodule.library_content_block import LibraryContentBlock # pylint: disable=import-outside-toplevel except ModuleNotFoundError: diff --git a/multi_problem_xblock/multi_problem_xblock.py b/multi_problem_xblock/multi_problem_xblock.py index e6c2e53..1c50d11 100644 --- a/multi_problem_xblock/multi_problem_xblock.py +++ b/multi_problem_xblock/multi_problem_xblock.py @@ -6,6 +6,8 @@ import logging 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 @@ -229,7 +231,7 @@ def get_overall_progress(self, __, ___): self.publish_completion(completion) return Response(json.dumps({'overall_progress': progress})) - def _prepare_user_score(self, include_question_answers=False) -> tuple[list, float, float]: + def _prepare_user_score(self, include_question_answers=False): """ Calculate total user score and prepare list of question answers with user response. @@ -287,14 +289,14 @@ def get_test_scores(self, _data, _suffix): ) return Response(template, content_type='text/html') - def student_view_data(self, context): + def student_view_data(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 = context.get('jumpToId') + jump_to_id = child_context.get('jumpToId') bookmarks_service = self.runtime.service(self, 'bookmarks') total_problems = 0 completed_problems = 0 @@ -370,3 +372,36 @@ def publish_completion(self, progress: float): 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/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 index bc69ce7..9459798 100644 --- a/multi_problem_xblock/templates/html/multi_problem_xblock.html +++ b/multi_problem_xblock/templates/html/multi_problem_xblock.html @@ -15,7 +15,9 @@
- {% blocktrans %}{{ self.current_slide }} of {{ items|length }}{% endblocktrans %} + {% blocktrans with items_length=items|length current_slide=self.current_slide %} + {{ current_slide }} of {{ items_length }} + {% endblocktrans %} {% if self.display_name %}

{{ self.display_name }}

diff --git a/tests/unit/test_basics.py b/tests/unit/test_basics.py index 5bb419f..c21e475 100644 --- a/tests/unit/test_basics.py +++ b/tests/unit/test_basics.py @@ -2,12 +2,11 @@ from unittest import mock import ddt -from sample_xblocks.basic.problem import ProblemBlock -from multi_problem_xblock.compat import L_SHOWANSWER +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 TestCaseMixin, instantiate_block +from ..utils import SampleProblemBlock, TestCaseMixin, instantiate_block @ddt.ddt @@ -19,7 +18,7 @@ def setUp(self): self.children = {} for i in range(3): usage_key = f'block-v1:edx+cs1+test+type@problem+block@{i}' - problem_block = instantiate_block(ProblemBlock, fields={ + problem_block = instantiate_block(SampleProblemBlock, fields={ 'usage_key': usage_key, }) self.children[usage_key] = problem_block @@ -59,6 +58,7 @@ def assertPublishEvent(self, 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( @@ -68,6 +68,7 @@ def test_template_contents(self): self.assertIn('
', student_fragment.content) def test_student_view_data(self): + """Verify student data used in templates""" _, template_context, js_context = self.block.student_view_data({}) items = template_context.pop('items') self.assertEqual(template_context, { @@ -86,3 +87,92 @@ def test_student_view_data(self): }) 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 cf3ce89..1bf1430 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,8 +1,10 @@ import json -from unittest.mock import MagicMock, patch +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 @@ -33,9 +35,24 @@ def instantiate_block(cls, fields=None): 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 From af054f84d97e476790549c6ef23b8d7c5f8e0768 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 30 Jul 2024 20:13:58 +0530 Subject: [PATCH 13/27] chore: upgrade deps --- requirements/base.txt | 18 +++++++------ requirements/ci.txt | 10 ++++---- requirements/constraints.txt | 6 +++-- requirements/dev.txt | 50 +++++++++++++++++++----------------- requirements/pip.txt | 4 +-- requirements/quality.txt | 40 ++++++++++++++++------------- requirements/test.txt | 29 +++++++++++---------- 7 files changed, 86 insertions(+), 71 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index 91495d7..bd8c888 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -8,13 +8,13 @@ appdirs==1.4.4 # via fs asgiref==3.8.1 # via django -boto3==1.34.130 +boto3==1.34.150 # via fs-s3fs -botocore==1.34.130 +botocore==1.34.150 # via # boto3 # s3transfer -django==4.2.13 +django==4.2.14 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # django-appconf @@ -55,7 +55,7 @@ pytz==2024.1 # via xblock pyyaml==6.0.1 # via xblock -s3transfer==0.10.1 +s3transfer==0.10.2 # via boto3 simplejson==3.19.2 # via xblock @@ -64,15 +64,17 @@ 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 +urllib3==2.2.2 ; 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 +xblock[django]==5.0.0 # via -r requirements/base.in # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/ci.txt b/requirements/ci.txt index d1996e4..564c0f5 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -4,7 +4,7 @@ # # 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,9 @@ 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 +tox==4.16.0 # 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..ce928d4 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -11,5 +11,7 @@ # Common constraints for edx repos -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt -# For python greater than or equal to 3.9 backports.zoneinfo causing failures -backports.zoneinfo; python_version<"3.9" +# For python less than or equal to 3.10 urllib3 causing failures +urllib3; python_version>"3.10" +# See https://github.com/openedx/i18n-tools/pull/148/files#diff-86d5fe588ff2fc7dccb1f4cdd8019d4473146536e88d7a9ede946ea962a91acbR23 +path<=16.16.0 diff --git a/requirements/dev.txt b/requirements/dev.txt index 99fde7c..a6a21cb 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -16,7 +16,7 @@ asgiref==3.8.1 # via # -r requirements/quality.txt # django -astroid==3.2.2 +astroid==3.2.4 # via # -r requirements/quality.txt # pylint @@ -25,11 +25,11 @@ binaryornot==0.4.4 # via # -r requirements/quality.txt # cookiecutter -boto3==1.34.130 +boto3==1.34.150 # via # -r requirements/quality.txt # fs-s3fs -botocore==1.34.130 +botocore==1.34.150 # via # -r requirements/quality.txt # boto3 @@ -38,11 +38,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 +81,7 @@ cookiecutter==2.6.0 # via # -r requirements/quality.txt # xblock-sdk -coverage[toml]==7.5.3 +coverage[toml]==7.6.0 # via # -r requirements/quality.txt # pytest-cov @@ -95,7 +95,7 @@ distlib==0.3.8 # via # -r requirements/ci.txt # virtualenv -django==4.2.13 +django==4.2.14 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/quality.txt @@ -110,11 +110,11 @@ 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.1 # via -r requirements/quality.txt -edx-lint==5.3.6 +edx-lint==5.3.7 # via -r requirements/quality.txt -filelock==3.15.3 +filelock==3.15.4 # via # -r requirements/ci.txt # tox @@ -163,8 +163,10 @@ lxml[html-clean]==5.2.2 # lxml-html-clean # xblock # xblock-sdk -lxml-html-clean==0.1.1 - # via -r requirements/quality.txt +lxml-html-clean==0.2.0 + # via + # -r requirements/quality.txt + # lxml mako==1.3.5 # via # -r requirements/quality.txt @@ -202,8 +204,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 @@ -235,7 +238,7 @@ 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 +262,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 +271,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 @@ -308,7 +311,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 +327,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 +339,23 @@ text-unidecode==1.3 # via # -r requirements/quality.txt # python-slugify -tomlkit==0.12.5 +tomlkit==0.13.0 # via # -r requirements/quality.txt # pylint -tox==4.15.1 +tox==4.16.0 # via -r requirements/ci.txt types-python-dateutil==2.9.0.20240316 # via # -r requirements/quality.txt # arrow -urllib3==2.2.2 +urllib3==2.2.2 ; 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 @@ -369,7 +373,7 @@ wheel==0.43.0 # via # -r requirements/pip-tools.txt # pip-tools -xblock[django]==4.0.1 +xblock[django]==5.0.0 # via # -r requirements/quality.txt # xblock-sdk diff --git a/requirements/pip.txt b/requirements/pip.txt index a1e3371..54b0571 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -8,7 +8,7 @@ wheel==0.43.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..7b3796b 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -16,7 +16,7 @@ asgiref==3.8.1 # via # -r requirements/test.txt # django -astroid==3.2.2 +astroid==3.2.4 # via # pylint # pylint-celery @@ -24,16 +24,16 @@ binaryornot==0.4.4 # via # -r requirements/test.txt # cookiecutter -boto3==1.34.130 +boto3==1.34.150 # via # -r requirements/test.txt # fs-s3fs -botocore==1.34.130 +botocore==1.34.150 # via # -r requirements/test.txt # boto3 # s3transfer -certifi==2024.6.2 +certifi==2024.7.4 # via # -r requirements/test.txt # requests @@ -60,7 +60,7 @@ cookiecutter==2.6.0 # via # -r requirements/test.txt # xblock-sdk -coverage[toml]==7.5.3 +coverage[toml]==7.6.0 # via # -r requirements/test.txt # pytest-cov @@ -68,7 +68,7 @@ ddt==1.7.2 # via -r requirements/test.txt dill==0.3.8 # via pylint -django==4.2.13 +django==4.2.14 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt @@ -83,9 +83,9 @@ 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.1 # via -r requirements/test.txt -edx-lint==5.3.6 +edx-lint==5.3.7 # via -r requirements/quality.in fs==2.4.16 # via @@ -129,8 +129,10 @@ lxml[html-clean]==5.2.2 # lxml-html-clean # xblock # xblock-sdk -lxml-html-clean==0.1.1 - # via -r requirements/test.txt +lxml-html-clean==0.2.0 + # via + # -r requirements/test.txt + # lxml mako==1.3.5 # via # -r requirements/test.txt @@ -161,8 +163,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 @@ -183,7 +186,7 @@ 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 +204,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 @@ -241,7 +244,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 +260,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 +270,15 @@ text-unidecode==1.3 # via # -r requirements/test.txt # python-slugify -tomlkit==0.12.5 +tomlkit==0.13.0 # via pylint types-python-dateutil==2.9.0.20240316 # via # -r requirements/test.txt # arrow -urllib3==2.2.2 +urllib3==2.2.2 ; python_version > "3.10" # via + # -c requirements/constraints.txt # -r requirements/test.txt # botocore # requests @@ -288,7 +292,7 @@ webob==1.8.7 # -r requirements/test.txt # xblock # xblock-sdk -xblock[django]==4.0.1 +xblock[django]==5.0.0 # via # -r requirements/test.txt # xblock-sdk diff --git a/requirements/test.txt b/requirements/test.txt index 93b1c99..6c5a611 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -16,16 +16,16 @@ asgiref==3.8.1 # django binaryornot==0.4.4 # via cookiecutter -boto3==1.34.130 +boto3==1.34.150 # via # -r requirements/base.txt # fs-s3fs -botocore==1.34.130 +botocore==1.34.150 # 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 +35,7 @@ click==8.1.7 # via cookiecutter cookiecutter==2.6.0 # via xblock-sdk -coverage[toml]==7.5.3 +coverage[toml]==7.6.0 # via pytest-cov ddt==1.7.2 # via -r requirements/test.in @@ -53,7 +53,7 @@ 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.1 # via -r requirements/test.in fs==2.4.16 # via @@ -88,7 +88,7 @@ lxml[html-clean]==5.2.2 # lxml-html-clean # xblock # xblock-sdk -lxml-html-clean==0.1.1 +lxml-html-clean==0.2.0 # via lxml mako==1.3.5 # via @@ -113,8 +113,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 +125,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 @@ -155,7 +157,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,7 +172,7 @@ six==1.16.0 # fs # fs-s3fs # python-dateutil -sqlparse==0.5.0 +sqlparse==0.5.1 # via # -r requirements/base.txt # django @@ -178,8 +180,9 @@ text-unidecode==1.3 # via python-slugify types-python-dateutil==2.9.0.20240316 # via arrow -urllib3==2.2.2 +urllib3==2.2.2 ; python_version > "3.10" # via + # -c requirements/constraints.txt # -r requirements/base.txt # botocore # requests @@ -193,7 +196,7 @@ webob==1.8.7 # -r requirements/base.txt # xblock # xblock-sdk -xblock[django]==4.0.1 +xblock[django]==5.0.0 # via # -r requirements/base.txt # xblock-sdk From 0361dffe70dbbdbcb8440c2be88a02ec882ab70f Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 30 Jul 2024 20:26:25 +0530 Subject: [PATCH 14/27] fix: remove support for 3.8 --- .github/workflows/ci.yml | 2 +- requirements/base.txt | 6 ++---- requirements/constraints.txt | 2 -- requirements/dev.txt | 3 +-- requirements/quality.txt | 3 +-- requirements/test.txt | 3 +-- setup.py | 3 +-- 7 files changed, 7 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00027e5..7986f4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-20.04] - python-version: [3.8, 3.11, 3.12] + python-version: [3.11, 3.12] toxenv: [django42, quality, translations] steps: diff --git a/requirements/base.txt b/requirements/base.txt index bd8c888..8f7e5c5 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -66,10 +66,8 @@ six==1.16.0 # python-dateutil sqlparse==0.5.1 # via django -urllib3==2.2.2 ; python_version > "3.10" - # via - # -c requirements/constraints.txt - # botocore +urllib3==2.2.2 + # via botocore web-fragments==2.2.0 # via xblock webob==1.8.7 diff --git a/requirements/constraints.txt b/requirements/constraints.txt index ce928d4..363b243 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -11,7 +11,5 @@ # Common constraints for edx repos -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt -# For python less than or equal to 3.10 urllib3 causing failures -urllib3; python_version>"3.10" # See https://github.com/openedx/i18n-tools/pull/148/files#diff-86d5fe588ff2fc7dccb1f4cdd8019d4473146536e88d7a9ede946ea962a91acbR23 path<=16.16.0 diff --git a/requirements/dev.txt b/requirements/dev.txt index a6a21cb..a64ec56 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -349,9 +349,8 @@ types-python-dateutil==2.9.0.20240316 # via # -r requirements/quality.txt # arrow -urllib3==2.2.2 ; python_version > "3.10" +urllib3==2.2.2 # via - # -c requirements/constraints.txt # -r requirements/quality.txt # botocore # requests diff --git a/requirements/quality.txt b/requirements/quality.txt index 7b3796b..82be5cd 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -276,9 +276,8 @@ types-python-dateutil==2.9.0.20240316 # via # -r requirements/test.txt # arrow -urllib3==2.2.2 ; python_version > "3.10" +urllib3==2.2.2 # via - # -c requirements/constraints.txt # -r requirements/test.txt # botocore # requests diff --git a/requirements/test.txt b/requirements/test.txt index 6c5a611..cb68f84 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -180,9 +180,8 @@ text-unidecode==1.3 # via python-slugify types-python-dateutil==2.9.0.20240316 # via arrow -urllib3==2.2.2 ; python_version > "3.10" +urllib3==2.2.2 # via - # -c requirements/constraints.txt # -r requirements/base.txt # botocore # requests diff --git a/setup.py b/setup.py index c47372a..795db2f 100644 --- a/setup.py +++ b/setup.py @@ -117,7 +117,6 @@ def package_data(pkg, root_list): long_description_content_type='text/markdown', classifiers=[ 'Programming Language :: Python', - 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Framework :: Django', @@ -130,5 +129,5 @@ def package_data(pkg, root_list): }, packages=['multi_problem_xblock'], package_data=package_data("multi_problem_xblock", ["static", "templates", "public", "translations"]), - python_requires=">=3.8", + python_requires=">=3.11", ) From 6951f82ba8bcb35bc13fd01993180a49b5eaa121 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 31 Jul 2024 15:03:21 +0530 Subject: [PATCH 15/27] fix: student_view_data pickle error --- multi_problem_xblock/multi_problem_xblock.py | 12 ++++++------ tests/unit/test_basics.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/multi_problem_xblock/multi_problem_xblock.py b/multi_problem_xblock/multi_problem_xblock.py index 1c50d11..de7afcd 100644 --- a/multi_problem_xblock/multi_problem_xblock.py +++ b/multi_problem_xblock/multi_problem_xblock.py @@ -208,7 +208,7 @@ def _get_problem_stats(self): """ total_problems = 0 completed_problems = 0 - for __, ___, child in self._children_iterator(filter_block_type='problem'): + 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(): @@ -216,7 +216,7 @@ def _get_problem_stats(self): return completed_problems, total_problems @XBlock.handler - def get_overall_progress(self, __, ___): + def get_overall_progress(self, _data, _suffix=None): """ Fetch status of all child problem xblocks to get overall progress and updates completion percentage. """ @@ -241,7 +241,7 @@ def _prepare_user_score(self, include_question_answers=False): question_answers = [] student_score = 0 total_possible_score = 0 - for __, ___, child in self._children_iterator(filter_block_type='problem'): + 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(): @@ -289,7 +289,7 @@ def get_test_scores(self, _data, _suffix): ) return Response(template, content_type='text/html') - def student_view_data(self, context=None): + def student_view_context(self, context=None): """ Student view data for templates and javascript initialization """ @@ -329,7 +329,7 @@ def student_view_data(self, context=None): 'content': rendered_child.content, 'bookmark_id': '{},{}'.format(child_context['username'], child_id), 'is_bookmarked': ( - bookmarks_service.is_bookmarked(usage_key=child.location) if bookmarks_service else False + bookmarks_service.is_bookmarked(usage_key=child.usage_key) if bookmarks_service else False ), } ) @@ -356,7 +356,7 @@ def student_view(self, context): """ Student view """ - fragment, template_context, js_context = self.student_view_data(context) + 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) ) diff --git a/tests/unit/test_basics.py b/tests/unit/test_basics.py index c21e475..c523f94 100644 --- a/tests/unit/test_basics.py +++ b/tests/unit/test_basics.py @@ -67,9 +67,9 @@ def test_template_contents(self): ) self.assertIn('
', student_fragment.content) - def test_student_view_data(self): + def test_student_view_context(self): """Verify student data used in templates""" - _, template_context, js_context = self.block.student_view_data({}) + _, template_context, js_context = self.block.student_view_context({}) items = template_context.pop('items') self.assertEqual(template_context, { 'self': self.block, From 48bd99e61018babb57b559bae7eb125b2afb9f54 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 31 Jul 2024 15:57:20 +0530 Subject: [PATCH 16/27] docs: update readme --- README.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index de04b21..1008a29 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,27 @@ 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`. ### 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 ### Testing with tox @@ -30,7 +42,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 +80,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 +117,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. From 9d0fc3a071ca942d6cf2b55e2c2360d0db85ac73 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 31 Jul 2024 16:00:56 +0530 Subject: [PATCH 17/27] docs: add screenshots --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 1008a29..f73f24f 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,17 @@ Go to `Settings -> Advanced` Settings and add `multi-problem` to `Advanced Modul #### 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/a92831f1-df7e-40d7-a323-9c514380a3ad) + + ### Testing with tox Inside a fresh virtualenv, `cd` into the root folder of this repository From 7238d004a8212b70ba5ebf4453560800ce31245a Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 31 Jul 2024 18:38:31 +0530 Subject: [PATCH 18/27] feat: add back button in test scores slide --- .../public/css/multi_problem_xblock.css | 6 ++++++ .../public/js/multi_problem_xblock.js | 10 +++++++++- .../html/multi_problem_xblock_test_scores.html | 17 ++++++++++++----- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/multi_problem_xblock/public/css/multi_problem_xblock.css b/multi_problem_xblock/public/css/multi_problem_xblock.css index 5f93edb..6621911 100644 --- a/multi_problem_xblock/public/css/multi_problem_xblock.css +++ b/multi_problem_xblock/public/css/multi_problem_xblock.css @@ -181,3 +181,9 @@ .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/multi_problem_xblock.js b/multi_problem_xblock/public/js/multi_problem_xblock.js index 081db1d..d6daa08 100644 --- a/multi_problem_xblock/public/js/multi_problem_xblock.js +++ b/multi_problem_xblock/public/js/multi_problem_xblock.js @@ -129,9 +129,18 @@ function MultiProblemBlock(runtime, element, initArgs) { dataType: 'html', success: function( data ) { $('.problem-slides-container', element).hide(); + $('.problem-test-score-container', element).show(); $('.problem-test-score-container', element).html(data); 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); @@ -158,7 +167,6 @@ function MultiProblemBlock(runtime, element, initArgs) { }); }) - window.RequireJS.require(['course_bookmarks/js/views/bookmark_button'], function(BookmarkButton) { var $bookmarkButtonElements = $element.find('.multi-problem-bookmark-buttons'); $bookmarkButtonElements.each(function() { 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 index 3e6330f..d61c98f 100644 --- a/multi_problem_xblock/templates/html/multi_problem_xblock_test_scores.html +++ b/multi_problem_xblock/templates/html/multi_problem_xblock_test_scores.html @@ -2,11 +2,18 @@
-
- - {% trans 'Complete!' %} - -

{% trans 'Test score' %}

+
+ +
+ + {% trans 'Complete!' %} + +

{% trans 'Test score' %}

+
{% for question_answer in question_answers %} From dffe5dd6ccf63971491637feae0e8451d1c7b8ac Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 31 Jul 2024 19:26:51 +0530 Subject: [PATCH 19/27] docs: update images --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f73f24f..0650057 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ Go to `Settings -> Advanced` Settings and add `multi-problem` to `Advanced Modul ![image](https://github.com/user-attachments/assets/f4243f26-c73a-4ebd-afbe-7e5bc84a9617) -![image](https://github.com/user-attachments/assets/a92831f1-df7e-40d7-a323-9c514380a3ad) +![image](https://github.com/user-attachments/assets/64074714-33cb-4bcb-a03f-c141113288df) + ### Testing with tox From bc60284f921c04f08c3e6fad763125de376e998a Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 31 Jul 2024 20:35:53 +0530 Subject: [PATCH 20/27] fix: gate result api --- multi_problem_xblock/multi_problem_xblock.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/multi_problem_xblock/multi_problem_xblock.py b/multi_problem_xblock/multi_problem_xblock.py index de7afcd..fba8f7d 100644 --- a/multi_problem_xblock/multi_problem_xblock.py +++ b/multi_problem_xblock/multi_problem_xblock.py @@ -267,6 +267,8 @@ 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) @@ -383,8 +385,8 @@ def definition_from_xml(cls, xml_object, system): 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." + '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: @@ -394,7 +396,7 @@ def definition_from_xml(cls, xml_object, system): return definition, children def definition_to_xml(self, resource_fs): - """ Exports Library Content Block to XML """ + """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) From cfb83dfd6ce7a669067b642a5e89a783c2e4fade Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 1 Aug 2024 16:40:50 +0530 Subject: [PATCH 21/27] feat: add cut-off score to test score slide --- multi_problem_xblock/multi_problem_xblock.py | 7 +++++++ .../public/css/multi_problem_xblock.css | 4 ++++ .../html/multi_problem_xblock_test_scores.html | 10 +++++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/multi_problem_xblock/multi_problem_xblock.py b/multi_problem_xblock/multi_problem_xblock.py index fba8f7d..4c495d3 100644 --- a/multi_problem_xblock/multi_problem_xblock.py +++ b/multi_problem_xblock/multi_problem_xblock.py @@ -4,6 +4,7 @@ import json import logging +import math from copy import copy from lxml import etree @@ -273,20 +274,26 @@ def get_test_scores(self, _data, _suffix): 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 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 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, }, ) return Response(template, content_type='text/html') diff --git a/multi_problem_xblock/public/css/multi_problem_xblock.css b/multi_problem_xblock/public/css/multi_problem_xblock.css index 6621911..2480126 100644 --- a/multi_problem_xblock/public/css/multi_problem_xblock.css +++ b/multi_problem_xblock/public/css/multi_problem_xblock.css @@ -132,6 +132,10 @@ 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; 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 index d61c98f..49cdef7 100644 --- a/multi_problem_xblock/templates/html/multi_problem_xblock_test_scores.html +++ b/multi_problem_xblock/templates/html/multi_problem_xblock_test_scores.html @@ -51,7 +51,15 @@

{% trans 'Test score' %}

{% endfor %}
{% trans 'Test Score' %} - {{ 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 %}
From d733533ccf000c5ffcc3247002daa124b9fef87a Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 2 Aug 2024 14:57:07 +0530 Subject: [PATCH 22/27] feat: show/hide back button based on display_feedback Users are only allowed to go back from test scores slide if display_feedback is set to immediately. If it is set to end_of_test, users cannot go back to the same questions but they can use Redo test button if allow_reset_problems is true. --- multi_problem_xblock/multi_problem_xblock.py | 22 +++++++++++++++++-- .../public/js/multi_problem_xblock.js | 16 +++++++++++--- .../templates/html/multi_problem_xblock.html | 2 ++ .../multi_problem_xblock_test_scores.html | 5 +++++ 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/multi_problem_xblock/multi_problem_xblock.py b/multi_problem_xblock/multi_problem_xblock.py index 4c495d3..3fcf9ce 100644 --- a/multi_problem_xblock/multi_problem_xblock.py +++ b/multi_problem_xblock/multi_problem_xblock.py @@ -197,6 +197,7 @@ 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 @@ -275,6 +276,7 @@ def get_test_scores(self, _data, _suffix): 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}' @@ -287,6 +289,10 @@ def get_test_scores(self, _data, _suffix): 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', { @@ -294,10 +300,17 @@ def get_test_scores(self, _data, _suffix): '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 @@ -314,7 +327,6 @@ def student_view_context(self, context=None): user_service = self.runtime.service(self, 'user') child_context['username'] = user_service.get_current_user().opt_attrs.get('edx-platform.username') - # use selected_children method from LibraryContentBlock to get child xblocks. for index, block_type, child in self._children_iterator(): child_id = str(child.usage_key) if child is None: @@ -344,6 +356,12 @@ def student_view_context(self, context=None): ) 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, @@ -352,7 +370,7 @@ def student_view_context(self, context=None): 'reset_button': self.allow_resetting_children, 'show_results': self.display_feedback != DISPLAYFEEDBACK.NEVER, 'next_page_on_submit': next_page_on_submit, - 'overall_progress': self._calculate_progress_percentage(completed_problems, total_problems), + 'overall_progress': overall_progress, 'bookmarks_service_enabled': bookmarks_service is not None, } js_context = { diff --git a/multi_problem_xblock/public/js/multi_problem_xblock.js b/multi_problem_xblock/public/js/multi_problem_xblock.js index d6daa08..fad941e 100644 --- a/multi_problem_xblock/public/js/multi_problem_xblock.js +++ b/multi_problem_xblock/public/js/multi_problem_xblock.js @@ -22,8 +22,6 @@ function MultiProblemBlock(runtime, element, initArgs) { next_page_on_submit: nextPageOnSubmit = false, } = initArgs; - showSlide(currentSlide) - function showSlide(n) { var slides = $('.slide', element); slides[n].style.display = "block"; @@ -67,6 +65,7 @@ function MultiProblemBlock(runtime, element, initArgs) { // Otherwise, display the correct tab: showSlide(nextSlide); } + $('.nextBtn', element).click((e) => nextPrev(1)); $('.prevBtn', element).click((e) => nextPrev(-1)); @@ -128,9 +127,13 @@ function MultiProblemBlock(runtime, element, initArgs) { type: 'GET', dataType: 'html', success: function( data ) { - $('.problem-slides-container', element).hide(); $('.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) => { @@ -167,6 +170,13 @@ function MultiProblemBlock(runtime, element, initArgs) { }); }) + // 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() { diff --git a/multi_problem_xblock/templates/html/multi_problem_xblock.html b/multi_problem_xblock/templates/html/multi_problem_xblock.html index 9459798..20cb973 100644 --- a/multi_problem_xblock/templates/html/multi_problem_xblock.html +++ b/multi_problem_xblock/templates/html/multi_problem_xblock.html @@ -6,6 +6,7 @@
+ {% if self.current_slide != -1 %}
+ {% 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 index 49cdef7..f3c9b4c 100644 --- a/multi_problem_xblock/templates/html/multi_problem_xblock_test_scores.html +++ b/multi_problem_xblock/templates/html/multi_problem_xblock_test_scores.html @@ -3,11 +3,16 @@
+ {% if allow_back_button %} + {% else %} + +
+ {% endif %}
{% trans 'Complete!' %} From 9b6bd9dbef21f971c5f8e33484d282e56bbad115 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 6 Aug 2024 16:57:40 +0530 Subject: [PATCH 23/27] refactor: update display_feedback and showanswer help text Added warning against updating source library id along with fields like display_feedback and showanswer to avoid race condition. --- multi_problem_xblock/multi_problem_xblock.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/multi_problem_xblock/multi_problem_xblock.py b/multi_problem_xblock/multi_problem_xblock.py index 3fcf9ce..7a26c44 100644 --- a/multi_problem_xblock/multi_problem_xblock.py +++ b/multi_problem_xblock/multi_problem_xblock.py @@ -78,7 +78,9 @@ class MultiProblemBlock(LibraryContentBlock): 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' + '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, @@ -100,7 +102,11 @@ class MultiProblemBlock(LibraryContentBlock): display_feedback = String( display_name=_('Display feedback'), - help=_('Defines when to show feedback i.e. correctness in the problem slides.'), + 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=[ From 2243c1dca78d38222eb7a4d1cb065d0df328eb60 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 7 Aug 2024 11:58:02 +0530 Subject: [PATCH 24/27] docs: update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0650057..b1026a1 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ $ pip install -r requirements.txt ### 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 From 9860668212a5ed61c5d583d1e290e0831b3456a7 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 7 Aug 2024 15:17:14 +0530 Subject: [PATCH 25/27] chore: update variable name in js --- .../public/js/multi_problem_xblock.js | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/multi_problem_xblock/public/js/multi_problem_xblock.js b/multi_problem_xblock/public/js/multi_problem_xblock.js index fad941e..7d8c2a7 100644 --- a/multi_problem_xblock/public/js/multi_problem_xblock.js +++ b/multi_problem_xblock/public/js/multi_problem_xblock.js @@ -22,39 +22,39 @@ function MultiProblemBlock(runtime, element, initArgs) { next_page_on_submit: nextPageOnSubmit = false, } = initArgs; - function showSlide(n) { + function showSlide(num) { var slides = $('.slide', element); - slides[n].style.display = "block"; + slides[num].style.display = "block"; //... and fix the Previous/Next buttons: - if (n == 0) { + if (num == 0) { $(".prevBtn", element).prop('disabled', true); } else { $(".prevBtn", element).prop('disabled', false); } - if (n >= (slides.length - 1)) { + 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(n, slides.length) + updateStepIndicator(num, slides.length) } - function updateStepIndicator(n, total) { + function updateStepIndicator(num, total) { $('.slide-position', element).text( - gettext('{current_position} of {total}').replace('{current_position}', n + 1).replace('{total}', total) + 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: n }), + data: JSON.stringify({ current_slide: num }), }); } - function nextPrev(n) { + function nextPrev(num) { // This function will figure out which tab to display var slides = $('.slide', element); // Calculate next slide position - var nextSlide = currentSlide + n; + var nextSlide = currentSlide + num; // if you have reached the end of the form... if (nextSlide >= slides.length) { return false; From 24daef4160ec633b2f43fca6fb3036eb0e32d226 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 8 Aug 2024 12:36:52 +0530 Subject: [PATCH 26/27] build: add support for 3.8 To support olive version of edx-platform --- .github/workflows/ci.yml | 2 +- .mise.toml | 2 +- requirements/base.txt | 28 ++++++++++----- requirements/ci.txt | 8 +++-- requirements/constraints.txt | 4 +++ requirements/dev.txt | 70 ++++++++++++++++++++++++++---------- requirements/pip-tools.txt | 14 ++++++-- requirements/pip.txt | 4 +-- requirements/quality.txt | 51 +++++++++++++++++--------- requirements/test.txt | 39 +++++++++++++------- setup.py | 3 +- 11 files changed, 161 insertions(+), 64 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7986f4a..00027e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-20.04] - python-version: [3.11, 3.12] + python-version: [3.8, 3.11, 3.12] toxenv: [django42, quality, translations] steps: diff --git a/.mise.toml b/.mise.toml index 267fb8e..8752f01 100644 --- a/.mise.toml +++ b/.mise.toml @@ -3,4 +3,4 @@ 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/requirements/base.txt b/requirements/base.txt index 8f7e5c5..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.150 +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.150 +botocore==1.34.156 # via # boto3 # s3transfer -django==4.2.14 +django==4.2.15 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # django-appconf @@ -53,7 +57,7 @@ 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.2 # via boto3 @@ -66,14 +70,20 @@ six==1.16.0 # python-dateutil 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]==5.0.0 - # 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 564c0f5..c827579 100644 --- a/requirements/ci.txt +++ b/requirements/ci.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 @@ -28,7 +28,11 @@ pluggy==1.5.0 # via tox pyproject-api==1.7.1 # via tox -tox==4.16.0 +tomli==2.0.1 + # via + # pyproject-api + # tox +tox==4.17.1 # via -r requirements/ci.in virtualenv==20.26.3 # via tox diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 363b243..e58eb55 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -13,3 +13,7 @@ # 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 a64ec56..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 @@ -21,15 +21,20 @@ astroid==3.2.4 # -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.150 +boto3==1.34.156 # via # -r requirements/quality.txt # fs-s3fs -botocore==1.34.150 +botocore==1.34.156 # via # -r requirements/quality.txt # boto3 @@ -81,7 +86,7 @@ cookiecutter==2.6.0 # via # -r requirements/quality.txt # xblock-sdk -coverage[toml]==7.6.0 +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.14 +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,10 +115,14 @@ django-appconf==1.0.6 # django-statici18n django-statici18n==2.5.0 # via -r requirements/quality.txt -edx-i18n-tools==1.6.1 +edx-i18n-tools==1.6.2 # via -r requirements/quality.txt edx-lint==5.3.7 # via -r requirements/quality.txt +exceptiongroup==1.2.2 + # via + # -r requirements/quality.txt + # pytest filelock==3.15.4 # via # -r requirements/ci.txt @@ -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,17 +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.2.0 - # via - # -r requirements/quality.txt - # lxml mako==1.3.5 # via # -r requirements/quality.txt @@ -232,7 +241,7 @@ 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 @@ -295,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 @@ -339,19 +348,39 @@ text-unidecode==1.3 # via # -r requirements/quality.txt # python-slugify +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.16.0 +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.3 @@ -368,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]==5.0.0 +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 54b0571..a056b76 100644 --- a/requirements/pip.txt +++ b/requirements/pip.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 # -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: diff --git a/requirements/quality.txt b/requirements/quality.txt index 82be5cd..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 @@ -20,15 +20,20 @@ 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.150 +boto3==1.34.156 # via # -r requirements/test.txt # fs-s3fs -botocore==1.34.150 +botocore==1.34.156 # via # -r requirements/test.txt # boto3 @@ -60,7 +65,7 @@ cookiecutter==2.6.0 # via # -r requirements/test.txt # xblock-sdk -coverage[toml]==7.6.0 +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.14 +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.1 +edx-i18n-tools==1.6.2 # via -r requirements/test.txt 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,17 +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.2.0 - # via - # -r requirements/test.txt - # lxml mako==1.3.5 # via # -r requirements/test.txt @@ -180,7 +184,7 @@ 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 @@ -228,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 @@ -270,15 +274,29 @@ text-unidecode==1.3 # via # -r requirements/test.txt # python-slugify +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 web-fragments==2.2.0 @@ -291,8 +309,9 @@ webob==1.8.7 # -r requirements/test.txt # xblock # xblock-sdk -xblock[django]==5.0.0 +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 cb68f84..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,13 +14,18 @@ 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.150 +boto3==1.34.156 # via # -r requirements/base.txt # fs-s3fs -botocore==1.34.150 +botocore==1.34.156 # via # -r requirements/base.txt # boto3 @@ -35,7 +40,7 @@ click==8.1.7 # via cookiecutter cookiecutter==2.6.0 # via xblock-sdk -coverage[toml]==7.6.0 +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.1 +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.2.0 - # via lxml mako==1.3.5 # via # -r requirements/base.txt @@ -145,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 @@ -178,11 +182,21 @@ sqlparse==0.5.1 # 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 web-fragments==2.2.0 @@ -195,8 +209,9 @@ webob==1.8.7 # -r requirements/base.txt # xblock # xblock-sdk -xblock[django]==5.0.0 +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/setup.py b/setup.py index 795db2f..c47372a 100644 --- a/setup.py +++ b/setup.py @@ -117,6 +117,7 @@ def package_data(pkg, root_list): long_description_content_type='text/markdown', classifiers=[ 'Programming Language :: Python', + 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Framework :: Django', @@ -129,5 +130,5 @@ def package_data(pkg, root_list): }, packages=['multi_problem_xblock'], package_data=package_data("multi_problem_xblock", ["static", "templates", "public", "translations"]), - python_requires=">=3.11", + python_requires=">=3.8", ) From 7dcafb2ea3c758437fa4f797fbd2f76f7016d35d Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 12 Aug 2024 17:30:29 +0530 Subject: [PATCH 27/27] feat: add support for olive version --- multi_problem_xblock/compat.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/multi_problem_xblock/compat.py b/multi_problem_xblock/compat.py index 60b376f..16505c9 100644 --- a/multi_problem_xblock/compat.py +++ b/multi_problem_xblock/compat.py @@ -14,8 +14,12 @@ def getLibraryContentBlock(): try: from xmodule.library_content_block import LibraryContentBlock # pylint: disable=import-outside-toplevel except ModuleNotFoundError: - log.warning('LibraryContentBlock not found, using empty object') - LibraryContentBlock = XBlock + 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 @@ -56,7 +60,13 @@ def getShowAnswerOptions(): return SHOWANSWER except ModuleNotFoundError: - log.warning('SHOWANSWER not found, using local copy') + 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