From 2fc029b05eef0fd32ae85a0434a55c8eca93828c Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Thu, 2 Nov 2023 17:04:52 -0500 Subject: [PATCH] feat: add grading strategy support --- cms/envs/common.py | 9 + lms/djangoapps/instructor/enrollment.py | 3 + .../instructor/tests/test_enrollment.py | 5 +- lms/envs/common.py | 11 +- lms/templates/problem.html | 5 +- xmodule/capa/capa_problem.py | 38 +- xmodule/capa/tests/test_capa_problem.py | 110 ++- xmodule/capa_block.py | 252 +++++- xmodule/fields.py | 25 +- xmodule/tests/test_capa_block.py | 727 +++++++++++++++++- 10 files changed, 1171 insertions(+), 14 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index 5379fa6891ee..a33c415789bd 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -570,6 +570,15 @@ # .. toggle_creation_date: 2024-03-14 # .. toggle_tickets: https://github.com/openedx/edx-platform/pull/34173 'ENABLE_HOME_PAGE_COURSE_API_V2': True, + + # .. toggle_name: FEATURES['ENABLE_GRADING_METHOD_IN_PROBLEMS'] + # .. toggle_implementation: DjangoSetting + # .. toggle_default: False + # .. toggle_description: Enables the grading method feature in capa problems. + # .. toggle_use_cases: open_edx + # .. toggle_creation_date: 2024-03-22 + # .. toggle_tickets: https://github.com/openedx/edx-platform/pull/33911 + 'ENABLE_GRADING_METHOD_IN_PROBLEMS': False, } # .. toggle_name: ENABLE_COPPA_COMPLIANCE diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py index 1dd451a41cb9..300543def6c2 100644 --- a/lms/djangoapps/instructor/enrollment.py +++ b/lms/djangoapps/instructor/enrollment.py @@ -391,6 +391,9 @@ def _reset_module_attempts(studentmodule): problem_state = json.loads(studentmodule.state) # old_number_of_attempts = problem_state["attempts"] problem_state["attempts"] = 0 + problem_state["score_history"] = [] + problem_state["correct_map_history"] = [] + problem_state["student_answers_history"] = [] # save studentmodule.state = json.dumps(problem_state) diff --git a/lms/djangoapps/instructor/tests/test_enrollment.py b/lms/djangoapps/instructor/tests/test_enrollment.py index 4aa14e32256b..59ccfac6caa1 100644 --- a/lms/djangoapps/instructor/tests/test_enrollment.py +++ b/lms/djangoapps/instructor/tests/test_enrollment.py @@ -543,7 +543,10 @@ def setup_team(self): 'attempts': 1, 'saved_files_descriptions': ['summary', 'proposal', 'diagrams'], 'saved_files_sizes': [1364677, 958418], - 'saved_files_names': ['case_study_abstract.txt', 'design_prop.pdf', 'diagram1.png'] + 'saved_files_names': ['case_study_abstract.txt', 'design_prop.pdf', 'diagram1.png'], + 'score_history': [], + 'correct_map_history': [], + 'student_answers_history': [], } team_state = json.dumps(self.team_state_dict) diff --git a/lms/envs/common.py b/lms/envs/common.py index 3e651a915f49..c45679e4b9bb 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1049,7 +1049,16 @@ # .. toggle_use_cases: opt_in # .. toggle_creation_date: 2023-10-10 # .. toggle_tickets: https://github.com/openedx/openedx-events/issues/210 - 'SEND_LEARNING_CERTIFICATE_LIFECYCLE_EVENTS_TO_BUS': False + 'SEND_LEARNING_CERTIFICATE_LIFECYCLE_EVENTS_TO_BUS': False, + + # .. toggle_name: FEATURES['ENABLE_GRADING_METHOD_IN_PROBLEMS'] + # .. toggle_implementation: DjangoSetting + # .. toggle_default: False + # .. toggle_description: Enables the grading method feature in capa problems. + # .. toggle_use_cases: open_edx + # .. toggle_creation_date: 2024-03-22 + # .. toggle_tickets: https://github.com/openedx/edx-platform/pull/33911 + 'ENABLE_GRADING_METHOD_IN_PROBLEMS': False, } # Specifies extra XBlock fields that should available when requested via the Course Blocks API diff --git a/lms/templates/problem.html b/lms/templates/problem.html index 3bf90b5daf44..b785e4aa685d 100644 --- a/lms/templates/problem.html +++ b/lms/templates/problem.html @@ -1,7 +1,7 @@ <%page expression_filter="h"/> <%! from django.utils.translation import ngettext, gettext as _ -from openedx.core.djangolib.markup import HTML +from openedx.core.djangolib.markup import HTML, Text %> <%namespace name='static' file='static_content.html'/> @@ -90,6 +90,9 @@

