diff --git a/xmodule/capa_block.py b/xmodule/capa_block.py index 958ca1354142..2c46671c3c68 100644 --- a/xmodule/capa_block.py +++ b/xmodule/capa_block.py @@ -1807,6 +1807,19 @@ def submit_problem( # pylint: disable=too-many-statements,too-many-branches,too ) return {"success": msg, "html": ""} + # Reject empty submissions before they reach the grading engine. Without this + # guard, grade_answers({}) raises a cryptic StudentInputError that is surfaced + # to the learner verbatim via the codejail-traceback parser (see #36829). + if not answers or all( + value in ("", [], {}, None) for value in answers.values() + ): + event_info["failure"] = "missing_answer" + self.publish_unmasked("problem_check_fail", event_info) + return { + "success": _("You must provide an answer before submitting."), + "html": "", + } + try: # expose the attempt number to a potential python custom grader # self.lcp.context['attempt'] refers to the attempt number (1-based) diff --git a/xmodule/tests/test_capa_block.py b/xmodule/tests/test_capa_block.py index 49860b8fa3cc..9485a4041f51 100644 --- a/xmodule/tests/test_capa_block.py +++ b/xmodule/tests/test_capa_block.py @@ -1490,6 +1490,73 @@ def test_submit_problem_error_with_staff_user(self): # but that it was considered the second attempt for grading purposes assert block.lcp.context["attempt"] == 2 + def test_unit_submit_problem_empty_answer(self): + """ + Unit test for bug #36829: empty submissions must be rejected server-side + with a clear translatable message, must NOT reach the grading engine, + and must NOT consume an attempt. + """ + block = CapaFactory.create(attempts=1, user_is_staff=False) + + with patch( + "xblocks_contrib.problem.capa.capa_problem.LoncapaProblem.grade_answers" + ) as mock_grade: + result = block.submit_problem({}) + mock_grade.assert_not_called() + + assert result["success"] == "You must provide an answer before submitting." + assert result["html"] == "" + assert block.attempts == 1 + + def test_integration_submit_problem_all_blank_values(self): + """ + Integration test for bug #36829: a payload where every input is present + but blank must be treated the same as an empty payload. + """ + block = CapaFactory.create(attempts=1, user_is_staff=False) + + with patch( + "xblocks_contrib.problem.capa.capa_problem.LoncapaProblem.grade_answers" + ) as mock_grade: + result = block.submit_problem({CapaFactory.input_key(): ""}) + mock_grade.assert_not_called() + + assert result["success"] == "You must provide an answer before submitting." + assert block.attempts == 1 + + def test_submit_problem_zero_is_not_empty(self): + """ + Regression guard for bug #36829: a literal '0' answer must NOT be + rejected as empty — it is a valid numeric response. + """ + block = CapaFactory.create(attempts=1, user_is_staff=False) + + with patch( + "xblocks_contrib.problem.capa.capa_problem.LoncapaProblem.grade_answers" + ) as mock_grade: + mock_grade.return_value = CorrectMap() + block.submit_problem({CapaFactory.input_key(): "0"}) + mock_grade.assert_called_once() + + # Attempt was accepted and incremented. + assert block.attempts == 2 + + def test_bug_36829_regression_empty_answer_publishes_fail_event(self): + """ + Regression for bug #36829: empty submissions emit a problem_check_fail + tracking event with failure='missing_answer' so the rejection is + observable in tracking logs (parity with the other pre-grading guards). + """ + block = CapaFactory.create(attempts=1, user_is_staff=False) + + with patch.object(block, "publish_unmasked") as mock_publish: + block.submit_problem({}) + + mock_publish.assert_called_once() + event_name, event_info = mock_publish.call_args[0] + assert event_name == "problem_check_fail" + assert event_info["failure"] == "missing_answer" + @ddt.data( ("never", True, None, "submitted"), ("never", False, None, "submitted"),