diff --git a/public/mod/quiz/classes/quiz_attempt.php b/public/mod/quiz/classes/quiz_attempt.php index afc53be1468..274dd42bb08 100644 --- a/public/mod/quiz/classes/quiz_attempt.php +++ b/public/mod/quiz/classes/quiz_attempt.php @@ -1642,6 +1642,7 @@ class quiz_attempt { * @param bool $studentisonline is the student currently interacting with Moodle? */ public function handle_if_time_expired($timestamp, $studentisonline) { + global $DB; $timeclose = $this->get_access_manager($timestamp)->get_end_time($this->attempt); @@ -1676,8 +1677,10 @@ class quiz_attempt { // Transition to the appropriate state. switch ($this->quizobj->get_quiz()->overduehandling) { case 'autosubmit': + $transaction = $DB->start_delegated_transaction(); $this->process_submit($timestamp, false, $studentisonline ? $timestamp : $timeclose, $studentisonline); $this->process_grade_submission($studentisonline ? $timestamp : $timeclose); + $transaction->allow_commit(); return; case 'graceperiod': diff --git a/public/mod/quiz/classes/task/grade_submission.php b/public/mod/quiz/classes/task/grade_submission.php new file mode 100644 index 00000000000..e5811992475 --- /dev/null +++ b/public/mod/quiz/classes/task/grade_submission.php @@ -0,0 +1,65 @@ +. + +namespace mod_quiz\task; + +use core\task\adhoc_task; +use mod_quiz\quiz_attempt; + +/** + * Ad-hoc task to grade a submitted attempt. + * + * This is used (currently) for fixing quiz attempts which are stuck in "submitted" state, and + * in the future will also support the asynchronous grading of quiz attempts. + * + * @package mod_quiz + * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Mark Johnson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class grade_submission extends adhoc_task { + /** + * Return an instance of the task, with the attempt ID stored in custom data. + * + * @param int $attemptid + * @return self + */ + public static function instance(int $attemptid): self { + $task = new self(); + $task->set_custom_data((object)['attemptid' => $attemptid]); + return $task; + } + + /** + * Perform grading for the referenced submitted attempt. + */ + public function execute(): void { + global $DB; + $data = $this->get_custom_data(); + if ($DB->record_exists('quiz_attempts', ['id' => $data->attemptid, 'state' => quiz_attempt::SUBMITTED])) { + $attempt = quiz_attempt::create($data->attemptid); + mtrace( + 'Grading attempt for user ID ' . + $attempt->get_userid() . ' for quiz ' . + $attempt->get_quiz_name() . ' on course ' . + $attempt->get_course()->shortname + ); + $attempt->process_grade_submission(time()); + } else { + mtrace('Attempt ID ' . $data->attemptid . ' not found, or not in submitted state.'); + } + } +} diff --git a/public/mod/quiz/db/upgrade.php b/public/mod/quiz/db/upgrade.php index fbd6d6a80fb..e4a9f623a59 100644 --- a/public/mod/quiz/db/upgrade.php +++ b/public/mod/quiz/db/upgrade.php @@ -149,6 +149,24 @@ function xmldb_quiz_upgrade($oldversion) { upgrade_mod_savepoint(true, 2025041401, 'quiz'); } + if ($oldversion < 2026011600) { + // Queue tasks to process stuck quiz attempts (state = 'submitted'). + $attemptids = $DB->get_fieldset_select( + 'quiz_attempts', + 'id', + 'state = ?', + [\mod_quiz\quiz_attempt::SUBMITTED], + ); + + foreach ($attemptids as $attemptid) { + $task = \mod_quiz\task\grade_submission::instance($attemptid); + \core\task\manager::queue_adhoc_task($task, true); + } + + // Quiz savepoint reached. + upgrade_mod_savepoint(true, 2026011600, 'quiz'); + } + // Automatically generated Moodle v5.1.0 release upgrade line. // Put any upgrade step following this. diff --git a/public/mod/quiz/tests/task/grade_submission_test.php b/public/mod/quiz/tests/task/grade_submission_test.php new file mode 100644 index 00000000000..f4563651b59 --- /dev/null +++ b/public/mod/quiz/tests/task/grade_submission_test.php @@ -0,0 +1,127 @@ +. + +namespace mod_quiz; + +use advanced_testcase; +use mod_quiz\quiz_attempt; +use mod_quiz\quiz_settings; +use mod_quiz\task\grade_submission; + +/** + * Unit tests for grade_submission task. + * + * @package mod_quiz + * @copyright 2026 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Conn Warwicker + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \mod_quiz\task\grade_submission + */ +final class grade_submission_test extends advanced_testcase { + /** + * Create a quiz attempt to use for testing + * @return quiz_attempt + */ + private function create_attempt(): quiz_attempt { + $this->resetAfterTest(true); + + // Make a user to do the quiz. + $user = $this->getDataGenerator()->create_user(); + $course = $this->getDataGenerator()->create_course(); + + // Make a quiz. + $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); + $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2]); + $quizobj = quiz_settings::create($quiz->id, $user->id); + + // Add a question. + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat = $questiongenerator->create_question_category(); + $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); + quiz_add_quiz_question($question->id, $quiz, 1); + + $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null, false, [], [], $user->id); + return quiz_attempt::create($attempt->id); + } + + /** + * Test that passing through an invalid attempt id does not break the task in any way + */ + public function test_invalid_attempt(): void { + // Try a negative number which will always be invalid as an ID. + $task = grade_submission::instance(-1); + + // Execute the task and confirm that nothing untoward happens. + $task->execute(); + $this->expectOutputString("Attempt ID -1 not found, or not in submitted state.\n"); + } + + /** + * Test executing the task on an inprogress attempt. + */ + public function test_attempt_state_inprogress(): void { + // Try and inprogress attempt. + $attempt = $this->create_attempt(); + $this->assertEquals(quiz_attempt::IN_PROGRESS, $attempt->get_state()); + + // Execute the task for this attempt. + $task = grade_submission::instance($attempt->get_attemptid()); + $task->execute(); + + // This should not be executed by the task. + $this->expectOutputString("Attempt ID " . $attempt->get_attemptid() . " not found, or not in submitted state.\n"); + } + + /** + * Test executing the task on a submitted attempt. + */ + public function test_attempt_state_submitted(): void { + // Try a "submitted" attempt, which is the expected state to be processed by the task. + $attempt = $this->create_attempt(); + $attempt->process_submit(time(), false); + $this->assertEquals(quiz_attempt::SUBMITTED, $attempt->get_state()); + + // Execute the task for this attempt. + $task = grade_submission::instance($attempt->get_attemptid()); + $task->execute(); + + // This should grade the attempt and mark it as finished. + $this->expectOutputRegex('/Grading attempt for user ID \d+ for quiz Quiz 1 on course tc_1/'); + + // Reload the attempt, as the `attempt` record on the quiz_attempt object doesn't seem to get updated. + $attempt = quiz_attempt::create($attempt->get_attemptid()); + $this->assertEquals(quiz_attempt::FINISHED, $attempt->get_state()); + } + + /** + * Test executing the task on a finished attempt. + */ + public function test_attempt_state_finished(): void { + // Try a "finished" attempt. + $attempt = $this->create_attempt(); + $attempt->process_grade_submission(time()); + + // At this point it should be finished. + $this->assertEquals(quiz_attempt::FINISHED, $attempt->get_state()); + + // Now try the task again, on the finished attempt. + $task = grade_submission::instance($attempt->get_attemptid()); + $task->execute(); + + // This should not be executed by the task. + $this->expectOutputString("Attempt ID {$attempt->get_attemptid()} not found, or not in submitted state.\n"); + } +} diff --git a/public/mod/quiz/version.php b/public/mod/quiz/version.php index 80899f4010f..6faeb80f55e 100644 --- a/public/mod/quiz/version.php +++ b/public/mod/quiz/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2025100600; +$plugin->version = 2026011600; $plugin->requires = 2025092600; $plugin->component = 'mod_quiz';