${Text(_("Grading method: {grading_method}")).format(grading_method=grading_method)} + % endif ${_("Some problems have options such as save, reset, hints, or show answer. These options follow the Submit button.")} diff --git a/xmodule/capa/capa_problem.py b/xmodule/capa/capa_problem.py index 797e95e1d5bb..6d78e156f292 100644 --- a/xmodule/capa/capa_problem.py +++ b/xmodule/capa/capa_problem.py @@ -20,6 +20,7 @@ from collections import OrderedDict from copy import deepcopy from datetime import datetime +from typing import Optional from xml.sax.saxutils import unescape from django.conf import settings @@ -172,6 +173,12 @@ def __init__(self, problem_text, id, capa_system, capa_block, # pylint: disable self.has_saved_answers = state.get('has_saved_answers', False) if 'correct_map' in state: self.correct_map.set_dict(state['correct_map']) + self.correct_map_history = [] + for cmap in state.get('correct_map_history', []): + correct_map = CorrectMap() + correct_map.set_dict(cmap) + self.correct_map_history.append(correct_map) + self.done = state.get('done', False) self.input_state = state.get('input_state', {}) @@ -232,6 +239,15 @@ def __init__(self, problem_text, id, capa_system, capa_block, # pylint: disable if extract_tree: self.extracted_tree = self._extract_html(self.tree) + @property + def is_grading_method_enabled(self) -> bool: + """ + Returns whether the grading method feature is enabled. If the + feature is not enabled, the grading method field will not be shown in + Studio settings and the default grading method will be used. + """ + return settings.FEATURES.get('ENABLE_GRADING_METHOD_IN_PROBLEMS', False) + def make_xml_compatible(self, tree): """ Adjust tree xml in-place for compatibility before creating @@ -299,8 +315,10 @@ def do_reset(self): Reset internal state to unfinished, with no answers """ self.student_answers = {} + self.student_answers_history = [] self.has_saved_answers = False self.correct_map = CorrectMap() + self.correct_map_history = [] self.done = False def set_initial_display(self): @@ -328,6 +346,7 @@ def get_state(self): 'student_answers': self.student_answers, 'has_saved_answers': self.has_saved_answers, 'correct_map': self.correct_map.get_dict(), + 'correct_map_history': [cmap.get_dict() for cmap in self.correct_map_history], 'input_state': self.input_state, 'done': self.done} @@ -434,6 +453,7 @@ def grade_answers(self, answers): self.student_answers = convert_files_to_filenames(answers) new_cmap = self.get_grade_from_current_answers(answers) self.correct_map = new_cmap # lint-amnesty, pylint: disable=attribute-defined-outside-init + self.correct_map_history.append(deepcopy(new_cmap)) return self.correct_map def supports_rescoring(self): @@ -455,7 +475,7 @@ def supports_rescoring(self): """ return all('filesubmission' not in responder.allowed_inputfields for responder in self.responders.values()) - def get_grade_from_current_answers(self, student_answers): + def get_grade_from_current_answers(self, student_answers, correct_map: Optional[CorrectMap] = None): """ Gets the grade for the currently-saved problem state, but does not save it to the block. @@ -468,9 +488,14 @@ def get_grade_from_current_answers(self, student_answers): For rescoring, `student_answers` is None. Calls the Response for each question in this problem, to do the actual grading. + + When the grading method is enabled, this method is used for rescore. In this case, + the `correct_map` and the `student_answers` passed as arguments will be used, + corresponding to each pair in the fields that store the history (correct_map_history + and student_answers_history). The correct map will always be updated, depending on + the student answers. The student answers will always remain the same over time. """ - # old CorrectMap - oldcmap = self.correct_map + oldcmap = correct_map if self.is_grading_method_enabled else self.correct_map # start new with empty CorrectMap newcmap = CorrectMap() @@ -487,7 +512,12 @@ def get_grade_from_current_answers(self, student_answers): # use 'student_answers' only if it is provided, and if it might contain a file # submission that would not exist in the persisted "student_answers". - if 'filesubmission' in responder.allowed_inputfields and student_answers is not None: + # If grading method is enabled, we need to pass each student answers and the + # correct map in the history fields. + if ( + "filesubmission" in responder.allowed_inputfields + and student_answers is not None + ) or self.is_grading_method_enabled: results = responder.evaluate_answers(student_answers, oldcmap) else: results = responder.evaluate_answers(self.student_answers, oldcmap) diff --git a/xmodule/capa/tests/test_capa_problem.py b/xmodule/capa/tests/test_capa_problem.py index d9a6ea0dbac4..88a06b4c7ed7 100644 --- a/xmodule/capa/tests/test_capa_problem.py +++ b/xmodule/capa/tests/test_capa_problem.py @@ -4,17 +4,24 @@ import textwrap import unittest +from django.conf import settings +from django.test import override_settings import pytest import ddt from lxml import etree from markupsafe import Markup -from mock import patch +from mock import patch, MagicMock +from xmodule.capa.correctmap import CorrectMap from xmodule.capa.responsetypes import LoncapaProblemError from xmodule.capa.tests.helpers import new_loncapa_problem from openedx.core.djangolib.markup import HTML +FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS = settings.FEATURES.copy() +FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS['ENABLE_GRADING_METHOD_IN_PROBLEMS'] = True + + @ddt.ddt class CAPAProblemTest(unittest.TestCase): """ CAPA problem related tests""" @@ -732,3 +739,104 @@ def test_get_question_answer(self): # Ensure that the answer is a string so that the dict returned from this # function can eventualy be serialized to json without issues. assert isinstance(problem.get_question_answers()['1_solution_1'], str) + + @override_settings(FEATURES=FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS) + def test_get_grade_from_current_answers(self): + """ + Verify that `responder.evaluate_answers` is called with `student_answers` + and `correct_map` sent to `get_grade_from_current_answers`. + + When both arguments are provided, means that the problem is being rescored. + """ + student_answers = {'1_2_1': 'over-suspicious'} + correct_map = CorrectMap(answer_id='1_2_1', correctness="correct", npoints=1) + problem = new_loncapa_problem( + """ + + + + Answer1 + Answer2 + Answer3 + Answer4 + + + + """ + ) + responder_mock = MagicMock() + + with patch.object(problem, 'responders', {'responder1': responder_mock}): + responder_mock.allowed_inputfields = ['choicegroup'] + responder_mock.evaluate_answers.return_value = correct_map + + result = problem.get_grade_from_current_answers(student_answers, correct_map) + self.assertDictEqual(result.get_dict(), correct_map.get_dict()) + responder_mock.evaluate_answers.assert_called_once_with(student_answers, correct_map) + + @override_settings(FEATURES=FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS) + def test_get_grade_from_current_answers_without_student_answers(self): + """ + Verify that `responder.evaluate_answers` is called with appropriate arguments. + + When `student_answers` is None, `responder.evaluate_answers` should be called with + the `self.student_answers` instead. + """ + correct_map = CorrectMap(answer_id='1_2_1', correctness="correct", npoints=1) + problem = new_loncapa_problem( + """ + + + + Answer1 + Answer2 + Answer3 + Answer4 + + + + """ + ) + responder_mock = MagicMock() + + with patch.object(problem, 'responders', {'responder1': responder_mock}): + problem.responders['responder1'].allowed_inputfields = ['choicegroup'] + problem.responders['responder1'].evaluate_answers.return_value = correct_map + + result = problem.get_grade_from_current_answers(None, correct_map) + + self.assertDictEqual(result.get_dict(), correct_map.get_dict()) + responder_mock.evaluate_answers.assert_called_once_with(None, correct_map) + + @override_settings(FEATURES=FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS) + def test_get_grade_from_current_answers_with_filesubmission(self): + """ + Verify that an exception is raised when `responder.evaluate_answers` is called + with `student_answers` as None and `correct_map` sent to `get_grade_from_current_answers` + + This ensures that rescore is not allowed if the problem has a filesubmission. + """ + correct_map = CorrectMap(answer_id='1_2_1', correctness="correct", npoints=1) + problem = new_loncapa_problem( + """ + + + + Answer1 + Answer2 + Answer3 + Answer4 + + + + """ + ) + responder_mock = MagicMock() + + with patch.object(problem, 'responders', {'responder1': responder_mock}): + responder_mock.allowed_inputfields = ['filesubmission'] + responder_mock.evaluate_answers.return_value = correct_map + + with self.assertRaises(Exception): + problem.get_grade_from_current_answers(None, correct_map) + responder_mock.evaluate_answers.assert_not_called() diff --git a/xmodule/capa_block.py b/xmodule/capa_block.py index 7b58b5aa9ab8..e7d917fee601 100644 --- a/xmodule/capa_block.py +++ b/xmodule/capa_block.py @@ -1,6 +1,7 @@ """ Implements the Problem XBlock, which is built on top of the CAPA subsystem. """ +from __future__ import annotations import copy import datetime @@ -22,7 +23,7 @@ from pytz import utc from web_fragments.fragment import Fragment from xblock.core import XBlock -from xblock.fields import Boolean, Dict, Float, Integer, Scope, String, XMLString +from xblock.fields import Boolean, Dict, Float, Integer, Scope, String, XMLString, List from xblock.scorable import ScorableXBlockMixin, Score from xmodule.capa import responsetypes @@ -52,7 +53,7 @@ from openedx.core.djangolib.markup import HTML, Text from .capa.xqueue_interface import XQueueService -from .fields import Date, ScoreField, Timedelta +from .fields import Date, ListScoreField, ScoreField, Timedelta from .progress import Progress log = logging.getLogger("edx.courseware") @@ -92,6 +93,16 @@ class SHOWANSWER: ATTEMPTED_NO_PAST_DUE = "attempted_no_past_due" +class GRADING_METHOD: + """ + Constants for grading method options. + """ + LAST_SCORE = "last_score" + FIRST_SCORE = "first_score" + HIGHEST_SCORE = "highest_score" + AVERAGE_SCORE = "average_score" + + class RANDOMIZATION: """ Constants for problem randomization @@ -181,6 +192,21 @@ class ProblemBlock( "If the value is not set, infinite attempts are allowed."), values={"min": 0}, scope=Scope.settings ) + grading_method = String( + display_name=_("Grading Method"), + help=_( + "Define the grading method for this problem. By default, " + "it's the score of the last submission made by the student." + ), + scope=Scope.settings, + default=GRADING_METHOD.LAST_SCORE, + values=[ + {"display_name": _("Last Score"), "value": GRADING_METHOD.LAST_SCORE}, + {"display_name": _("First Score"), "value": GRADING_METHOD.FIRST_SCORE}, + {"display_name": _("Highest Score"), "value": GRADING_METHOD.HIGHEST_SCORE}, + {"display_name": _("Average Score"), "value": GRADING_METHOD.AVERAGE_SCORE}, + ], + ) due = Date(help=_("Date that this problem is due by"), scope=Scope.settings) graceperiod = Timedelta( help=_("Amount of time after the due date that submissions will be accepted"), @@ -263,11 +289,20 @@ class ProblemBlock( ) correct_map = Dict(help=_("Dictionary with the correctness of current student answers"), scope=Scope.user_state, default={}) + correct_map_history = List( + help=_("List of correctness maps for each attempt"), scope=Scope.user_state, default=[] + ) input_state = Dict(help=_("Dictionary for maintaining the state of inputtypes"), scope=Scope.user_state) student_answers = Dict(help=_("Dictionary with the current student responses"), scope=Scope.user_state) + student_answers_history = List( + help=_("List of student answers for each attempt"), scope=Scope.user_state, default=[] + ) # enforce_type is set to False here because this field is saved as a dict in the database. score = ScoreField(help=_("Dictionary with the current student score"), scope=Scope.user_state, enforce_type=False) + score_history = ListScoreField( + help=_("List of scores for each attempt"), scope=Scope.user_state, default=[], enforce_type=False + ) has_saved_answers = Boolean(help=_("Whether or not the answers have been saved since last submit"), scope=Scope.user_state, default=False) done = Boolean(help=_("Whether the student has answered the problem"), scope=Scope.user_state, default=False) @@ -456,6 +491,31 @@ def display_name_with_default(self): return self.display_name + def grading_method_display_name(self) -> str | None: + """ + If the `ENABLE_GRADING_METHOD_IN_PROBLEMS` feature flag is enabled, + return the grading method, else return None. + """ + _ = self.runtime.service(self, "i18n").gettext + display_name = { + GRADING_METHOD.LAST_SCORE: _("Last Score"), + GRADING_METHOD.FIRST_SCORE: _("First Score"), + GRADING_METHOD.HIGHEST_SCORE: _("Highest Score"), + GRADING_METHOD.AVERAGE_SCORE: _("Average Score"), + } + if self.is_grading_method_enabled: + return display_name[self.grading_method] + return None + + @property + def is_grading_method_enabled(self) -> bool: + """ + Returns whether the grading method feature is enabled. If the + feature is not enabled, the grading method field will not be shown in + Studio settings and the default grading method will be used. + """ + return settings.FEATURES.get('ENABLE_GRADING_METHOD_IN_PROBLEMS', False) + @property def debug(self): """ @@ -510,6 +570,8 @@ def non_editable_metadata_fields(self): # https://github.com/openedx/public-engineering/issues/192 ProblemBlock.matlab_api_key, ]) + if not self.is_grading_method_enabled: + non_editable_fields.append(ProblemBlock.grading_method) return non_editable_fields @property @@ -832,6 +894,7 @@ def get_state_for_lcp(self): return { 'done': self.done, 'correct_map': self.correct_map, + 'correct_map_history': self.correct_map_history, 'student_answers': self.student_answers, 'has_saved_answers': self.has_saved_answers, 'input_state': self.input_state, @@ -845,6 +908,7 @@ def set_state_from_lcp(self): lcp_state = self.lcp.get_state() self.done = lcp_state['done'] self.correct_map = lcp_state['correct_map'] + self.correct_map_history = lcp_state['correct_map_history'] self.input_state = lcp_state['input_state'] self.student_answers = lcp_state['student_answers'] self.has_saved_answers = lcp_state['has_saved_answers'] @@ -1241,6 +1305,7 @@ def get_problem_html(self, encapsulate=True, submit_notification=False): 'reset_button': self.should_show_reset_button(), 'save_button': self.should_show_save_button(), 'answer_available': self.answer_available(), + 'grading_method': self.grading_method_display_name(), 'attempts_used': self.attempts, 'attempts_allowed': self.max_attempts, 'demand_hint_possible': demand_hint_possible, @@ -1687,6 +1752,7 @@ def submit_problem(self, data, override_time=False): self.lcp.has_saved_answers = False answers = self.make_dict_of_responses(data) answers_without_files = convert_files_to_filenames(answers) + self.student_answers_history.append(answers_without_files) event_info['answers'] = answers_without_files metric_name = 'xmodule.capa.check_problem.{}'.format # lint-amnesty, pylint: disable=unused-variable @@ -1753,7 +1819,12 @@ def submit_problem(self, data, override_time=False): self.attempts = self.attempts + 1 self.lcp.done = True self.set_state_from_lcp() - self.set_score(self.score_from_lcp(self.lcp)) + + current_score = self.score_from_lcp(self.lcp) + self.score_history.append(current_score) + if self.is_grading_method_enabled: + current_score = self.get_score_with_grading_method(current_score) + self.set_score(current_score) self.set_last_submission_time() except (StudentInputError, ResponseError, LoncapaProblemError) as inst: @@ -1827,6 +1898,28 @@ def submit_problem(self, data, override_time=False): } # pylint: enable=too-many-statements + def get_score_with_grading_method(self, current_score: Score) -> Score: + """ + Calculate and return the current score based on the grading method. + + Args: + current_score (Score): The current score of the LON-CAPA problem. + + In this method: + - The current score is obtained from the LON-CAPA problem. + - The score history is updated adding the current score. + + Returns: + Score: The score based on the grading method. + """ + grading_method_handler = GradingMethodHandler( + current_score, + self.grading_method, + self.score_history, + self.max_score(), + ) + return grading_method_handler.get_score() + def publish_unmasked(self, title, event_info): """ All calls to runtime.publish route through here so that the @@ -2144,7 +2237,6 @@ def rescore(self, only_if_higher=False): event_info['orig_score'] = orig_score.raw_earned event_info['orig_total'] = orig_score.raw_possible try: - self.update_correctness() calculated_score = self.calculate_score() except (StudentInputError, ResponseError, LoncapaProblemError) as inst: # lint-amnesty, pylint: disable=unused-variable log.warning("Input error in capa_block:problem_rescore", exc_info=True) @@ -2178,6 +2270,28 @@ def rescore(self, only_if_higher=False): event_info['attempts'] = self.attempts self.publish_unmasked('problem_rescore', event_info) + def get_rescore_with_grading_method(self) -> Score: + """ + Calculate and return the rescored score based on the grading method. + + In this method: + - The list with the correctness maps is updated. + - The list with the score history is updated based on the correctness maps. + - The final score is calculated based on the grading method. + + Returns: + Score: The score calculated based on the grading method. + """ + self.update_correctness_list() + self.score_history = self.calculate_score_list() + grading_method_handler = GradingMethodHandler( + self.score, + self.grading_method, + self.score_history, + self.max_score(), + ) + return grading_method_handler.get_score() + def has_submitted_answer(self): return self.done @@ -2206,13 +2320,47 @@ def update_correctness(self): new_correct_map = self.lcp.get_grade_from_current_answers(None) self.lcp.correct_map.update(new_correct_map) + def update_correctness_list(self): + """ + Updates the `correct_map_history` and the `correct_map` of the LCP. + + Operates by creating a new correctness map based on the current + state of the LCP, and updating the old correctness map of the LCP. + """ + # Make sure that the attempt number is always at least 1 for grading purposes, + # even if the number of attempts have been reset and this problem is regraded. + self.lcp.context['attempt'] = max(self.attempts, 1) + new_correct_map_list = [] + for student_answers, correct_map in zip(self.student_answers_history, self.correct_map_history): + new_correct_map = self.lcp.get_grade_from_current_answers(student_answers, correct_map) + new_correct_map_list.append(new_correct_map) + self.lcp.correct_map_history = new_correct_map_list + if new_correct_map_list: + self.lcp.correct_map.update(new_correct_map_list[-1]) + def calculate_score(self): """ Returns the score calculated from the current problem state. + + If the grading method is enabled, the score is calculated based on the grading method. """ + if self.is_grading_method_enabled: + return self.get_rescore_with_grading_method() + self.update_correctness() new_score = self.lcp.calculate_score() return Score(raw_earned=new_score['score'], raw_possible=new_score['total']) + def calculate_score_list(self): + """ + Returns the score calculated from the current problem state. + """ + new_score_list = [] + + for correct_map in self.lcp.correct_map_history: + new_score = self.lcp.calculate_score(correct_map) + new_score_list.append(Score(raw_earned=new_score['score'], raw_possible=new_score['total'])) + return new_score_list + def score_from_lcp(self, lcp): """ Returns the score associated with the correctness map @@ -2222,6 +2370,102 @@ def score_from_lcp(self, lcp): return Score(raw_earned=lcp_score['score'], raw_possible=lcp_score['total']) +class GradingMethodHandler: + """ + A class for handling grading method and calculating scores. + + This class allows for flexible handling of grading methods, including options + such as considering the last score, the first score, the highest score, + or the average score. + + Attributes: + - score (Score): The current score. + - grading_method (str): The chosen grading method. + - score_history (list[Score]): A list to store the history of scores. + - max_score (int): The maximum possible score. + - mapping_method (dict): A dictionary mapping the grading + method to the corresponding handler. + + Methods: + - get_score(): Retrieves the updated score based on the grading method. + - handle_last_score(): Handles the last score method. + - handle_first_score(): Handles the first score method. + - handle_highest_score(): Handles the highest score method. + - handle_average_score(): Handles the average score method. + """ + + def __init__( + self, + score: Score, + grading_method: str, + score_history: list[Score], + max_score: int, + ): + self.score = score + self.grading_method = grading_method + self.score_history = score_history + if not self.score_history: + self.score_history.append(score) + self.max_score = max_score + self.mapping_method = { + GRADING_METHOD.LAST_SCORE: self.handle_last_score, + GRADING_METHOD.FIRST_SCORE: self.handle_first_score, + GRADING_METHOD.HIGHEST_SCORE: self.handle_highest_score, + GRADING_METHOD.AVERAGE_SCORE: self.handle_average_score, + } + + def get_score(self) -> Score: + """ + Retrieves the updated score based on the grading method. + + Returns: + - Score: The updated score based on the chosen grading method. + """ + return self.mapping_method[self.grading_method]() + + def handle_last_score(self) -> Score: + """ + Retrieves the score based on the last score. + It is the last score in the score history. + + Returns: + - Score: The score based on the last score. + """ + return self.score_history[-1] + + def handle_first_score(self) -> Score: + """ + Retrieves the score based on the first score. + It is the first score in the score history. + + Returns: + - Score: The score based on the first score. + """ + return self.score_history[0] + + def handle_highest_score(self) -> Score: + """ + Retrieves the score based on the highest score. + It is the highest score in the score history. + + Returns: + - Score: The score based on the highest score. + """ + return max(self.score_history) + + def handle_average_score(self) -> Score: + """ + Calculates the average score based on all attempts. The average score is + the sum of all scores divided by the number of scores. + + Returns: + - Score: The average score based on all attempts. + """ + total = sum(score.raw_earned for score in self.score_history) + average_score = round(total / len(self.score_history), 2) + return Score(raw_earned=average_score, raw_possible=self.max_score) + + class ComplexEncoder(json.JSONEncoder): """ Extend the JSON encoder to correctly handle complex numbers diff --git a/xmodule/fields.py b/xmodule/fields.py index 7837074f9d1f..2e65304d4422 100644 --- a/xmodule/fields.py +++ b/xmodule/fields.py @@ -7,7 +7,7 @@ import dateutil.parser from pytz import UTC -from xblock.fields import JSONField +from xblock.fields import JSONField, List from xblock.scorable import Score log = logging.getLogger(__name__) @@ -300,3 +300,26 @@ def from_json(self, value): return Score(raw_earned, raw_possible) enforce_type = from_json + + +class ListScoreField(ScoreField, List): + """ + Field for blocks that need to store a list of Scores. + """ + + MUTABLE = True + _default = [] + + def from_json(self, value): + if value is None: + return value + if isinstance(value, list): + scores = [] + for score_json in value: + score = super().from_json(score_json) + scores.append(score) + return scores + + raise TypeError("Value must be a list of Scores. Got {}".format(type(value))) + + enforce_type = from_json diff --git a/xmodule/tests/test_capa_block.py b/xmodule/tests/test_capa_block.py index ab94028fc955..42494547822e 100644 --- a/xmodule/tests/test_capa_block.py +++ b/xmodule/tests/test_capa_block.py @@ -6,6 +6,7 @@ import datetime import json +import mock import os import random import textwrap @@ -17,6 +18,7 @@ import requests import webob from codejail.safe_exec import SafeExecException +from django.conf import settings from django.test import override_settings from django.utils.encoding import smart_str from lms.djangoapps.courseware.user_state_client import XBlockUserState @@ -41,6 +43,10 @@ from . import get_test_system +FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS = settings.FEATURES.copy() +FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS['ENABLE_GRADING_METHOD_IN_PROBLEMS'] = True + + class CapaFactory: """ A helper class to create problem blocks with various parameters for testing. @@ -725,6 +731,364 @@ def test_submit_problem_correct(self): # and that this was considered attempt number 2 for grading purposes assert block.lcp.context['attempt'] == 2 + @patch('xmodule.capa_block.ProblemBlock.get_score_with_grading_method') + @patch('xmodule.capa.correctmap.CorrectMap.is_correct') + @patch('xmodule.capa_block.ProblemBlock.get_problem_html') + def test_submit_problem_with_grading_method_disable( + self, mock_html: Mock, mock_is_correct: Mock, mock_get_score: Mock + ): + """ + Test that the grading method is disabled by default. Then, the + `get_score_with_grading_method` method should not be called, and + always the last attempt as the final score. + """ + block = CapaFactory.create(attempts=0, max_attempts=3) + mock_html.return_value = "Test HTML" + + # First Attempt + mock_is_correct.return_value = True + get_request_dict = {CapaFactory.input_key(): '3.14'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 1 + assert block.lcp.context['attempt'] == 1 + assert block.score == Score(raw_earned=1, raw_possible=1) + mock_get_score.assert_not_called() + + # Second Attempt + mock_is_correct.return_value = False + get_request_dict = {CapaFactory.input_key(): '3.50'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 2 + assert block.lcp.context['attempt'] == 2 + assert block.score == Score(raw_earned=0, raw_possible=1) + mock_get_score.assert_not_called() + + # Third Attempt + mock_is_correct.return_value = True + get_request_dict = {CapaFactory.input_key(): '3.14'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 3 + assert block.lcp.context['attempt'] == 3 + assert block.score == Score(raw_earned=1, raw_possible=1) + mock_get_score.assert_not_called() + + @override_settings(FEATURES=FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS) + @patch('xmodule.capa.correctmap.CorrectMap.is_correct') + @patch('xmodule.capa_block.ProblemBlock.get_problem_html') + def test_submit_problem_with_grading_method_enable( + self, mock_html: Mock, mock_is_correct: Mock + ): + """ + Test that the grading method is enabled when submit a problem. + Then, the `get_score_with_grading_method` method should be called. + """ + block = CapaFactory.create(attempts=0) + mock_html.return_value = "Test HTML" + mock_is_correct.return_value = True + + with patch.object( + ProblemBlock, 'get_score_with_grading_method', wraps=block.get_score_with_grading_method + ) as mock_get_score: + get_request_dict = {CapaFactory.input_key(): '3.14'} + block.submit_problem(get_request_dict) + + assert block.attempts == 1 + assert block.lcp.context['attempt'] == 1 + assert block.score == Score(raw_earned=1, raw_possible=1) + mock_get_score.assert_called() + + @patch('xmodule.capa.correctmap.CorrectMap.is_correct') + @patch('xmodule.capa_block.ProblemBlock.get_problem_html') + def test_submit_problem_grading_method_disable_to_enable( + self, mock_html: Mock, mock_is_correct: Mock + ): + """ + Test when the grading method is disabled and then enabled. + + When the grading method is disabled, the final score is always the last attempt. + When the grading method is enabled, the final score is calculated according to the grading method. + """ + block = CapaFactory.create(attempts=0, max_attempts=4) + mock_html.return_value = "Test HTML" + + # Disabled grading method + with patch( + 'xmodule.capa_block.ProblemBlock.is_grading_method_enabled', + new_callable=mock.PropertyMock, + return_value=False + ): + # First Attempt + mock_is_correct.return_value = True + get_request_dict = {CapaFactory.input_key(): '3.14'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 1 + assert block.lcp.context['attempt'] == 1 + assert block.score == Score(raw_earned=1, raw_possible=1) + + # Second Attempt + mock_is_correct.return_value = False + get_request_dict = {CapaFactory.input_key(): '3.50'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 2 + assert block.lcp.context['attempt'] == 2 + assert block.score == Score(raw_earned=0, raw_possible=1) + + # Enabled grading method + with patch( + 'xmodule.capa_block.ProblemBlock.is_grading_method_enabled', + new_callable=mock.PropertyMock, + return_value=True + ): + # Third Attempt + mock_is_correct.return_value = False + get_request_dict = {CapaFactory.input_key(): '3.96'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 3 + assert block.lcp.context['attempt'] == 3 + assert block.score == Score(raw_earned=0, raw_possible=1) + + # Fourth Attempt + block.grading_method = 'highest_score' + mock_is_correct.return_value = False + get_request_dict = {CapaFactory.input_key(): '3.99'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 4 + assert block.lcp.context['attempt'] == 4 + assert block.score == Score(raw_earned=1, raw_possible=1) + + @patch('xmodule.capa.correctmap.CorrectMap.is_correct') + @patch('xmodule.capa_block.ProblemBlock.get_problem_html') + def test_submit_problem_grading_method_enable_to_disable( + self, mock_html: Mock, mock_is_correct: Mock + ): + """ + Test when the grading method is enabled and then disabled. + + When the grading method is enabled, the final score is calculated according to the grading method. + When the grading method is disabled, the final score is always the last attempt. + """ + block = CapaFactory.create(attempts=0, max_attempts=4, grading_method='highest_score') + mock_html.return_value = "Test HTML" + + # Enabled grading method + with patch( + 'xmodule.capa_block.ProblemBlock.is_grading_method_enabled', + new_callable=mock.PropertyMock, + return_value=True + ): + # First Attempt + mock_is_correct.return_value = True + get_request_dict = {CapaFactory.input_key(): '3.14'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 1 + assert block.lcp.context['attempt'] == 1 + assert block.score == Score(raw_earned=1, raw_possible=1) + + # Second Attempt + mock_is_correct.return_value = False + get_request_dict = {CapaFactory.input_key(): '3.50'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 2 + assert block.lcp.context['attempt'] == 2 + assert block.score == Score(raw_earned=1, raw_possible=1) + + # Disabled grading method + with patch( + 'xmodule.capa_block.ProblemBlock.is_grading_method_enabled', + new_callable=mock.PropertyMock, + return_value=False + ): + # Third Attempt + mock_is_correct.return_value = False + get_request_dict = {CapaFactory.input_key(): '3.96'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 3 + assert block.lcp.context['attempt'] == 3 + assert block.score == Score(raw_earned=0, raw_possible=1) + + # Fourth Attempt + mock_is_correct.return_value = True + get_request_dict = {CapaFactory.input_key(): '3.14'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 4 + assert block.lcp.context['attempt'] == 4 + assert block.score == Score(raw_earned=1, raw_possible=1) + + @override_settings(FEATURES=FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS) + @patch('xmodule.capa.correctmap.CorrectMap.is_correct') + @patch('xmodule.capa_block.ProblemBlock.get_problem_html') + def test_submit_problem_correct_last_score(self, mock_html: Mock, mock_is_correct: Mock): + """ + Test the `last_score` grading method. + + When the grading method is `last_score`, + the final score is always the last attempt. + """ + # default grading method is last_score + block = CapaFactory.create(attempts=0, max_attempts=2) + mock_html.return_value = "Test HTML" + + # First Attempt + mock_is_correct.return_value = True + get_request_dict = {CapaFactory.input_key(): '3.14'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 1 + assert block.lcp.context['attempt'] == 1 + assert block.score == Score(raw_earned=1, raw_possible=1) + + # Second Attempt + mock_is_correct.return_value = False + get_request_dict = {CapaFactory.input_key(): '3.54'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 2 + assert block.lcp.context['attempt'] == 2 + assert block.score == Score(raw_earned=0, raw_possible=1) + + @override_settings(FEATURES=FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS) + @patch('xmodule.capa.correctmap.CorrectMap.is_correct') + @patch('xmodule.capa_block.ProblemBlock.get_problem_html') + def test_submit_problem_correct_highest_score(self, mock_html: Mock, mock_is_correct: Mock): + """ + Test the `highest_score` grading method. + + When the grading method is `highest_score`, + the final score is the highest score among all attempts. + """ + block = CapaFactory.create(attempts=0, max_attempts=2, grading_method='highest_score') + mock_html.return_value = "Test HTML" + + # First Attempt + mock_is_correct.return_value = True + get_request_dict = {CapaFactory.input_key(): '3.14'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 1 + assert block.lcp.context['attempt'] == 1 + assert block.score == Score(raw_earned=1, raw_possible=1) + + # Second Attempt + mock_is_correct.return_value = False + get_request_dict = {CapaFactory.input_key(): '3.54'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 2 + assert block.lcp.context['attempt'] == 2 + assert block.score == Score(raw_earned=1, raw_possible=1) + + @override_settings(FEATURES=FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS) + @patch('xmodule.capa.correctmap.CorrectMap.is_correct') + @patch('xmodule.capa_block.ProblemBlock.get_problem_html') + def test_submit_problem_correct_first_score(self, mock_html: Mock, mock_is_correct: Mock): + """ + Test the `first_score` grading method. + + When the grading method is `first_score`, + the final score is the first score among all attempts. + """ + block = CapaFactory.create(attempts=0, max_attempts=2, grading_method='first_score') + mock_html.return_value = "Test HTML" + + # First Attempt + mock_is_correct.return_value = False + + get_request_dict = {CapaFactory.input_key(): '3.14'} + block.submit_problem(get_request_dict) + + assert block.attempts == 1 + assert block.lcp.context['attempt'] == 1 + assert block.score == Score(raw_earned=0, raw_possible=1) + + # Second Attempt + mock_is_correct.return_value = True + get_request_dict = {CapaFactory.input_key(): '3.54'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 2 + assert block.lcp.context['attempt'] == 2 + assert block.score == Score(raw_earned=0, raw_possible=1) + + @override_settings(FEATURES=FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS) + @patch('xmodule.capa.correctmap.CorrectMap.is_correct') + @patch('xmodule.capa_block.ProblemBlock.get_problem_html') + def test_submit_problem_correct_average_score(self, mock_html: Mock, mock_is_correct: Mock): + """ + Test the `average_score` grading method. + + When the grading method is `average_score`, + the final score is the average score among all attempts. + """ + block = CapaFactory.create(attempts=0, max_attempts=4, grading_method='average_score') + mock_html.return_value = "Test HTML" + + # First Attempt + mock_is_correct.return_value = False + get_request_dict = {CapaFactory.input_key(): '3.14'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 1 + assert block.lcp.context['attempt'] == 1 + assert block.score == Score(raw_earned=0, raw_possible=1) + + # Second Attempt + mock_is_correct.return_value = True + get_request_dict = {CapaFactory.input_key(): '3.54'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 2 + assert block.lcp.context['attempt'] == 2 + assert block.score == Score(raw_earned=0.5, raw_possible=1) + + # Third Attempt + mock_is_correct.return_value = False + get_request_dict = {CapaFactory.input_key(): '3.45'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 3 + assert block.lcp.context['attempt'] == 3 + assert block.score == Score(raw_earned=0.33, raw_possible=1) + + # Fourth Attempt + mock_is_correct.return_value = False + get_request_dict = {CapaFactory.input_key(): '41.3'} + + block.submit_problem(get_request_dict) + + assert block.attempts == 4 + assert block.lcp.context['attempt'] == 4 + assert block.score == Score(raw_earned=0.25, raw_possible=1) + def test_submit_problem_incorrect(self): block = CapaFactory.create(attempts=0) @@ -1218,6 +1582,224 @@ def test_rescore_problem_incorrect(self): # and that this is treated as the first attempt for grading purposes assert block.lcp.context['attempt'] == 1 + @patch('xmodule.capa_block.ProblemBlock.get_rescore_with_grading_method') + def test_rescore_problem_with_grading_method_disable(self, mock_get_rescore: Mock): + """ + Test the rescore method with grading method disabled. + In this case, the rescore method should not call `get_rescore_with_grading_method` method. + """ + block = CapaFactory.create(attempts=0, done=True) + + block.rescore(only_if_higher=False) + + assert block.attempts == 0 + assert block.lcp.context['attempt'] == 1 + mock_get_rescore.assert_not_called() + + @override_settings(FEATURES=FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS) + def test_rescore_problem_with_grading_method_enable(self): + """ + Test the rescore method with grading method enabled. + In this case, the rescore method should call `get_rescore_with_grading_method` method. + """ + block = CapaFactory.create(attempts=0, done=True) + + with patch.object( + ProblemBlock, 'get_rescore_with_grading_method', wraps=block.get_rescore_with_grading_method + ) as mock_get_rescore: + + block.rescore(only_if_higher=False) + + assert block.attempts == 0 + assert block.lcp.context['attempt'] == 1 + mock_get_rescore.assert_called() + + @patch('xmodule.capa_block.ProblemBlock.publish_grade') + def test_rescore_problem_grading_method_disable_to_enable(self, mock_publish_grade: Mock): + """ + Test the rescore method the grading method is disabled and then enabled. + + When the grading method is disabled, the final score is always the last score. + When the grading method is enabled, the final score is the score based on the grading method. + """ + block = CapaFactory.create(attempts=0, max_attempts=3) + + get_request_dict = {CapaFactory.input_key(): '3.21'} + block.submit_problem(get_request_dict) + + get_request_dict = {CapaFactory.input_key(): '3.45'} + block.submit_problem(get_request_dict) + + get_request_dict = {CapaFactory.input_key(): '3.14'} + block.submit_problem(get_request_dict) + + # Disabled grading method + with patch( + 'xmodule.capa_block.ProblemBlock.is_grading_method_enabled', + new_callable=mock.PropertyMock, + return_value=False + ): + # Score is the last score + assert block.score == Score(raw_earned=1, raw_possible=1) + + block.rescore(only_if_higher=False) + + # Still Score is the last score + mock_publish_grade.assert_called_with( + score=Score(raw_earned=1, raw_possible=1), only_if_higher=False + ) + + # Enabled grading method + with patch( + 'xmodule.capa_block.ProblemBlock.is_grading_method_enabled', + new_callable=mock.PropertyMock, + return_value=True + ): + with patch( + 'xmodule.capa.capa_problem.LoncapaProblem.is_grading_method_enabled', + new_callable=mock.PropertyMock, + return_value=True + ): + # Change grading method to 'first_score' + block.grading_method = 'first_score' + block.rescore(only_if_higher=False) + + mock_publish_grade.assert_called_with( + score=Score(raw_earned=0, raw_possible=1), only_if_higher=False + ) + + # Change grading method to 'highest_score' + block.grading_method = 'highest_score' + block.rescore(only_if_higher=False) + + mock_publish_grade.assert_called_with( + score=Score(raw_earned=1, raw_possible=1), only_if_higher=False + ) + + # Change grading method to 'average_score' + block.grading_method = 'average_score' + block.rescore(only_if_higher=False) + + mock_publish_grade.assert_called_with( + score=Score(raw_earned=0.33, raw_possible=1), only_if_higher=False + ) + + @patch('xmodule.capa_block.ProblemBlock.publish_grade') + def test_rescore_problem_grading_method_enable_to_disable(self, mock_publish_grade: Mock): + """ + Test the rescore method the grading method is enabled and then disabled. + + When the grading method is enabled, the final score is the score based on the grading method. + When the grading method is disabled, the final score is always the last score. + """ + block = CapaFactory.create(attempts=0, max_attempts=3) + + get_request_dict = {CapaFactory.input_key(): '3.21'} + block.submit_problem(get_request_dict) + + get_request_dict = {CapaFactory.input_key(): '3.45'} + block.submit_problem(get_request_dict) + + get_request_dict = {CapaFactory.input_key(): '3.14'} + block.submit_problem(get_request_dict) + + # Enabled grading method + with patch( + 'xmodule.capa_block.ProblemBlock.is_grading_method_enabled', + new_callable=mock.PropertyMock, + return_value=True + ): + with patch( + 'xmodule.capa.capa_problem.LoncapaProblem.is_grading_method_enabled', + new_callable=mock.PropertyMock, + return_value=True + ): + # Grading method is 'last_score' + assert block.grading_method == 'last_score' + assert block.score == Score(raw_earned=1, raw_possible=1) + + # Change grading method to 'first_score' + block.grading_method = 'first_score' + block.rescore(only_if_higher=False) + + mock_publish_grade.assert_called_with( + score=Score(raw_earned=0, raw_possible=1), only_if_higher=False + ) + + # Change grading method to 'highest_score' + block.grading_method = 'highest_score' + block.rescore(only_if_higher=False) + + mock_publish_grade.assert_called_with( + score=Score(raw_earned=1, raw_possible=1), only_if_higher=False + ) + + # Change grading method to 'average_score' + block.grading_method = 'average_score' + block.rescore(only_if_higher=False) + + mock_publish_grade.assert_called_with( + score=Score(raw_earned=0.33, raw_possible=1), only_if_higher=False + ) + + # Disabled grading method + with patch( + 'xmodule.capa_block.ProblemBlock.is_grading_method_enabled', + new_callable=mock.PropertyMock, + return_value=False + ): + block.rescore(only_if_higher=False) + # The score is the last score + assert block.score == Score(raw_earned=1, raw_possible=1) + + @override_settings(FEATURES=FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS) + @patch('xmodule.capa_block.ProblemBlock.publish_grade') + def test_rescore_problem_update_grading_method(self, mock_publish_grade: Mock): + """ + Test the rescore method when the grading method is updated. + + When the grading method is updated, the final + score is the score based on the new grading method. + """ + block = CapaFactory.create(attempts=0, max_attempts=3) + + get_request_dict = {CapaFactory.input_key(): '3.21'} + block.submit_problem(get_request_dict) + + get_request_dict = {CapaFactory.input_key(): '3.45'} + block.submit_problem(get_request_dict) + + get_request_dict = {CapaFactory.input_key(): '3.14'} + block.submit_problem(get_request_dict) + + # Grading method is 'last_score' + assert block.grading_method == 'last_score' + assert block.score == Score(raw_earned=1, raw_possible=1) + + # Change grading method to 'first_score' + block.grading_method = 'first_score' + block.rescore(only_if_higher=False) + + mock_publish_grade.assert_called_with( + score=Score(raw_earned=0, raw_possible=1), only_if_higher=False + ) + + # Change grading method to 'highest_score' + block.grading_method = 'highest_score' + block.rescore(only_if_higher=False) + + mock_publish_grade.assert_called_with( + score=Score(raw_earned=1, raw_possible=1), only_if_higher=False + ) + + # Change grading method to 'average_score' + block.grading_method = 'average_score' + block.rescore(only_if_higher=False) + + mock_publish_grade.assert_called_with( + score=Score(raw_earned=0.33, raw_possible=1), only_if_higher=False + ) + def test_rescore_problem_not_done(self): # Simulate that the problem is NOT done block = CapaFactory.create(done=False) @@ -1235,6 +1817,144 @@ def test_rescore_problem_not_supported(self): with pytest.raises(NotImplementedError): block.rescore(only_if_higher=False) + def test_calculate_score_list(self): + """ + Test that the `calculate_score_list` method returns the correct list of scores. + """ + block = CapaFactory.create(correct=True) + correct_map = CorrectMap(answer_id='1_2_1', correctness="correct", npoints=1) + block.lcp.correct_map_history = [correct_map, correct_map] + + with patch.object(block.lcp, 'calculate_score', return_value={'score': 1, 'total': 2}): + result = block.calculate_score_list() + expected_result = [Score(raw_earned=1, raw_possible=2), Score(raw_earned=1, raw_possible=2)] + self.assertEqual(result, expected_result) + + def test_calculate_score_list_empty(self): + """ + Test that the `calculate_score_list` method returns an + empty list when the `correct_map_history` is empty. + + The `calculate_score` method should not be called. + """ + block = CapaFactory.create(correct=True) + block.lcp.correct_map_history = [] + + with patch.object(block.lcp, 'calculate_score', return_value=Mock()): + result = block.calculate_score_list() + self.assertEqual(result, []) + block.lcp.calculate_score.assert_not_called() + + def test_update_correctness_list_updates_attempt(self): + """ + Test that the `update_correctness_list` method updates the attempt number. + """ + block = CapaFactory.create(correct=True, attempts=0) + + block.update_correctness_list() + + self.assertEqual(block.lcp.context['attempt'], 1) + + def test_update_correctness_list_with_history(self): + """ + Test that the `update_correctness_list` method updates the correct map history. + """ + block = CapaFactory.create(correct=True, attempts=2) + correct_map = CorrectMap(answer_id='1_2_1', correctness="correct", npoints=1) + student_answers = {'1_2_1': 'abcd'} + block.correct_map_history = [correct_map] + block.student_answers_history = [student_answers] + + with patch.object(block.lcp, 'get_grade_from_current_answers', return_value=correct_map): + block.update_correctness_list() + self.assertEqual(block.lcp.context['attempt'], 2) + block.lcp.get_grade_from_current_answers.assert_called_once_with(student_answers, correct_map) + self.assertEqual(block.lcp.correct_map_history, [correct_map]) + self.assertEqual(block.lcp.correct_map.get_dict(), correct_map.get_dict()) + + def test_update_correctness_list_without_history(self): + """ + Test that the `update_correctness_list` method does not + update the correct map history because the history is empty. + + The `get_grade_from_current_answers` method should not be called. + """ + block = CapaFactory.create(correct=True, attempts=1) + block.correct_map_history = [] + block.student_answers_history = [] + + with patch.object(block.lcp, 'get_grade_from_current_answers', return_value=Mock()): + block.update_correctness_list() + self.assertEqual(block.lcp.context['attempt'], 1) + block.lcp.get_grade_from_current_answers.assert_not_called() + + @override_settings(FEATURES=FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS) + def test_get_rescore_with_grading_method(self): + """ + Test that the `get_rescore_with_grading_method` method returns the correct score. + """ + block = CapaFactory.create(done=True, attempts=0, max_attempts=2) + get_request_dict = {CapaFactory.input_key(): '3.21'} + block.submit_problem(get_request_dict) + get_request_dict = {CapaFactory.input_key(): '3.14'} + block.submit_problem(get_request_dict) + + result = block.get_rescore_with_grading_method() + + self.assertEqual(result, Score(raw_earned=1, raw_possible=1)) + + def test_get_score_with_grading_method(self): + """ + Test that the `get_score_with_grading_method` method + returns the correct score based on the grading method. + """ + block = CapaFactory.create(done=True, attempts=0, max_attempts=2) + get_request_dict = {CapaFactory.input_key(): '3.21'} + block.submit_problem(get_request_dict) + get_request_dict = {CapaFactory.input_key(): '3.14'} + block.submit_problem(get_request_dict) + expected_score = Score(raw_earned=1, raw_possible=1) + + score = block.get_score_with_grading_method(block.score_from_lcp(block.lcp)) + + self.assertEqual(score, expected_score) + self.assertEqual(block.score, expected_score) + + @patch('xmodule.capa_block.ProblemBlock.score_from_lcp') + def test_get_score_with_grading_method_updates_score(self, mock_score_from_lcp: Mock): + """ + Test that the `get_score_with_grading_method` method returns the correct score. + + Check that the score is returned with the correct score and the score + history is updated including that score. + """ + block = CapaFactory.create(attempts=1) + current_score = Score(raw_earned=1, raw_possible=1) + mock_score_from_lcp.return_value = current_score + + score = block.get_score_with_grading_method(current_score) + + self.assertEqual(score, current_score) + self.assertEqual(block.score_history, [current_score]) + + def test_get_score_with_grading_method_calls_grading_method_handler(self): + """ + Test that the `get_score_with_grading_method` method calls + the grading method handler with the appropriate arguments. + """ + block = CapaFactory.create(attempts=1) + current_score = Score(raw_earned=0, raw_possible=1) + + with patch('xmodule.capa_block.GradingMethodHandler') as mock_handler: + mock_handler.return_value.get_score.return_value = current_score + block.get_score_with_grading_method(current_score) + mock_handler.assert_called_once_with( + Score(raw_earned=0, raw_possible=1), + "last_score", + block.score_history, + current_score.raw_possible, + ) + def capa_factory_for_problem_xml(self, xml): # lint-amnesty, pylint: disable=missing-function-docstring class CustomCapaFactory(CapaFactory): """ @@ -1263,7 +1983,12 @@ def test_codejail_error_upon_problem_creation(self): def _rescore_problem_error_helper(self, exception_class): """Helper to allow testing all errors that rescoring might return.""" # Create the block - block = CapaFactory.create(attempts=1, done=True) + block = CapaFactory.create(attempts=0) + CapaFactory.answer_key() + + # Check the problem + get_request_dict = {CapaFactory.input_key(): '1'} + block.submit_problem(get_request_dict) # Simulate answering a problem that raises the exception with patch('xmodule.capa.capa_problem.LoncapaProblem.get_grade_from_current_answers') as mock_rescore: