diff --git a/cms/envs/common.py b/cms/envs/common.py
index 5379fa6891..a33c415789 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -570,6 +570,15 @@ FEATURES = {
# .. 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 1dd451a41c..300543def6 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 4aa14e3225..59ccfac6ca 100644
--- a/lms/djangoapps/instructor/tests/test_enrollment.py
+++ b/lms/djangoapps/instructor/tests/test_enrollment.py
@@ -543,7 +543,10 @@ class TestInstructorEnrollmentStudentModule(SharedModuleStoreTestCase):
'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 3e651a915f..c45679e4b9 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -1049,7 +1049,16 @@ FEATURES = {
# .. 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 3bf90b5daf..b785e4aa68 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 @@ from openedx.core.djangolib.markup import HTML
% if attempts_allowed and (not submit_disabled_cta or attempts_used == 0):
${ngettext("You have used {num_used} of {num_total} attempt", "You have used {num_used} of {num_total} attempts", attempts_allowed).format(num_used=attempts_used, num_total=attempts_allowed)}
% endif
+ % if grading_method:
+
${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 797e95e1d5..6d78e156f2 100644
--- a/xmodule/capa/capa_problem.py
+++ b/xmodule/capa/capa_problem.py
@@ -20,6 +20,7 @@ import re
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 @@ class LoncapaProblem(object):
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 @@ class LoncapaProblem(object):
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 @@ class LoncapaProblem(object):
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 @@ class LoncapaProblem(object):
'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 @@ class LoncapaProblem(object):
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 @@ class LoncapaProblem(object):
"""
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 @@ class LoncapaProblem(object):
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 @@ class LoncapaProblem(object):
# 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 d9a6ea0dba..88a06b4c7e 100644
--- a/xmodule/capa/tests/test_capa_problem.py
+++ b/xmodule/capa/tests/test_capa_problem.py
@@ -4,17 +4,24 @@ Test capa problem.
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 @@ class CAPAProblemReportHelpersTest(unittest.TestCase):
# 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 7b58b5aa9a..e7d917fee6 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 lxml import etree
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 common.djangoapps.xblock_django.constants import (
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 @@ class ProblemBlock(
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 @@ class ProblemBlock(
# 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 @@ class ProblemBlock(
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 @@ class ProblemBlock(
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 @@ class ProblemBlock(
'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 @@ class ProblemBlock(
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 @@ class ProblemBlock(
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 @@ class ProblemBlock(
}
# 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 @@ class ProblemBlock(
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 @@ class ProblemBlock(
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 @@ class ProblemBlock(
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 @@ class ProblemBlock(
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 7837074f9d..2e65304d44 100644
--- a/xmodule/fields.py
+++ b/xmodule/fields.py
@@ -7,7 +7,7 @@ import time
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 @@ class ScoreField(JSONField):
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 ab94028fc9..4249454782 100644
--- a/xmodule/tests/test_capa_block.py
+++ b/xmodule/tests/test_capa_block.py
@@ -6,6 +6,7 @@ Tests of the Capa XModule
import datetime
import json
+import mock
import os
import random
import textwrap
@@ -17,6 +18,7 @@ import ddt
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 ..capa_block import RANDOMIZATION, SHOWANSWER
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 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
# 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 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
# 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 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
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 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
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: