diff --git a/.upgradenotes/MDL-68806-2024060613451616.yml b/.upgradenotes/MDL-68806-2024060613451616.yml new file mode 100644 index 00000000000..31c3c24891e --- /dev/null +++ b/.upgradenotes/MDL-68806-2024060613451616.yml @@ -0,0 +1,21 @@ +issueNumber: MDL-68806 +notes: + mod_quiz: + - message: >- + quiz_attempt now has 2 additional state values, NOT_STARTED and + SUBMITTED. These represent attempts when an attempt has been + type: improved + - message: > + quiz_attempt_save_started now sets the IN_PROGRESS state, timestarted, + and saves the attempt, while the new quiz_attempt_save_not_started + function sets the NOT_STARTED state and saves the attempt. + type: changed + - message: > + quiz_attempt::process_finish is now deprecated, and its functionality is + split between ::process_submit, which saves the + + submission, sets the finish time and sets the SUBMITTED status, and + ::process_grade_submission which performs automated + + grading and sets the FINISHED status. + type: deprecated diff --git a/.upgradenotes/MDL-68806-2024060613482737.yml b/.upgradenotes/MDL-68806-2024060613482737.yml new file mode 100644 index 00000000000..3f455f1ca1d --- /dev/null +++ b/.upgradenotes/MDL-68806-2024060613482737.yml @@ -0,0 +1,20 @@ +issueNumber: MDL-68806 +notes: + mod_quiz: + - message: > + New quiz setting "precreateperiod" controls the period before timeopen + during which attempts will be pre-created using the new + + NOT_STARTED state. This setting is marked advanced and locked by + default, so can only be set by administrators. This setting + + is read by the \mod_quiz\task\precreate_attempts task to identify + quizzes due for pre-creation. + type: improved + - message: > + quiz_attempt_save_started Now takes an additional $timenow parameter, to + specify the timestart of the attempt. This was previously + + set in quiz_create_attempt, but is now set in quiz_attempt_save_started + and quiz_attempt_save_not_started. + type: changed diff --git a/.upgradenotes/MDL-68806-2024060613532440.yml b/.upgradenotes/MDL-68806-2024060613532440.yml new file mode 100644 index 00000000000..5d101d1b260 --- /dev/null +++ b/.upgradenotes/MDL-68806-2024060613532440.yml @@ -0,0 +1,15 @@ +issueNumber: MDL-68806 +notes: + core_question: + - message: > + question_attempt_step's constructor now accepts the class constant + TIMECREATED_ON_FIRST_RENDER as a value for the + + $timecreated parameter. Calling question_attempt::render for the first + time will now set the first step's timecreated + + to the current time if it is set to this value. Note, null could not be + used here as it is already used to indicate + + timecreated should be set to the current time. + type: changed diff --git a/.upgradenotes/MDL-68806-2024081214032268.yml b/.upgradenotes/MDL-68806-2024081214032268.yml new file mode 100644 index 00000000000..90d2725e052 --- /dev/null +++ b/.upgradenotes/MDL-68806-2024081214032268.yml @@ -0,0 +1,24 @@ +issueNumber: MDL-68806 +notes: + mod_quiz: + - message: >+ + The webservice function `mod_quiz_get_user_attempts` is now deprecated + in favour of `mod_quiz_get_user_quiz_attempts`. + + + With the introduction of the new NOT_STARTED quiz attempt state, + `mod_quiz_get_user_attempts` has been modified to not return NOT_STARTED + attempts, allowing clients such as the mobile app to continue working + without modifications. + + + `mod_quiz_get_user_quiz_attempts` will return attempts in all states, as + `mod_quiz_get_user_attempts` did before. Once clients are updated to + handle NOT_STARTED attempts, they can migrate to use this function. + + + A minor modification to `mod_quiz_start_attempt` has been made to allow + it to transparently start an existing attempt that is in the NOT_STARTED + state, rather than creating a new one. + + type: deprecated diff --git a/admin/tool/recyclebin/tests/course_bin_test.php b/admin/tool/recyclebin/tests/course_bin_test.php index 60cf0d5ead7..37e9f10a302 100644 --- a/admin/tool/recyclebin/tests/course_bin_test.php +++ b/admin/tool/recyclebin/tests/course_bin_test.php @@ -321,6 +321,7 @@ final class course_bin_test extends \advanced_testcase { $tosubmit = array(1 => array('answer' => '0')); $attemptobj->process_submitted_actions($timenow, false, $tosubmit); $attemptobj = quiz_attempt::create($attempt->id); - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); + $attemptobj->process_grade_submission($timenow); } } diff --git a/backup/moodle2/tests/restore_stepslib_date_test.php b/backup/moodle2/tests/restore_stepslib_date_test.php index e048efdf1d6..c362338b554 100644 --- a/backup/moodle2/tests/restore_stepslib_date_test.php +++ b/backup/moodle2/tests/restore_stepslib_date_test.php @@ -409,10 +409,11 @@ final class restore_stepslib_date_test extends \restore_date_testcase { // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); + $attemptobj->process_grade_submission($timenow); $questionattemptstepdates = []; - $originaliterator = $quba->get_attempt_iterator(); + $originaliterator = $attemptobj->get_question_usage()->get_attempt_iterator(); foreach ($originaliterator as $questionattempt) { $questionattemptstepdates[] = ['originaldate' => $questionattempt->get_last_action_time()]; } diff --git a/lib/db/install.xml b/lib/db/install.xml index eb1534c80e9..5d5f1c27381 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -1619,7 +1619,7 @@ - + diff --git a/mod/quiz/accessrule/seb/tests/test_helper_trait.php b/mod/quiz/accessrule/seb/tests/test_helper_trait.php index 40829fcd176..c096d802861 100644 --- a/mod/quiz/accessrule/seb/tests/test_helper_trait.php +++ b/mod/quiz/accessrule/seb/tests/test_helper_trait.php @@ -217,7 +217,8 @@ trait quizaccess_seb_test_helper_trait { // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); - $attemptobj->process_finish($starttime, false); + $attemptobj->process_submit($starttime, false); + $attemptobj->process_grade_submission($starttime); $this->setUser(); diff --git a/mod/quiz/backup/moodle2/backup_quiz_stepslib.php b/mod/quiz/backup/moodle2/backup_quiz_stepslib.php index c3914d17d39..f7dd9ef4343 100644 --- a/mod/quiz/backup/moodle2/backup_quiz_stepslib.php +++ b/mod/quiz/backup/moodle2/backup_quiz_stepslib.php @@ -41,7 +41,7 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru 'sumgrades', 'grade', 'timecreated', 'timemodified', 'password', 'subnet', 'browsersecurity', 'delay1', 'delay2', 'showuserpicture', 'showblocks', 'completionattemptsexhausted', - 'completionminattempts', 'allowofflineattempts']); + 'completionminattempts', 'allowofflineattempts', 'precreateattempts']); // Define elements for access rule subplugin settings. $this->add_subplugin_structure('quizaccess', $quiz, true); diff --git a/mod/quiz/classes/event/attempt_graded.php b/mod/quiz/classes/event/attempt_graded.php new file mode 100644 index 00000000000..b56c720edd1 --- /dev/null +++ b/mod/quiz/classes/event/attempt_graded.php @@ -0,0 +1,104 @@ +. + +/** + * The mod_quiz attempt submitted event. + * + * @package mod_quiz + * @copyright 2024 Catalyst IT Europe Ltd. + * @author Mark Johnson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace mod_quiz\event; + +/** + * The mod_quiz attempt graded event class. + * + * @package mod_quiz + * @since Moodle 4.5 + * @copyright 2024 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class attempt_graded extends \core\event\base { + /** + * Init method. + */ + protected function init(): void { + $this->data['objecttable'] = 'quiz_attempts'; + $this->data['crud'] = 'u'; + $this->data['edulevel'] = self::LEVEL_PARTICIPATING; + } + + /** + * Returns description of what happened. + * + * @return string + */ + public function get_description(): string { + return "The attempt with id {$this->objectid} for for quiz with course module id {$this->contextinstanceid} by user with " . + "id {$this->relateduserid} had automatic grading performed."; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name(): string { + return get_string('eventquizattemptgraded', 'mod_quiz'); + } + + /** + * Returns relevant URL. + * + * @return \moodle_url + */ + public function get_url(): \moodle_url { + return new \moodle_url('/mod/quiz/review.php', ['attempt' => $this->objectid]); + } + + /** + * Custom validation. + * + * @throws \coding_exception + * @return void + */ + protected function validate_data(): void { + parent::validate_data(); + + if (!isset($this->relateduserid)) { + throw new \coding_exception('The \'relateduserid\' must be set.'); + } + + if (!array_key_exists('submitterid', $this->other)) { + throw new \coding_exception('The \'submitterid\' value must be set in other.'); + } + } + + #[\Override] + public static function get_objectid_mapping(): array { + return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; + } + + #[\Override] + public static function get_other_mapping(): array { + $othermapped = []; + $othermapped['submitterid'] = ['db' => 'user', 'restore' => 'user']; + $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; + + return $othermapped; + } +} diff --git a/mod/quiz/classes/external.php b/mod/quiz/classes/external.php index 685d409632a..3098b701075 100644 --- a/mod/quiz/classes/external.php +++ b/mod/quiz/classes/external.php @@ -145,7 +145,14 @@ class mod_quiz_external extends external_api { // Fields only for managers. if (has_capability('moodle/course:manageactivities', $context)) { - $additionalfields = ['shuffleanswers', 'timecreated', 'timemodified', 'password', 'subnet']; + $additionalfields = [ + 'shuffleanswers', + 'timecreated', + 'timemodified', + 'password', + 'subnet', + 'precreateattempts', + ]; $viewablefields = array_merge($viewablefields, $additionalfields); } @@ -267,6 +274,8 @@ class mod_quiz_external extends external_api { 'hasfeedback' => new external_value(PARAM_INT, 'Whether the quiz has any non-blank feedback text', VALUE_OPTIONAL), 'hasquestions' => new external_value(PARAM_INT, 'Whether the quiz has questions', VALUE_OPTIONAL), + 'precreateattempts' => new external_value(PARAM_INT, 'Whether attempt pre-creation is enabled', + VALUE_OPTIONAL), ] )) ), @@ -354,7 +363,15 @@ class mod_quiz_external extends external_api { * * @return external_function_parameters * @since Moodle 3.1 + * @deprecated Since Moodle 5.0 MDL-68806. + * @todo Final deprecation in Moodle 6.0 (MDL-80956) */ + #[\core\attribute\deprecated( + 'mod_quiz_external::get_user_quiz_attempts_parameters', + since: '5.0', + reason: 'The old API for fetching attempts doesn\'t return true states for NOT_STARTED and SUBMITTED attempts', + mdl: 'MDL-68806' + )] public static function get_user_attempts_parameters() { return new external_function_parameters ( [ @@ -370,15 +387,27 @@ class mod_quiz_external extends external_api { /** * Return a list of attempts for the given quiz and user. * + * For backwards compatibility, SUBMITTED attempts will be treated as FINISHED with marks hidden, and NOT_STARTED will not + * be returned. To return all real states, call get_user_quiz_attempts instead. + * * @param int $quizid quiz instance id * @param int $userid user id * @param string $status quiz status: all, finished or unfinished * @param bool $includepreviews whether to include previews or not * @return array of warnings and the list of attempts * @since Moodle 3.1 + * @deprecated Since Moodle 5.0 MDL-68806. + * @todo Final deprecation in Moodle 6.0 (MDL-80956) */ + #[\core\attribute\deprecated( + 'mod_quiz_external::get_user_quiz_attempts', + since: '5.0', + reason: 'The old API for fetching attempts doesn\'t return true states for NOT_STARTED and SUBMITTED attempts', + mdl: 'MDL-68806' + )] public static function get_user_attempts($quizid, $userid = 0, $status = 'finished', $includepreviews = false) { global $USER; + \core\deprecation::emit_deprecation_if_present(__METHOD__); $warnings = []; @@ -417,9 +446,20 @@ class mod_quiz_external extends external_api { array_column($attempts, 'uniqueid')); $attemptresponse = []; foreach ($attempts as $attempt) { + if ($attempt->state == quiz_attempt::NOT_STARTED) { + continue; // For backwards compatibility, do not return Not Started attempts. + } $reviewoptions = quiz_get_review_options($quiz, $attempt, $context); - if (!has_capability('mod/quiz:viewreports', $context) && - ($reviewoptions->marks < question_display_options::MARK_AND_MAX || $attempt->state != quiz_attempt::FINISHED)) { + if ( + $attempt->state == quiz_attempt::SUBMITTED || + ( + !has_capability('mod/quiz:viewreports', $context) && + ( + $reviewoptions->marks < question_display_options::MARK_AND_MAX || + $attempt->state != quiz_attempt::FINISHED + ) + ) + ) { // Blank the mark if the teacher does not allow it. $attempt->sumgrades = null; } else if (isset($gradeitemmarks[$attempt->uniqueid])) { @@ -432,6 +472,9 @@ class mod_quiz_external extends external_api { ]; } } + if ($attempt->state == quiz_attempt::SUBMITTED) { + $attempt->state = quiz_attempt::FINISHED; // For backwards-compatibility. + } $attemptresponse[] = $attempt; } $result = []; @@ -489,13 +532,144 @@ class mod_quiz_external extends external_api { * * @return external_single_structure * @since Moodle 3.1 + * @deprecated Since Moodle 5.0 MDL-68806. + * @todo Final deprecation in Moodle 6.0 (MDL-80956) */ + #[\core\attribute\deprecated( + 'mod_quiz_external::get_user_quiz_attempts_returns', + since: '5.0', + reason: 'The old API for fetching attempts doesn\'t return true states for NOT_STARTED and SUBMITTED attempts', + mdl: 'MDL-68806' + )] public static function get_user_attempts_returns() { + $attemptstructure = self::attempt_structure(); + $attemptstructure->keys['state']->desc .= " For backwards compatibility, attempts in 'submitted' state will return " . + "'finished' and attempts in 'notstarted' state will return 'inprogress'. To get attempts with all real states, call " . + "get_user_quiz_attempts() instead."; + return new external_single_structure( + [ + 'attempts' => new external_multiple_structure($attemptstructure), + 'warnings' => new external_warnings(), + ] + ); + } + + /** + * Mark get_user_attempts as deprecated. + * + * @return bool + */ + public static function get_user_attempts_is_deprecated(): bool { + return true; + } + + /** + * Describes the parameters for get_user_quiz_attempts. + * + * @return external_function_parameters + * @since Moodle 4.5 + */ + public static function get_user_quiz_attempts_parameters(): external_function_parameters { + return new external_function_parameters ( + [ + 'quizid' => new external_value(PARAM_INT, 'quiz instance id'), + 'userid' => new external_value(PARAM_INT, 'user id, empty for current user', VALUE_DEFAULT, 0), + 'status' => new external_value(PARAM_ALPHA, 'quiz status: all, finished or unfinished', VALUE_DEFAULT, 'finished'), + 'includepreviews' => new external_value(PARAM_BOOL, 'whether to include previews or not', VALUE_DEFAULT, false), + ], + ); + } + + /** + * Return a list of attempts for the given quiz and user. + * + * @param int $quizid quiz instance id + * @param int $userid user id + * @param string $status quiz status: all, finished or unfinished + * @param bool $includepreviews whether to include previews or not + * @return array of warnings and the list of attempts + * @since Moodle 4.5 + */ + public static function get_user_quiz_attempts( + int $quizid, + int $userid = 0, + string $status = 'finished', + bool $includepreviews = false + ): array { + global $USER; + + $warnings = []; + + $params = [ + 'quizid' => $quizid, + 'userid' => $userid, + 'status' => $status, + 'includepreviews' => $includepreviews, + ]; + $params = self::validate_parameters(self::get_user_quiz_attempts_parameters(), $params); + + [$quiz, $course, $cm, $context] = self::validate_quiz($params['quizid']); + + if (!in_array($params['status'], ['all', 'finished', 'unfinished'])) { + throw new invalid_parameter_exception('Invalid status value'); + } + + // Default value for userid. + if (empty($params['userid'])) { + $params['userid'] = $USER->id; + } + + $user = core_user::get_user($params['userid'], '*', MUST_EXIST); + core_user::require_active_user($user); + + // Extra checks so only users with permissions can view other users attempts. + if ($USER->id != $user->id) { + require_capability('mod/quiz:viewreports', $context); + } + + // Update quiz with override information. + $quiz = quiz_update_effective_access($quiz, $params['userid']); + $attempts = quiz_get_user_attempts($quiz->id, $user->id, $params['status'], $params['includepreviews']); + $quizobj = new quiz_settings($quiz, $cm, $course); + $gradeitemmarks = $quizobj->get_grade_calculator()->compute_grade_item_totals_for_attempts( + array_column($attempts, 'uniqueid')); + $attemptresponse = []; + foreach ($attempts as $attempt) { + $reviewoptions = quiz_get_review_options($quiz, $attempt, $context); + if (!has_capability('mod/quiz:viewreports', $context) && + ($reviewoptions->marks < question_display_options::MARK_AND_MAX || $attempt->state != quiz_attempt::FINISHED)) { + // Blank the mark if the teacher does not allow it. + $attempt->sumgrades = null; + } else if (isset($gradeitemmarks[$attempt->uniqueid])) { + $attempt->gradeitemmarks = []; + foreach ($gradeitemmarks[$attempt->uniqueid] as $gradeitem) { + $attempt->gradeitemmarks[] = [ + 'name' => \core_external\util::format_string($gradeitem->name, $context), + 'grade' => $gradeitem->grade, + 'maxgrade' => $gradeitem->maxgrade, + ]; + } + } + $attemptresponse[] = $attempt; + } + $result = []; + $result['attempts'] = $attemptresponse; + $result['warnings'] = $warnings; + return $result; + } + + /** + * Describes the get_user_attempts return value. + * + * @return external_single_structure + * @since Moodle 4.5 + */ + public static function get_user_quiz_attempts_returns(): external_single_structure { return new external_single_structure( [ 'attempts' => new external_multiple_structure(self::attempt_structure()), 'warnings' => new external_warnings(), - ] + ], ); } @@ -803,7 +977,7 @@ class mod_quiz_external extends external_api { $accessmanager->notify_preflight_check_passed($currentattemptid); } - if ($currentattemptid) { + if ($currentattemptid && $lastattempt->state !== quiz_attempt::NOT_STARTED) { if ($lastattempt->state == quiz_attempt::OVERDUE) { throw new moodle_exception('stateoverdue', 'quiz', $quizobj->view_url()); } else { diff --git a/mod/quiz/classes/local/reports/attempts_report_options.php b/mod/quiz/classes/local/reports/attempts_report_options.php index 8f55cbdcb32..bd9f2ab2e9e 100644 --- a/mod/quiz/classes/local/reports/attempts_report_options.php +++ b/mod/quiz/classes/local/reports/attempts_report_options.php @@ -46,8 +46,10 @@ class attempts_report_options { * @var array form field name => corresponding quiz_attempt:: state constant. */ protected static $statefields = [ + 'statenotstarted' => quiz_attempt::NOT_STARTED, 'stateinprogress' => quiz_attempt::IN_PROGRESS, 'stateoverdue' => quiz_attempt::OVERDUE, + 'statesubmitted' => quiz_attempt::SUBMITTED, 'statefinished' => quiz_attempt::FINISHED, 'stateabandoned' => quiz_attempt::ABANDONED, ]; @@ -65,8 +67,14 @@ class attempts_report_options { * @var array|null of quiz_attempt::IN_PROGRESS, etc. constants. null means * no restriction. */ - public $states = [quiz_attempt::IN_PROGRESS, quiz_attempt::OVERDUE, - quiz_attempt::FINISHED, quiz_attempt::ABANDONED]; + public $states = [ + quiz_attempt::NOT_STARTED, + quiz_attempt::IN_PROGRESS, + quiz_attempt::OVERDUE, + quiz_attempt::SUBMITTED, + quiz_attempt::FINISHED, + quiz_attempt::ABANDONED, + ]; /** * @var bool whether to show all finished attmepts, or just the one that gave diff --git a/mod/quiz/classes/local/reports/attempts_report_options_form.php b/mod/quiz/classes/local/reports/attempts_report_options_form.php index dfc3993fc9a..caeadd853a4 100644 --- a/mod/quiz/classes/local/reports/attempts_report_options_form.php +++ b/mod/quiz/classes/local/reports/attempts_report_options_form.php @@ -66,10 +66,14 @@ abstract class attempts_report_options_form extends \moodleform { ]); $stategroup = [ + $mform->createElement('advcheckbox', 'statenotstarted', '', + get_string('statenotstarted', 'quiz')), $mform->createElement('advcheckbox', 'stateinprogress', '', get_string('stateinprogress', 'quiz')), $mform->createElement('advcheckbox', 'stateoverdue', '', get_string('stateoverdue', 'quiz')), + $mform->createElement('advcheckbox', 'statesubmitted', '', + get_string('statesubmitted', 'quiz')), $mform->createElement('advcheckbox', 'statefinished', '', get_string('statefinished', 'quiz')), $mform->createElement('advcheckbox', 'stateabandoned', '', @@ -77,12 +81,17 @@ abstract class attempts_report_options_form extends \moodleform { ]; $mform->addGroup($stategroup, 'stateoptions', get_string('reportattemptsthatare', 'quiz'), [' '], false); + $mform->addHelpButton('stateoptions', 'stateoptions', 'quiz'); + $mform->setDefault('statenotstarted', 1); $mform->setDefault('stateinprogress', 1); $mform->setDefault('stateoverdue', 1); + $mform->setDefault('statesubmitted', 1); $mform->setDefault('statefinished', 1); $mform->setDefault('stateabandoned', 1); + $mform->disabledIf('statenotstarted', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); $mform->disabledIf('stateinprogress', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); $mform->disabledIf('stateoverdue', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); + $mform->disabledIf('statesubmitted', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); $mform->disabledIf('statefinished', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); $mform->disabledIf('stateabandoned', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); @@ -126,8 +135,17 @@ abstract class attempts_report_options_form extends \moodleform { public function validation($data, $files) { $errors = parent::validation($data, $files); - if ($data['attempts'] != attempts_report::ENROLLED_WITHOUT && !( - $data['stateinprogress'] || $data['stateoverdue'] || $data['statefinished'] || $data['stateabandoned'])) { + if ( + $data['attempts'] != attempts_report::ENROLLED_WITHOUT && + !( + $data['stateinprogress'] + || $data['stateoverdue'] + || $data['statefinished'] + || $data['stateabandoned'] + || $data['statenotstarted'] + || $data['statesubmitted'] + ) + ) { $errors['stateoptions'] = get_string('reportmustselectstate', 'quiz'); } diff --git a/mod/quiz/classes/local/reports/attempts_report_table.php b/mod/quiz/classes/local/reports/attempts_report_table.php index eb15fff95b4..18985b9217a 100644 --- a/mod/quiz/classes/local/reports/attempts_report_table.php +++ b/mod/quiz/classes/local/reports/attempts_report_table.php @@ -229,7 +229,7 @@ abstract class attempts_report_table extends \table_sql { * @return string HTML content to go inside the td. */ public function col_timestart($attempt) { - if ($attempt->attempt) { + if ($attempt->attempt && $attempt->timestart) { return userdate($attempt->timestart, $this->strtimeformat); } else { return '-'; diff --git a/mod/quiz/classes/quiz_attempt.php b/mod/quiz/classes/quiz_attempt.php index 2ec840f72b6..978866f4d9f 100644 --- a/mod/quiz/classes/quiz_attempt.php +++ b/mod/quiz/classes/quiz_attempt.php @@ -53,10 +53,14 @@ use stdClass; */ class quiz_attempt { + /** @var string to identify the "not started" state, when an attempt has been pre-generated. */ + const NOT_STARTED = 'notstarted'; /** @var string to identify the in progress state. */ const IN_PROGRESS = 'inprogress'; /** @var string to identify the overdue state. */ const OVERDUE = 'overdue'; + /** @var string to identify the submitted state, when an attempt is awaiting grading. */ + const SUBMITTED = 'submitted'; /** @var string to identify the finished state. */ const FINISHED = 'finished'; /** @var string to identify the abandoned state. */ @@ -1648,7 +1652,8 @@ class quiz_attempt { // Transition to the appropriate state. switch ($this->quizobj->get_quiz()->overduehandling) { case 'autosubmit': - $this->process_finish($timestamp, false, $studentisonline ? $timestamp : $timeclose, $studentisonline); + $this->process_submit($timestamp, false, $studentisonline ? $timestamp : $timeclose, $studentisonline); + $this->process_grade_submission($studentisonline ? $timestamp : $timeclose); return; case 'graceperiod': @@ -1805,8 +1810,36 @@ class quiz_attempt { * @param ?int $timefinish if set, use this as the finish time for the attempt. * (otherwise use $timestamp as the finish time as well). * @param bool $studentisonline is the student currently interacting with Moodle? + * @deprecated since Moodle 5.0 MDL-68806 use process_submit() and process_grade_submission() instead + * @todo Final deprecation in Moodle 6.0 MDL-80956 */ public function process_finish($timestamp, $processsubmitted, $timefinish = null, $studentisonline = false) { + debugging('quiz_attempt::process_finish is deprecated. Please use quiz_attempt::process_submit to store ' . + 'answers and mark an attempt submitted, and quiz_attempt::process_grade_submission to do automatic grading.'); + $this->process_submit($timestamp, $processsubmitted, $timefinish, $studentisonline); + $this->process_grade_submission($timefinish ?? $timestamp); + } + + /** + * Submit the attempt. + * + * The separate $timefinish argument should be used when the quiz attempt + * is being processed asynchronously (for example when cron is submitting + * attempts where the time has expired). + * + * @param int $timestamp the time to record as last modified time. + * @param bool $processsubmitted if true, and question responses in the current + * POST request are stored to be graded, before the attempt is finished. + * @param ?int $timefinish if set, use this as the finish time for the attempt. + * (otherwise use $timestamp as the finish time as well). + * @param bool $studentisonline is the student currently interacting with Moodle? + */ + public function process_submit( + int $timestamp, + bool $processsubmitted, + ?int $timefinish = null, + bool $studentisonline = false + ): void { global $DB; $transaction = $DB->start_delegated_transaction(); @@ -1814,7 +1847,6 @@ class quiz_attempt { if ($processsubmitted) { $this->quba->process_all_actions($timestamp); } - $this->quba->finish_all_questions($timestamp); question_engine::save_questions_usage_by_activity($this->quba); @@ -1822,15 +1854,44 @@ class quiz_attempt { $this->attempt->timemodified = $timestamp; $this->attempt->timefinish = $timefinish ?? $timestamp; + $this->attempt->state = self::SUBMITTED; + $this->attempt->timecheckstate = null; + + $DB->update_record('quiz_attempts', $this->attempt); + + if (!$this->is_preview()) { + // Trigger event. + $this->fire_state_transition_event('\mod_quiz\event\attempt_submitted', $timestamp, $studentisonline); + \core\hook\manager::get_instance()->dispatch(new attempt_state_changed($originalattempt, $this->attempt)); + } + + $transaction->allow_commit(); + } + + /** + * Perform automatic grading for a submitted attempt. + * + * @param int $timestamp the time to record as last modified time. + */ + public function process_grade_submission(int $timestamp): void { + global $DB; + + $transaction = $DB->start_delegated_transaction(); + $this->quba->finish_all_questions($timestamp); + question_engine::save_questions_usage_by_activity($this->quba); + + $originalattempt = clone $this->attempt; + + $this->attempt->timemodified = $timestamp; + $this->attempt->sumgrades = $this->quba->get_total_mark(); $this->attempt->state = self::FINISHED; - $this->attempt->timecheckstate = null; - $this->attempt->gradednotificationsenttime = null; - if (!$this->requires_manual_grading() || - !has_capability('mod/quiz:emailnotifyattemptgraded', $this->get_quizobj()->get_context(), - $this->get_userid())) { - $this->attempt->gradednotificationsenttime = $this->attempt->timefinish; + if ( + !$this->requires_manual_grading() || + !has_capability('mod/quiz:emailnotifyattemptgraded', $this->get_quizobj()->get_context(), $this->get_userid()) + ) { + $this->attempt->gradednotificationsenttime = $timestamp; } $DB->update_record('quiz_attempts', $this->attempt); @@ -1839,7 +1900,7 @@ class quiz_attempt { $this->recompute_final_grade(); // Trigger event. - $this->fire_state_transition_event('\mod_quiz\event\attempt_submitted', $timestamp, $studentisonline); + $this->fire_state_transition_event('\mod_quiz\event\attempt_graded', $timestamp, false); di::get(hook\manager::class)->dispatch(new attempt_state_changed($originalattempt, $this->attempt)); // Tell any access rules that care that the attempt is over. @@ -1947,7 +2008,8 @@ class quiz_attempt { di::get(hook\manager::class)->dispatch(new attempt_state_changed($originalattempt, $this->attempt)); $timeclose = $this->get_access_manager($timestamp)->get_end_time($this->attempt); if ($timeclose && $timestamp > $timeclose) { - $this->process_finish($timestamp, false, $timeclose); + $this->process_submit($timestamp, false, $timeclose); + $this->process_grade_submission($timeclose); } $transaction->allow_commit(); @@ -2158,7 +2220,8 @@ class quiz_attempt { // late to be processed, record the close time, to reduce confusion. $finishtime = $timeclose; } - $this->process_finish($timenow, !$toolate, $finishtime, true); + $this->process_submit($timenow, !$toolate, $finishtime, true); + $this->process_grade_submission($finishtime); } } catch (question_out_of_sequence_exception $e) { diff --git a/mod/quiz/classes/task/precreate_attempts.php b/mod/quiz/classes/task/precreate_attempts.php new file mode 100644 index 00000000000..b2340c5e06a --- /dev/null +++ b/mod/quiz/classes/task/precreate_attempts.php @@ -0,0 +1,155 @@ +. + +namespace mod_quiz\task; + +use core\task\scheduled_task; +use mod_quiz\quiz_settings; +use question_engine; + +/** + * Pre-create attempts for quizzes that have passed their threshold. + * + * @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 precreate_attempts extends scheduled_task { + /** + * Create new instance of the task. + * + * @param int $maxruntime The number of seconds to allow the task to start processing new quizzes. + */ + public function __construct( + /** @var int $maxruntime The number of seconds to allow the task to start processing new quizzes. */ + protected int $maxruntime = 600, + ) { + } + + #[\Override] + public function get_name(): string { + return get_string('precreatetask', 'mod_quiz'); + } + + /** + * Pre-create quiz attempts for configured quizzes. + * + * Find all quizzes with timeopen where the current time is later + * than timeopen-precreateperiod, the quiz has questions, but no attempts. + * + * If the precreateperiod setting is unlocked, also filter by quizzes with precreateattempts enabled. + * + * Find all the users enrolled on the course who can attempt the quiz and create an attempt + * in the NOT_STARTED state. + * + * This will run for $this->maxruntime seconds, then stop to avoid hogging the cron process. Remaining quizzes will be + * processed on subsequent runs. + * + * @return void + */ + public function execute(): void { + global $DB; + $starttime = time(); + $precreateperiod = (int)get_config('quiz', 'precreateperiod'); + $precreatedefault = (int)get_config('quiz', 'precreateattempts'); + if ($precreateperiod === 0) { + mtrace('Pre-creation of quiz attempts is disabled. Nothing to do.'); + return; + } + $sql = " + SELECT DISTINCT q.id, q.name, q.course, q.timeopen + FROM {quiz} q + JOIN {quiz_slots} qs ON q.id = qs.quizid + LEFT JOIN {quiz_attempts} qa ON q.id = qa.quiz + WHERE qa.id IS NULL + AND q.timeopen > :now + AND q.timeopen < :threshold + AND ( + q.precreateattempts = :precreateattempts + OR (1 = :precreatedefault AND q.precreateattempts IS NULL) + ) + ORDER BY q.timeopen ASC"; + $params = [ + 'now' => $starttime, + 'threshold' => $starttime + $precreateperiod, + 'precreateattempts' => 1, + 'precreatedefault' => $precreatedefault, + ]; + + $quizzes = $DB->get_records_sql($sql, $params); + mtrace('Found ' . count($quizzes) . ' quizzes to create attempts for.'); + $quizcount = 0; + foreach ($quizzes as $quiz) { + $transaction = $DB->start_delegated_transaction(); + try { + $quizstart = microtime(true); + mtrace('Creating attempts for ' . $quiz->name); + $attemptcount = self::precreate_attempts_for_quiz($quiz->id, $quiz->course); + $quizend = microtime(true); + $quizduration = round($quizend - $quizstart, 2); + mtrace('Created ' . $attemptcount . ' attempts for ' . $quiz->name . ' in ' . $quizduration . ' seconds'); + $quizcount++; + $transaction->allow_commit(); + } catch (\Throwable $e) { + mtrace('Failed to create attempts for ' . $quiz->name); + $transaction->rollback($e); + } + + if (microtime(true) - $starttime > $this->maxruntime) { + // Stop to let other tasks run, then do some more next run. + mtrace('Time limit reached.'); + break; + } + } + mtrace('Created attempts for ' . $quizcount . ' quizzes.'); + } + + /** + * Pre-create attempts for a quiz. + * + * @param int $quizid + * @param int $courseid + * @return int The number of attempts created. + */ + public static function precreate_attempts_for_quiz(int $quizid, int $courseid): int { + global $DB; + $coursecontext = \context_course::instance($courseid); + $users = get_enrolled_users($coursecontext, 'mod/quiz:attempt'); + $attemptcount = 0; + $timenow = time(); + foreach ($users as $user) { + if ($DB->record_exists('quiz_attempts', ['userid' => $user->id, 'quiz' => $quizid])) { + // Last-minute safety check in case the quiz opened and the user started an attempt since the task started. + continue; + } + $quizobj = quiz_settings::create($quizid, $user->id); + $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); + $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); + $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $user->id); + quiz_start_new_attempt( + $quizobj, + $quba, + $attempt, + 1, + $timenow, + ); + quiz_attempt_save_not_started($quba, $attempt); + $attemptcount++; + } + return $attemptcount; + } +} diff --git a/mod/quiz/db/install.xml b/mod/quiz/db/install.xml index 8a7d8ee9f30..3b6aa39662b 100644 --- a/mod/quiz/db/install.xml +++ b/mod/quiz/db/install.xml @@ -1,5 +1,5 @@ - @@ -48,6 +48,7 @@ + diff --git a/mod/quiz/db/services.php b/mod/quiz/db/services.php index 568570b9e0c..00ae4d4b851 100644 --- a/mod/quiz/db/services.php +++ b/mod/quiz/db/services.php @@ -50,12 +50,22 @@ $functions = [ 'mod_quiz_get_user_attempts' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_user_attempts', - 'description' => 'Return a list of attempts for the given quiz and user.', + 'description' => 'Return a list of attempts for the given quiz and user. ' . + '(Deprecated in favour of mod_quiz_get_user_quiz_attempts).', 'type' => 'read', 'capabilities' => 'mod/quiz:view', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], + 'mod_quiz_get_user_quiz_attempts' => [ + 'classname' => 'mod_quiz_external', + 'methodname' => 'get_user_quiz_attempts', + 'description' => 'Return a list of attempts for the given quiz and user.', + 'type' => 'read', + 'capabilities' => 'mod/quiz:view', + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], + ], + 'mod_quiz_get_user_best_grade' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_user_best_grade', diff --git a/mod/quiz/db/tasks.php b/mod/quiz/db/tasks.php index 242562ae21d..c680daa8960 100644 --- a/mod/quiz/db/tasks.php +++ b/mod/quiz/db/tasks.php @@ -53,4 +53,13 @@ $tasks = [ 'month' => '*', 'dayofweek' => '*', ], + [ + 'classname' => 'mod_quiz\task\precreate_attempts', + 'blocking' => 0, + 'minute' => 'R', + 'hour' => '*', + 'day' => '*', + 'dayofweek' => '*', + 'month' => '*', + ], ]; diff --git a/mod/quiz/db/upgrade.php b/mod/quiz/db/upgrade.php index 7e367036194..00984992c08 100644 --- a/mod/quiz/db/upgrade.php +++ b/mod/quiz/db/upgrade.php @@ -120,6 +120,18 @@ function xmldb_quiz_upgrade($oldversion) { // Automatically generated Moodle v4.5.0 release upgrade line. // Put any upgrade step following this. + if ($oldversion < 2025011300) { + // Define field precreateattempts to be added to quiz. + $table = new xmldb_table('quiz'); + $field = new xmldb_field('precreateattempts', XMLDB_TYPE_INTEGER, '1', null, null, null, null, 'allowofflineattempts'); + + // Conditionally launch add field precreateattempts. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + upgrade_mod_savepoint(true, 2025011300, 'quiz'); + } return true; } diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index f0b4a5c9796..0d4ec099135 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -137,7 +137,7 @@ $string['cachedef_overrides'] = 'User and group override information'; $string['calculated'] = 'Calculated'; $string['calculatedquestion'] = 'Calculated question not supported at line {$a}. The question will be ignored'; $string['cannotcreatepath'] = 'Path cannot be created ({$a})'; -$string['cannoteditafterattempts'] = 'You cannot add or remove questions because this quiz has been attempted. ({$a})'; +$string['cannoteditafterattempts'] = 'You cannot add or remove questions because this quiz has attempts. ({$a})'; $string['cannotfindprevattempt'] = 'Cannot find previous attempt to build on.'; $string['cannotfindquestionregard'] = 'Failed to get questions for regrading!'; $string['cannotinsert'] = 'Cannot insert question'; @@ -202,6 +202,7 @@ $string['configdelay2'] = 'If you set a time delay here, then a student has to w $string['configeachattemptbuildsonthelast'] = 'If multiple attempts are allowed then each new attempt contains the results of the previous attempt.'; $string['configgrademethod'] = 'When multiple attempts are allowed, which method should be used to calculate the student\'s final grade for the quiz.'; $string['configintro'] = 'The values you set here define the default values that are used in the settings form when you create a new quiz. You can also configure which quiz settings are considered advanced.'; +$string['configintroglobal'] = 'These settings control the system-wide behaviour of the Quiz activity.'; $string['configmaximumgrade'] = 'The default grade that the quiz grade is scaled to be out of.'; $string['confignewpageevery'] = 'When adding questions to the quiz page breaks will automatically be inserted according to the setting you choose here.'; $string['confignavmethod'] = 'In Free navigation, questions may be answered in any order using navigation. In Sequential, questions must be answered in strict sequence.'; @@ -264,6 +265,7 @@ $string['decimalpoints'] = 'Decimal places'; $string['default'] = 'Default'; $string['defaultgrade'] = 'Default question grade'; $string['defaultinfo'] = 'The default category for questions.'; +$string['defaultsettings'] = 'Default quiz settings'; $string['delaylater'] = 'Enforced delay between later attempts'; $string['delaylater_help'] = 'If enabled, a student must wait for the specified time to elapse before attempting the quiz a third time and any subsequent times.'; $string['delay1'] = 'Time delay between first and second attempt'; @@ -370,6 +372,7 @@ $string['eventpagebreakcreated'] = 'Page break created'; $string['eventpagebreakdeleted'] = 'Page break deleted'; $string['eventquestionmanuallygraded'] = 'Question manually graded'; $string['eventquizattemptabandoned'] = 'Quiz attempt abandoned'; +$string['eventquizattemptgraded'] = 'Quiz attempt graded'; $string['eventquizattemptregraded'] = 'Quiz attempt regraded'; $string['eventquizattemptreopened'] = 'Quiz attempt reopened'; $string['eventquizattemptstarted'] = 'Quiz attempt started'; @@ -720,6 +723,35 @@ $string['pluginname'] = 'Quiz'; $string['popup'] = 'Show quiz in a \'secure\' window'; $string['popupblockerwarning'] = 'This section of the test is in secure mode, this means that you need to take the quiz in a secure window. Please turn off your popup blocker. Thank you.'; $string['popupnotice'] = 'Students will see this quiz in a secure window'; +$string['precreateattempts'] = 'Pre-create attempts'; +$string['precreateattempts_desc'] = 'If enabled, quizzes will have attempts created in advance of the quiz opening time, +based on "Pre-create period" which must also be set. + +This prevents spikes in server load when students start complex or numerous quizzes all at once. + +Quizzes without an opening time are unaffected. + +If this setting is locked, it is forced on or off for all quizzes. +If unlocked, and "Pre-create period" is set, quizzes can opt in to this feature. If "Pre-create" period is not set, this setting +will not be shown on the quiz settings form.'; +$string['precreateattempts_help'] = 'Pre-creating attempts helps optimise site performance. Quiz questions cannot be modified once a quiz has attempts.'; +$string['precreateoff'] = 'Do not pre-create attempts'; +$string['precreateperiod'] = 'Pre-create period'; +$string['precreateperiod_desc'] = 'The period ahead of the quiz open time that attempts will be pre-created. + +Attempts are generated by a scheduled task (mod_quiz\task\precreate_attempts) running each hour by default. + +Since quizzes cannot be edited once they have attempts, it is best to keep this period as short as possible. +If you tend to have one quiz or set of quizzes starting at once time, a short period like 1 hour is sensible. + +If you have several starting at different times through the day, and wish all pre-creation to happen when the system is quiet such +as overnight, you may which to set this to a longer period such as 12 hours and adjust the scheduled task to run during quieter +hours. + +This setting is controlled at site-level, it cannot be overridden by individual quizzes.'; +$string['precreatetask'] = 'Pre-create attempts'; +$string['precreateusedefault'] = 'Use site-wide default ({$a})'; +$string['precreateyes'] = 'Yes, {$a} hours before quiz open time'; $string['preprocesserror'] = 'Error occurred during pre-processing!'; $string['preview'] = 'Preview'; $string['previewquestion'] = 'Preview question'; @@ -921,10 +953,10 @@ $string['reports'] = 'Reports'; $string['reportshowonly'] = 'Show only attempts'; $string['reportshowonlyfinished'] = 'Show at most one finished attempt per user ({$a})'; $string['reportsimplestat'] = 'Simple statistics'; -$string['reportusersall'] = 'all users who have attempted the quiz'; -$string['reportuserswith'] = 'enrolled users who have attempted the quiz'; -$string['reportuserswithorwithout'] = 'enrolled users who have, or have not, attempted the quiz'; -$string['reportuserswithout'] = 'enrolled users who have not attempted the quiz'; +$string['reportusersall'] = 'all users who have a quiz attempt'; +$string['reportuserswith'] = 'enrolled users who have a quiz attempt'; +$string['reportuserswithorwithout'] = 'enrolled users who have, or do not have, a quiz attempt'; +$string['reportuserswithout'] = 'enrolled users who do not have a quiz attempt'; $string['reportwhattoinclude'] = 'What to include in the report'; $string['requirepassword'] = 'Require password'; $string['requirepassword_help'] = 'If a password is specified, a student must enter it in order to attempt the quiz.'; @@ -1051,8 +1083,18 @@ $string['statefinished'] = 'Finished'; $string['statefinisheddetails'] = 'Submitted {$a}'; $string['stateinprogress'] = 'In progress'; $string['statenotloaded'] = 'The state for question {$a} has not been loaded from the database'; +$string['statenotstarted'] = 'Not started'; +$string['stateoptions'] = 'Attempt state options'; +$string['stateoptions_help'] = ' +* Not started: The attempt was automatically created before the quiz opened. The student has not started the attempt yet. +* In progress: The student has started the attempt. They still have time to submit it. +* Overdue: The attempt has been open for longer than the allowed time limit. The student can still submit it within the grade period. +* Submitted: The student has completed the attempt and submitted their responses. It is queued for automatic marking. +* Finished: The attempt has been submitted and any automatic marking is complete. +* Never submitted: The student started the attempt, but did not submit it within the time limit or grace period.'; $string['stateoverdue'] = 'Overdue'; $string['stateoverduedetails'] = 'Must be submitted by {$a}'; +$string['statesubmitted'] = 'Submitted'; $string['status'] = 'Status'; $string['stoponerror'] = 'Stop on error'; $string['submission_confirmation'] = 'Submit all your answers and finish?'; diff --git a/mod/quiz/lib.php b/mod/quiz/lib.php index f3950652477..9f76382a948 100644 --- a/mod/quiz/lib.php +++ b/mod/quiz/lib.php @@ -524,9 +524,10 @@ function quiz_get_user_attempts($quizids, $userid, $status = 'finished', $includ break; case 'finished': - $statuscondition = ' AND state IN (:state1, :state2)'; + $statuscondition = ' AND state IN (:state1, :state2, :state3)'; $params['state1'] = quiz_attempt::FINISHED; $params['state2'] = quiz_attempt::ABANDONED; + $params['state3'] = quiz_attempt::SUBMITTED; break; case 'unfinished': diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index 344a2263964..d6464655742 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -128,29 +128,19 @@ function quiz_create_attempt(quiz_settings $quizobj, $attemptnumber, $lastattemp } $attempt->attempt = $attemptnumber; - $attempt->timestart = $timenow; $attempt->timefinish = 0; $attempt->timemodified = $timenow; $attempt->timemodifiedoffline = 0; - $attempt->state = quiz_attempt::IN_PROGRESS; $attempt->currentpage = 0; $attempt->sumgrades = null; $attempt->gradednotificationsenttime = null; + $attempt->timecheckstate = null; // If this is a preview, mark it as such. if ($ispreview) { $attempt->preview = 1; } - $timeclose = $quizobj->get_access_manager($timenow)->get_end_time($attempt); - if ($timeclose === false || $ispreview) { - $attempt->timecheckstate = null; - } else { - $attempt->timecheckstate = $timeclose; - } - - di::get(hook\manager::class)->dispatch(new attempt_state_changed(null, $attempt)); - return $attempt; } /** @@ -266,7 +256,7 @@ function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $time $forcedvariantsbyseed, $variantstrategy); } - $quba->start_all_questions($variantstrategy, $timenow, $attempt->userid); + $quba->start_all_questions($variantstrategy, question_attempt_step::TIMECREATED_ON_FIRST_RENDER, $attempt->userid); // Work out the attempt layout. $sections = $quizobj->get_sections(); @@ -354,19 +344,50 @@ function quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt) { } /** - * The save started question usage and quiz attempt in db and log the started attempt. + * Create or update the quiz attempt record, and the question usage. + * + * If the attempt already exists in the database with the NOT_STARTED state, it will be transitioned + * to IN_PROGRESS and the timestart updated. If it does not already exist, a new record will be created + * already in the IN_PROGRESS state. * * @param quiz_settings $quizobj * @param question_usage_by_activity $quba * @param stdClass $attempt + * @param ?int $timenow The time to use for the attempt's timestart property. Defaults to time(). * @return stdClass attempt object with uniqueid and id set. */ -function quiz_attempt_save_started($quizobj, $quba, $attempt) { +function quiz_attempt_save_started( + quiz_settings $quizobj, + question_usage_by_activity $quba, + \stdClass $attempt, + ?int $timenow = null, +): stdClass { global $DB; - // Save the attempt in the database. - question_engine::save_questions_usage_by_activity($quba); - $attempt->uniqueid = $quba->get_id(); - $attempt->id = $DB->insert_record('quiz_attempts', $attempt); + + $attempt->timestart = $timenow ?? time(); + + $timeclose = $quizobj->get_access_manager($attempt->timestart)->get_end_time($attempt); + if ($timeclose === false || $attempt->preview) { + $attempt->timecheckstate = null; + } else { + $attempt->timecheckstate = $timeclose; + } + + $originalattempt = null; + if (isset($attempt->id) && $attempt->state === quiz_attempt::NOT_STARTED) { + $originalattempt = clone $attempt; + // In case questions have been edited since attempts were pre-created, update questions now. + quiz_attempt::create($attempt->id)->update_questions_to_new_version_if_changed(); + // Update the attempt's state. + $attempt->state = quiz_attempt::IN_PROGRESS; + $DB->update_record('quiz_attempts', $attempt); + } else { + // Save the attempt in the database. + question_engine::save_questions_usage_by_activity($quba); + $attempt->uniqueid = $quba->get_id(); + $attempt->state = quiz_attempt::IN_PROGRESS; + $attempt->id = $DB->insert_record('quiz_attempts', $attempt); + } // Params used by the events below. $params = [ @@ -391,6 +412,29 @@ function quiz_attempt_save_started($quizobj, $quba, $attempt) { $event->add_record_snapshot('quiz_attempts', $attempt); $event->trigger(); + di::get(hook\manager::class)->dispatch(new attempt_state_changed($originalattempt, $attempt)); + + return $attempt; +} + +/** + * Create the quiz attempt record, and the question usage. + * + * This saves an attempt in the NOT_STARTED state, and is designed for use when pre-creating attempts + * ahead of the quiz start time to spread out the processing load. + * + * @param question_usage_by_activity $quba + * @param stdClass $attempt + * @return stdClass attempt object with uniqueid and id set. + */ +function quiz_attempt_save_not_started(question_usage_by_activity $quba, stdClass $attempt): stdClass { + global $DB; + // Save the attempt in the database. + question_engine::save_questions_usage_by_activity($quba); + $attempt->uniqueid = $quba->get_id(); + $attempt->state = quiz_attempt::NOT_STARTED; + $attempt->id = $DB->insert_record('quiz_attempts', $attempt); + di::get(hook\manager::class)->dispatch(new attempt_state_changed(null, $attempt)); return $attempt; } @@ -971,10 +1015,14 @@ function quiz_questions_per_page_options() { */ function quiz_attempt_state_name($state) { switch ($state) { + case quiz_attempt::NOT_STARTED: + return get_string('statenotstarted', 'quiz'); case quiz_attempt::IN_PROGRESS: return get_string('stateinprogress', 'quiz'); case quiz_attempt::OVERDUE: return get_string('stateoverdue', 'quiz'); + case quiz_attempt::SUBMITTED: + return get_string('statesubmitted', 'quiz'); case quiz_attempt::FINISHED: return get_string('statefinished', 'quiz'); case quiz_attempt::ABANDONED: @@ -1969,10 +2017,13 @@ function quiz_validate_new_attempt(quiz_settings $quizobj, access_manager $acces $lastattempt = end($attempts); $attemptnumber = null; - // If an in-progress attempt exists, check password then redirect to it. - if ($lastattempt && ($lastattempt->state == quiz_attempt::IN_PROGRESS || - $lastattempt->state == quiz_attempt::OVERDUE)) { + if ( + $lastattempt + && in_array($lastattempt->state, [quiz_attempt::NOT_STARTED, quiz_attempt::IN_PROGRESS, quiz_attempt::OVERDUE]) + ) { + // If an in-progress or not-started attempt exists, check password then redirect to it. $currentattemptid = $lastattempt->id; + $messages = $accessmanager->prevent_access(); // If the attempt is now overdue, deal with that. @@ -2047,15 +2098,33 @@ function quiz_prepare_and_start_new_attempt(quiz_settings $quizobj, $attemptnumb $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); - // Create the new attempt and initialize the question sessions - $timenow = time(); // Update time now, in case the server is running really slowly. - $attempt = quiz_create_attempt($quizobj, $attemptnumber, $lastattempt, $timenow, $ispreviewuser, $userid); + $attempt = $DB->get_record( + 'quiz_attempts', + [ + 'quiz' => $quizobj->get_quizid(), + 'userid' => $userid, + 'preview' => 0, + 'state' => quiz_attempt::NOT_STARTED, + ], + ); + if (!$attempt) { + // Create the new attempt and initialize the question sessions. + $timenow = time(); // Update time now, in case the server is running really slowly. + $attempt = quiz_create_attempt($quizobj, $attemptnumber, $lastattempt, $timenow, $ispreviewuser, $userid); - if (!($quizobj->get_quiz()->attemptonlast && $lastattempt)) { - $attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow, - $forcedrandomquestions, $forcedvariants); - } else { - $attempt = quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt); + if (!($quizobj->get_quiz()->attemptonlast && $lastattempt)) { + $attempt = quiz_start_new_attempt( + $quizobj, + $quba, + $attempt, + $attemptnumber, + $timenow, + $forcedrandomquestions, + $forcedvariants, + ); + } else { + $attempt = quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt); + } } $transaction = $DB->start_delegated_transaction(); diff --git a/mod/quiz/mod_form.php b/mod/quiz/mod_form.php index b9f27329876..1829badd4ba 100644 --- a/mod/quiz/mod_form.php +++ b/mod/quiz/mod_form.php @@ -115,6 +115,25 @@ class mod_quiz_mod_form extends moodleform_mod { $mform->addHelpButton('graceperiod', 'graceperiod', 'quiz'); $mform->hideIf('graceperiod', 'overduehandling', 'neq', 'graceperiod'); + // Pre-create attempts. + // This is only shown if "Pre-create period" as been set at site level, and the quiz open time is enabled. + $precreateperiod = get_config('quiz', 'precreateperiod'); + if (!empty($precreateperiod)) { + $yesoption = get_string('precreateyes', 'quiz', $precreateperiod / HOURSECS); + $precreateoptions = [ + 1 => $yesoption, + 0 => get_string('no'), + ]; + $mform->addElement( + 'select', + 'precreateattempts', + get_string('precreateattempts', 'quiz'), + $precreateoptions + ); + $mform->hideIf('precreateattempts', 'timeopen[enabled]'); + $mform->addHelpButton('precreateattempts', 'precreateattempts', 'quiz'); + } + // ------------------------------------------------------------------------------- // Grade settings. $this->standard_grading_coursemodule_elements(); diff --git a/mod/quiz/report/overview/tests/behat/attempt_states.feature b/mod/quiz/report/overview/tests/behat/attempt_states.feature new file mode 100644 index 00000000000..7f658a26ab0 --- /dev/null +++ b/mod/quiz/report/overview/tests/behat/attempt_states.feature @@ -0,0 +1,52 @@ +@mod @mod_quiz @quiz @quiz_overview +Feature: View attempt states + In order to see how students are progressing through the quiz + As a teacher + I need to see different attempt states on the overview report + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + | student2 | Student | 2 | student2@example.com | + | student3 | Student | 3 | student3@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + | student2 | C1 | student | + | student3 | C1 | student | + And the following "question categories" exist: + | contextlevel | reference | name | + | Course | C1 | Test questions | + And the following "activities" exist: + | activity | name | intro | course | idnumber | + | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | + And the following "questions" exist: + | questioncategory | qtype | name | questiontext | + | Test questions | description | Intro | Welcome to this quiz | + | Test questions | truefalse | TF1 | First question | + | Test questions | truefalse | TF2 | Second question | + And quiz "Quiz 1" contains the following questions: + | question | page | maxmark | + | Intro | 1 | | + | TF1 | 1 | | + | TF2 | 1 | 3.0 | + + Scenario: View attempts in different states + Given quiz "Quiz 1" has pre-created attempts + And user "student1" has started an attempt at quiz "Quiz 1" + And user "student2" has attempted "Quiz 1" with responses: + | slot | response | + | 2 | True | + | 3 | False | + When I am on the "Quiz 1" "mod_quiz > Grades report" page logged in as "teacher 1" + Then the following should exist in the "attempts" table: + | Email address | Status | Started | Completed | Grade/100.00 | + | student1@example.com | In progress | ## now ##%d %B %Y %I:%M %p## | - | - | + | student2@example.com | Finished | ## now ##%d %B %Y %I:%M %p## | ## now ##%d %B %Y %I:%M %p## | 25.00 | + | student3@example.com | Not started | - | - | - | diff --git a/mod/quiz/report/overview/tests/behat/basic.feature b/mod/quiz/report/overview/tests/behat/basic.feature index 868eb5d4615..c5d5dcac94a 100644 --- a/mod/quiz/report/overview/tests/behat/basic.feature +++ b/mod/quiz/report/overview/tests/behat/basic.feature @@ -77,13 +77,13 @@ Feature: Basic use of the Grades report And I should see "100.00" in the "S2 Student2" "table_row" # Check changing the form parameters - And I set the field "Attempts from" to "enrolled users who have not attempted the quiz" + And I set the field "Attempts from" to "enrolled users who do not have a quiz attempt" And I press "Show report" # Note: teachers should not appear in the report. # Check student3's grade And I should see "-" in the "S3 Student3" "table_row" - And I set the field "Attempts from" to "enrolled users who have, or have not, attempted the quiz" + And I set the field "Attempts from" to "enrolled users who have, or do not have, a quiz attempt" And I press "Show report" # Check student1's grade And I should see "25.00" in the "S1 Student1" "table_row" @@ -92,7 +92,7 @@ Feature: Basic use of the Grades report # Check student3's grade And I should see "-" in the "S3 Student3" "table_row" - And I set the field "Attempts from" to "all users who have attempted the quiz" + And I set the field "Attempts from" to "all users who have a quiz attempt" And I press "Show report" # Check student1's grade And I should see "25.00" in the "S1 Student1" "table_row" diff --git a/mod/quiz/report/overview/tests/report_test.php b/mod/quiz/report/overview/tests/report_test.php index ee9f10113bb..8a31fbde31b 100644 --- a/mod/quiz/report/overview/tests/report_test.php +++ b/mod/quiz/report/overview/tests/report_test.php @@ -109,7 +109,8 @@ final class report_test extends \advanced_testcase { [$quiz, $student1, 2, 5.0, quiz_attempt::FINISHED], [$quiz, $student1, 3, 8.0, quiz_attempt::FINISHED], [$quiz, $student1, 4, null, quiz_attempt::ABANDONED], - [$quiz, $student1, 5, null, quiz_attempt::IN_PROGRESS], + [$quiz, $student1, 5, null, quiz_attempt::SUBMITTED], + [$quiz, $student1, 6, null, quiz_attempt::IN_PROGRESS], [$quiz, $student2, 1, null, quiz_attempt::ABANDONED], [$quiz, $student2, 2, null, quiz_attempt::ABANDONED], [$quiz, $student2, 3, 7.0, quiz_attempt::FINISHED], @@ -143,11 +144,29 @@ final class report_test extends \advanced_testcase { // Do nothing. break; + case quiz_attempt::SUBMITTED: + // Save answers but do not grade attempt. + $attemptobj->process_submitted_actions( + $timestart + 300, + false, + [ + 1 => ['answer' => 'My essay by ' . $student->firstname, 'answerformat' => FORMAT_PLAIN], + ] + ); + $attemptobj->process_submit($timestart + 600, false); + break; + case quiz_attempt::FINISHED: // Save answer and finish attempt. - $attemptobj->process_submitted_actions($timestart + 300, false, [ - 1 => ['answer' => 'My essay by ' . $student->firstname, 'answerformat' => FORMAT_PLAIN]]); - $attemptobj->process_finish($timestart + 600, false); + $attemptobj->process_submitted_actions( + $timestart + 300, + false, + [ + 1 => ['answer' => 'My essay by ' . $student->firstname, 'answerformat' => FORMAT_PLAIN], + ] + ); + $attemptobj->process_submit($timestart + 600, false); + $attemptobj->process_grade_submission($timestart + 600); // Manually grade it. $quba = $attemptobj->get_question_usage(); @@ -207,7 +226,7 @@ final class report_test extends \advanced_testcase { $this->assertArrayHasKey($student1->id . '#3', $table->rawdata); $this->assertEquals(1, $table->rawdata[$student1->id . '#3']->gradedattempt); $this->assertArrayHasKey($student1->id . '#3', $table->rawdata); - $this->assertEquals(0, $table->rawdata[$student1->id . '#5']->gradedattempt); + $this->assertEquals(0, $table->rawdata[$student1->id . '#6']->gradedattempt); $this->assertArrayHasKey($student2->id . '#3', $table->rawdata); $this->assertEquals(1, $table->rawdata[$student2->id . '#3']->gradedattempt); $this->assertArrayHasKey($student3->id . '#0', $table->rawdata); @@ -361,7 +380,8 @@ final class report_test extends \advanced_testcase { $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null); $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submitted_actions(time(), false, [1 => ['answer' => 'toad']]); - $attemptobj->process_finish(time(), false); + $attemptobj->process_submit(time(), false); + $attemptobj->process_grade_submission(time()); // We should be using 'always latest' version, which is currently v2, so should be right. $this->assertEquals(10, $attemptobj->get_question_usage()->get_total_mark()); diff --git a/mod/quiz/report/responses/tests/behat/basic.feature b/mod/quiz/report/responses/tests/behat/basic.feature index c9524946158..50b822b3430 100644 --- a/mod/quiz/report/responses/tests/behat/basic.feature +++ b/mod/quiz/report/responses/tests/behat/basic.feature @@ -36,7 +36,7 @@ Feature: Basic use of the Responses report When I am on the "Quiz 1" "mod_quiz > Responses report" page logged in as teacher Then I should see "Attempts: 0" And I should see "Nothing to display" - And I set the field "Attempts from" to "enrolled users who have not attempted the quiz" + And I set the field "Attempts from" to "enrolled users who have a quiz attempt" @javascript Scenario: Report works when there are attempts @@ -56,7 +56,7 @@ Feature: Basic use of the Responses report Then I should see "Attempts: 1" And I should see "Student One" And I should not see "Student Two" - And I set the field "Attempts from" to "enrolled users who have, or have not, attempted the quiz" + And I set the field "Attempts from" to "enrolled users who have, or do not have, a quiz attempt" And I set the field "Which tries" to "All tries" And I should see "Response 1a" And I press "Show report" @@ -71,5 +71,5 @@ Feature: Basic use of the Responses report Scenario: Report does not allow strange combinations of options Given I am on the "Quiz 1" "mod_quiz > Responses report" page logged in as teacher And the "Which tries" "select" should be enabled - When I set the field "Attempts from" to "enrolled users who have not attempted the quiz" + When I set the field "Attempts from" to "enrolled users who do not have a quiz attempt" Then the "Which tries" "select" should be disabled diff --git a/mod/quiz/settings.php b/mod/quiz/settings.php index 94f3f080b90..c856c1c2c25 100644 --- a/mod/quiz/settings.php +++ b/mod/quiz/settings.php @@ -57,8 +57,45 @@ if (empty($reportsbyname) && empty($rulesbyname)) { $quizsettings = new admin_settingpage('modsettingquiz', $pagetitle, 'moodle/site:config'); if ($ADMIN->fulltree) { - // Introductory explanation that all the settings are defaults for the add quiz form. - $quizsettings->add(new admin_setting_heading('quizintro', '', get_string('configintro', 'quiz'))); + + $quizsettings->add(new admin_setting_heading('quizintro', '', get_string('configintroglobal', 'quiz'))); + + // Delay to notify graded attempts. + $quizsettings->add(new admin_setting_configduration('quiz/notifyattemptgradeddelay', + get_string('attemptgradeddelay', 'quiz'), get_string('attemptgradeddelay_desc', 'quiz'), 5 * HOURSECS, HOURSECS)); + + // Pre-create attempt period. + $precreateoptions = [get_string('precreateoff', 'quiz')]; + for ($i = 1; $i <= 24; $i++) { + $precreateoptions[$i * HOURSECS] = sprintf(get_string('dateintervalhrfull', 'langconfig'), $i); + } + $setting = new admin_setting_configselect( + 'quiz/precreateperiod', + get_string('precreateperiod', 'quiz'), + get_string('precreateperiod_desc', 'quiz'), + 0, + $precreateoptions, + ); + $quizsettings->add($setting); + + // Minimum grace period used behind the scenes. + $quizsettings->add(new admin_setting_configduration('quiz/graceperiodmin', + get_string('graceperiodmin', 'quiz'), get_string('graceperiodmin_desc', 'quiz'), + 60, 1)); + + // Initial number of feedback items. + $quizsettings->add(new admin_setting_configtext('quiz/initialnumfeedbacks', + get_string('initialnumfeedbacks', 'quiz'), get_string('initialnumfeedbacks_desc', 'quiz'), + 2, PARAM_INT, 5)); + + // Autosave frequency. + $quizsettings->add(new admin_setting_configduration('quiz/autosaveperiod', + get_string('autosaveperiod', 'quiz'), get_string('autosaveperiod_desc', 'quiz'), 60, 1)); + + // Heading explanation that all the settings below are defaults for the add quiz form. + $name = new lang_string('defaultsettings', 'quiz'); + $description = new lang_string('configintro', 'quiz'); + $quizsettings->add(new admin_setting_heading('defaultsettings', $name, $description)); // Time limit. $setting = new admin_setting_configduration('quiz/timelimit', @@ -68,10 +105,6 @@ if ($ADMIN->fulltree) { $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); - // Delay to notify graded attempts. - $quizsettings->add(new admin_setting_configduration('quiz/notifyattemptgradeddelay', - get_string('attemptgradeddelay', 'quiz'), get_string('attemptgradeddelay_desc', 'quiz'), 5 * HOURSECS, HOURSECS)); - // What to do with overdue attempts. $setting = new \mod_quiz\admin\overdue_handling_setting('quiz/overduehandling', get_string('overduehandling', 'quiz'), get_string('overduehandling_desc', 'quiz'), @@ -87,10 +120,16 @@ if ($ADMIN->fulltree) { $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); - // Minimum grace period used behind the scenes. - $quizsettings->add(new admin_setting_configduration('quiz/graceperiodmin', - get_string('graceperiodmin', 'quiz'), get_string('graceperiodmin_desc', 'quiz'), - 60, 1)); + // Enable pre-creation of attempts. + $setting = new admin_setting_configcheckbox( + 'quiz/precreateattempts', + get_string('precreateattempts', 'quiz'), + get_string('precreateattempts_help', 'quiz'), + 0, + ); + $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, true); + $setting->set_locked_flag_options(admin_setting_flag::ENABLED, true); + $quizsettings->add($setting); // Number of attempts. $options = [get_string('unlimited')]; @@ -266,20 +305,12 @@ if ($ADMIN->fulltree) { $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); - $quizsettings->add(new admin_setting_configtext('quiz/initialnumfeedbacks', - get_string('initialnumfeedbacks', 'quiz'), get_string('initialnumfeedbacks_desc', 'quiz'), - 2, PARAM_INT, 5)); - // Allow user to specify if setting outcomes is an advanced setting. if (!empty($CFG->enableoutcomes)) { $quizsettings->add(new admin_setting_configcheckbox('quiz/outcomes_adv', get_string('outcomesadvanced', 'quiz'), get_string('configoutcomesadvanced', 'quiz'), '0')); } - - // Autosave frequency. - $quizsettings->add(new admin_setting_configduration('quiz/autosaveperiod', - get_string('autosaveperiod', 'quiz'), get_string('autosaveperiod_desc', 'quiz'), 60, 1)); } // Now, depending on whether any reports have their own settings page, add diff --git a/mod/quiz/startattempt.php b/mod/quiz/startattempt.php index 2e3f8367656..81f0f809b1c 100644 --- a/mod/quiz/startattempt.php +++ b/mod/quiz/startattempt.php @@ -99,15 +99,14 @@ if ($accessmanager->is_preflight_check_required($currentattemptid)) { // Pre-flight check passed. $accessmanager->notify_preflight_check_passed($currentattemptid); } -if ($currentattemptid) { - if ($lastattempt->state == quiz_attempt::OVERDUE) { - redirect($quizobj->summary_url($lastattempt->id)); - } else { - redirect($quizobj->attempt_url($currentattemptid, $page)); - } + +if (!$currentattemptid || $lastattempt->state == quiz_attempt::NOT_STARTED) { + $attempt = quiz_prepare_and_start_new_attempt($quizobj, $attemptnumber, $lastattempt); +} else { + $attempt = $lastattempt; +} +if ($attempt->state === quiz_attempt::OVERDUE) { + redirect($quizobj->summary_url($attempt->id)); +} else { + redirect($quizobj->attempt_url($attempt->id, $page)); } - -$attempt = quiz_prepare_and_start_new_attempt($quizobj, $attemptnumber, $lastattempt); - -// Redirect to the attempt page. -redirect($quizobj->attempt_url($attempt->id, $page)); diff --git a/mod/quiz/tests/attempt_test.php b/mod/quiz/tests/attempt_test.php index 8a3695b30c4..f6bdc290b24 100644 --- a/mod/quiz/tests/attempt_test.php +++ b/mod/quiz/tests/attempt_test.php @@ -571,4 +571,21 @@ final class attempt_test extends \advanced_testcase { $this->assertEquals(0, $grades[$readinggrade->id]->grade); $this->assertEquals(1, $grades[$readinggrade->id]->maxgrade); } + + /** + * When creating a new quiz attempt, question attempts should be created with the first step's timecreated set to null. + * + * When the question attempt is rendered, it should be set to the current time. + * + * @return void + * @throws \coding_exception + * @covers ::quiz_start_new_attempt + */ + public function test_step_timecreated_unset_when_starting_quiz_attempt(): void { + $attempt = $this->create_quiz_and_attempt_with_layout('1'); + $questionattempt = $attempt->get_question_attempt(1); + $this->assertEquals(\question_attempt_step::TIMECREATED_ON_FIRST_RENDER, $questionattempt->get_step(0)->get_timecreated()); + $questionattempt->render(new \question_display_options(), 1); + $this->assertEqualsWithDelta(time(), $questionattempt->get_step(0)->get_timecreated(), 1); + } } diff --git a/mod/quiz/tests/attempt_walkthrough_test.php b/mod/quiz/tests/attempt_walkthrough_test.php index e2f875e8fdd..126f873ad4d 100644 --- a/mod/quiz/tests/attempt_walkthrough_test.php +++ b/mod/quiz/tests/attempt_walkthrough_test.php @@ -129,21 +129,30 @@ final class attempt_walkthrough_test extends \advanced_testcase { // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); // Re-load quiz attempt data. $attemptobj = quiz_attempt::create($attempt->id); // Check that results are stored as expected. $this->assertEquals(1, $attemptobj->get_attempt_number()); - $this->assertEquals(3, $attemptobj->get_sum_marks()); - $this->assertEquals(true, $attemptobj->is_finished()); + $this->assertEquals(false, $attemptobj->is_finished()); $this->assertEquals($timenow, $attemptobj->get_submitted_date()); $this->assertEquals($user1->id, $attemptobj->get_userid()); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $this->assertEquals(0, $attemptobj->get_number_of_unanswered_questions()); + // Check we don't have grades yet. + $this->assertEmpty(quiz_get_user_grades($quiz, $user1->id)); + $this->assertNull($attemptobj->get_sum_marks()); + + // Now grade the submission. + $attemptobj->process_grade_submission($timenow); + $attemptobj = quiz_attempt::create($attempt->id); // Check quiz grades. + $this->assertEquals(true, $attemptobj->is_finished()); + $this->assertEquals(3, $attemptobj->get_sum_marks()); + $grades = quiz_get_user_grades($quiz, $user1->id); $grade = array_shift($grades); $this->assertEquals(100.0, $grade->rawgrade); @@ -412,20 +421,31 @@ final class attempt_walkthrough_test extends \advanced_testcase { $attemptobj = quiz_attempt::create($attempt->id); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $this->assertEquals(0, $attemptobj->get_number_of_unanswered_questions()); - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); // Re-load quiz attempt data. $attemptobj = quiz_attempt::create($attempt->id); // Check that results are stored as expected. $this->assertEquals(1, $attemptobj->get_attempt_number()); - $this->assertEquals(4, $attemptobj->get_sum_marks()); - $this->assertEquals(true, $attemptobj->is_finished()); + $this->assertEquals(false, $attemptobj->is_finished()); $this->assertEquals($timenow, $attemptobj->get_submitted_date()); $this->assertEquals($user1->id, $attemptobj->get_userid()); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $this->assertEquals(0, $attemptobj->get_number_of_unanswered_questions()); + // Check we don't have grades yet. + $this->assertEmpty(quiz_get_user_grades($quiz, $user1->id)); + $this->assertNull($attemptobj->get_sum_marks()); + + // Now grade the submission. + $attemptobj->process_grade_submission($timenow); + $attemptobj = quiz_attempt::create($attempt->id); + + // Check quiz grades. + $this->assertEquals(true, $attemptobj->is_finished()); + $this->assertEquals(4, $attemptobj->get_sum_marks()); + // Check quiz grades. $grades = quiz_get_user_grades($quiz, $user1->id); $grade = array_shift($grades); @@ -510,20 +530,30 @@ final class attempt_walkthrough_test extends \advanced_testcase { $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $this->assertEquals(0, $attemptobj->get_number_of_unanswered_questions()); - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); // Re-load quiz attempt data. $attemptobj = quiz_attempt::create($attempt->id); // Check that results are stored as expected. $this->assertEquals(1, $attemptobj->get_attempt_number()); - $this->assertEquals(1, $attemptobj->get_sum_marks()); - $this->assertEquals(true, $attemptobj->is_finished()); + $this->assertEquals(false, $attemptobj->is_finished()); $this->assertEquals($timenow, $attemptobj->get_submitted_date()); $this->assertEquals($user1->id, $attemptobj->get_userid()); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $this->assertEquals(0, $attemptobj->get_number_of_unanswered_questions()); + // Check we don't have grades yet. + $this->assertEmpty(quiz_get_user_grades($this->quizwithvariants, $user1->id)); + $this->assertNull($attemptobj->get_sum_marks()); + + // Now grade the submission. + $attemptobj->process_grade_submission($timenow); + $attemptobj = quiz_attempt::create($attempt->id); + + // Check quiz grades. + $this->assertEquals(true, $attemptobj->is_finished()); + $this->assertEquals(1, $attemptobj->get_sum_marks()); // Check quiz grades. $grades = quiz_get_user_grades($this->quizwithvariants, $user1->id); $grade = array_shift($grades); @@ -641,22 +671,106 @@ final class attempt_walkthrough_test extends \advanced_testcase { // Verify this was logged correctly - there are some gradebook events between the two we want to check. $events = $sink->get_events(); - $this->assertGreaterThanOrEqual(2, $events); + $this->assertGreaterThanOrEqual(3, $events); + $attempturl = new moodle_url( + '/mod/quiz/review.php', + ['attempt' => $attemptobj->get_attemptid()], + ); $reopenedevent = array_shift($events); $this->assertInstanceOf('\mod_quiz\event\attempt_reopened', $reopenedevent); $this->assertEquals($attemptobj->get_context(), $reopenedevent->get_context()); - $this->assertEquals( - new moodle_url('/mod/quiz/review.php', ['attempt' => $attemptobj->get_attemptid()]), - $reopenedevent->get_url() - ); - $submittedevent = array_pop($events); + $this->assertEquals($attempturl, $reopenedevent->get_url()); + + $submittedevent = array_shift($events); $this->assertInstanceOf('\mod_quiz\event\attempt_submitted', $submittedevent); $this->assertEquals($attemptobj->get_context(), $submittedevent->get_context()); - $this->assertEquals( - new moodle_url('/mod/quiz/review.php', ['attempt' => $attemptobj->get_attemptid()]), - $submittedevent->get_url() + + $this->assertEquals($attempturl, $submittedevent->get_url()); + + $gradedevent = array_pop($events); + $this->assertInstanceOf('\mod_quiz\event\attempt_graded', $gradedevent); + $this->assertEquals($attemptobj->get_context(), $gradedevent->get_context()); + $this->assertEquals($attempturl, $gradedevent->get_url()); + } + + /** + * Create a quiz with questions, pre-create an attempt, edit a question, then begin the attempt. + */ + public function test_quiz_attempt_walkthrough_update_question_after_precreate(): void { + global $SITE; + + $this->resetAfterTest(true); + + // Make a quiz. + $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); + + $quiz = $quizgenerator->create_instance( + [ + 'course' => $SITE->id, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 3, + ], ); + + // Create a couple of questions. + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + $cat = $questiongenerator->create_question_category(); + $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); + $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); + $matchq = $questiongenerator->create_question('match', null, ['category' => $cat->id]); + $description = $questiongenerator->create_question('description', null, ['category' => $cat->id]); + + // Add them to the quiz. + quiz_add_quiz_question($saq->id, $quiz); + quiz_add_quiz_question($numq->id, $quiz); + quiz_add_quiz_question($matchq->id, $quiz); + quiz_add_quiz_question($description->id, $quiz); + + // Make a user to do the quiz. + $user1 = $this->getDataGenerator()->create_user(); + + $quizobj = quiz_settings::create($quiz->id, $user1->id); + + // Start the attempt. + $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); + $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); + + $timenow = time(); + $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $user1->id); + + quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); + $this->assertEquals('1,2,3,4,0', $attempt->layout); + + quiz_attempt_save_not_started($quba, $attempt); + + $attemptobj = quiz_attempt::create($attempt->id); + + // Update question in quiz. + $newsa = $questiongenerator->update_question($saq, null, + ['name' => 'This is the second version of shortanswer']); + $newnumbq = $questiongenerator->update_question($numq, null, + ['name' => 'This is the second version of numerical']); + $newmatch = $questiongenerator->update_question($matchq, null, + ['name' => 'This is the second version of match']); + $newdescription = $questiongenerator->update_question($description, null, + ['name' => 'This is the second version of description']); + + $this->assertEquals($saq->id, $attemptobj->get_question_attempt(1)->get_question_id()); + $this->assertEquals($numq->id, $attemptobj->get_question_attempt(2)->get_question_id()); + $this->assertEquals($matchq->id, $attemptobj->get_question_attempt(3)->get_question_id()); + $this->assertEquals($description->id, $attemptobj->get_question_attempt(4)->get_question_id()); + + quiz_attempt_save_started($quizobj, $quba, $attempt); + + // Verify that the started attempt contains the new questions. + $attemptobj = quiz_attempt::create($attempt->id); + $this->assertEquals($newsa->id, $attemptobj->get_question_attempt(1)->get_question_id()); + $this->assertEquals($newnumbq->id, $attemptobj->get_question_attempt(2)->get_question_id()); + $this->assertEquals($newmatch->id, $attemptobj->get_question_attempt(3)->get_question_id()); + $this->assertEquals($newdescription->id, $attemptobj->get_question_attempt(4)->get_question_id()); } } diff --git a/mod/quiz/tests/backup/restore_date_test.php b/mod/quiz/tests/backup/restore_date_test.php index cba06ab56af..705b84b4105 100644 --- a/mod/quiz/tests/backup/restore_date_test.php +++ b/mod/quiz/tests/backup/restore_date_test.php @@ -37,8 +37,16 @@ final class restore_date_test extends \restore_date_testcase { global $DB, $USER; // Create quiz data. - $record = ['timeopen' => 100, 'timeclose' => 100, 'timemodified' => 100, 'tiemcreated' => 100, 'questionsperpage' => 0, - 'grade' => 100.0, 'sumgrades' => 2]; + $record = [ + 'timeopen' => 100, + 'timeclose' => 100, + 'timemodified' => 100, + 'timecreated' => 100, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + 'precreateattempts' => 1, + ]; list($course, $quiz) = $this->create_course_and_module('quiz', $record); // Create questions. @@ -55,7 +63,7 @@ final class restore_date_test extends \restore_date_testcase { $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timestamp); - quiz_attempt_save_started($quizobj, $quba, $attempt); + quiz_attempt_save_started($quizobj, $quba, $attempt, $timestamp); // Quiz grade. $grade = new \stdClass(); @@ -86,6 +94,7 @@ final class restore_date_test extends \restore_date_testcase { $this->assertFieldsNotRolledForward($quiz, $newquiz, ['timecreated', 'timemodified']); $props = ['timeclose', 'timeopen']; $this->assertFieldsRolledForward($quiz, $newquiz, $props); + $this->assertEquals($quiz->precreateattempts, $newquiz->precreateattempts); $newattempt = $DB->get_record('quiz_attempts', ['quiz' => $newquiz->id]); $newoverride = $DB->get_record('quiz_overrides', ['quiz' => $newquiz->id]); diff --git a/mod/quiz/tests/behat/attempt_begin.feature b/mod/quiz/tests/behat/attempt_begin.feature index 42275c1c066..1d36b01a24a 100644 --- a/mod/quiz/tests/behat/attempt_begin.feature +++ b/mod/quiz/tests/behat/attempt_begin.feature @@ -124,3 +124,20 @@ Feature: The various checks that may happen when an attept is started And I press the "back" button in the browser Then a new page should have loaded since I started watching And I should see "Continue your attempt" + + @javascript + Scenario: Start a quiz with pre-created attempts + Given the following config values are set as admin: + | precreateperiod | 1 | quiz | + Given the following "activities" exist: + | activity | name | intro | course | idnumber | timeopen | timelimit | quizpassword | attempts | + | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | ## now ## | 3600 | Frog | 1 | + And quiz "Quiz 1" contains the following questions: + | question | page | + | TF1 | 1 | + And quiz "Quiz 1" has pre-created attempts + When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" + And I press "Attempt quiz" + And I set the field "Quiz password" to "Frog" + And I press "Start attempt" + Then I should see "Text of the first question" diff --git a/mod/quiz/tests/behat/behat_mod_quiz.php b/mod/quiz/tests/behat/behat_mod_quiz.php index d98aa8ac08a..c23c318df0f 100644 --- a/mod/quiz/tests/behat/behat_mod_quiz.php +++ b/mod/quiz/tests/behat/behat_mod_quiz.php @@ -1015,7 +1015,8 @@ class behat_mod_quiz extends behat_question_base { $attempts = quiz_get_user_attempts($quizid, $user->id, 'unfinished', true); $attemptobj = quiz_attempt::create(key($attempts)); - $attemptobj->process_finish(time(), true); + $attemptobj->process_submit(time(), true); + $attemptobj->process_grade_submission(time()); $this->set_user(); } @@ -1056,4 +1057,17 @@ class behat_mod_quiz extends behat_question_base { ["//li[contains(@class,'qtype')]//span[@class='slotnumber' and contains(., %locator%)]/.."]) ]; } + + /** + * Generate pre-created attempts for a quiz. + * + * @param string $quizname the name of the quiz to create attempts for. + * @Given quiz :quizname has pre-created attempts + */ + public function quiz_has_precreated_attempts(string $quizname): void { + global $DB; + + $quiz = $DB->get_record('quiz', ['name' => $quizname], 'id, course', MUST_EXIST); + \mod_quiz\task\precreate_attempts::precreate_attempts_for_quiz($quiz->id, $quiz->course); + } } diff --git a/mod/quiz/tests/behat/quiz_activity_completion.feature b/mod/quiz/tests/behat/quiz_activity_completion.feature index 75f7d82a168..393c074dd7c 100644 --- a/mod/quiz/tests/behat/quiz_activity_completion.feature +++ b/mod/quiz/tests/behat/quiz_activity_completion.feature @@ -50,9 +50,10 @@ Feature: View activity completion in the quiz activity And the "Receive a grade" completion condition of "Test quiz name" is displayed as "todo" And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "todo" And the "Receive a pass grade or complete all available attempts" completion condition of "Test quiz name" is displayed as "todo" - And user "student1" has attempted "Test quiz name" with responses: - | slot | response | - | 1 | False | + And I press "Attempt quiz" + And I set the field "False" to "1" + And I press "Finish attempt ..." + And I press "Submit all and finish" And I am on "Course 1" course homepage And I follow "Test quiz name" And the "View" completion condition of "Test quiz name" is displayed as "done" @@ -60,6 +61,10 @@ Feature: View activity completion in the quiz activity And the "Receive a grade" completion condition of "Test quiz name" is displayed as "done" And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "failed" And the "Receive a pass grade or complete all available attempts" completion condition of "Test quiz name" is displayed as "todo" + And I run all adhoc tasks + And I reload the page + And the "Receive a grade" completion condition of "Test quiz name" is displayed as "done" + And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "failed" And I press "Re-attempt quiz" And I set the field "" to "1" And I press "Finish attempt ..." diff --git a/mod/quiz/tests/behat/settings_form_fields_disableif.feature b/mod/quiz/tests/behat/settings_form_fields_disableif.feature index cd4fadaba3d..08185ce0136 100644 --- a/mod/quiz/tests/behat/settings_form_fields_disableif.feature +++ b/mod/quiz/tests/behat/settings_form_fields_disableif.feature @@ -147,3 +147,24 @@ Feature: Settings form fields disabled if not required And I am on the "Quiz 1" "quiz activity editing" page And I expand all fieldsets And I should not see "Repaginate now" + + @javascript + Scenario Outline: Pre-create attempts setting is only shown if precreateperiod is set and timeopen is enabled. + Given the following config values are set as admin: + | precreateattempts | 1 | quiz | + | precreateattempts_locked | 0 | quiz | + | precreateperiod | | quiz | + And I log in as "admin" + When I add a "quiz" activity to course "Course 1" section 1 + And I click on "Timing" "link" + And I set the field "timeopen[enabled]" to "" + And I expand all fieldsets + Then I see "Pre-create attempts" + And "Pre-create attempts" "select" be visible + And I see "Yes, 1 hours before quiz open time" + + Examples: + | period | timeopen | exists | + | 3600 | 1 | should | + | 3600 | 0 | should not | + | 0 | 1 | should not | diff --git a/mod/quiz/tests/classes/attempt_walkthrough_testcase.php b/mod/quiz/tests/classes/attempt_walkthrough_testcase.php index cfe4575ae72..9b9f6b33c97 100644 --- a/mod/quiz/tests/classes/attempt_walkthrough_testcase.php +++ b/mod/quiz/tests/classes/attempt_walkthrough_testcase.php @@ -314,7 +314,8 @@ abstract class attempt_walkthrough_testcase extends \advanced_testcase { // Finish the attempt. if (!isset($step['finished']) || ($step['finished'] == 1)) { $attemptobj = quiz_attempt::create($attemptid); - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); + $attemptobj->process_grade_submission($timenow); } } return $attemptids; diff --git a/mod/quiz/tests/classes/question_helper_test_trait.php b/mod/quiz/tests/classes/question_helper_test_trait.php index 9f9ee850739..381df437849 100644 --- a/mod/quiz/tests/classes/question_helper_test_trait.php +++ b/mod/quiz/tests/classes/question_helper_test_trait.php @@ -127,7 +127,8 @@ trait question_helper_test_trait { // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); - $attemptobj->process_finish($starttime, false); + $attemptobj->process_submit($starttime, false); + $attemptobj->process_grade_submission($starttime); $this->setUser(); return [$quizobj, $quba, $attemptobj]; diff --git a/mod/quiz/tests/custom_completion_test.php b/mod/quiz/tests/custom_completion_test.php index 2ed22234418..ccd496fde92 100644 --- a/mod/quiz/tests/custom_completion_test.php +++ b/mod/quiz/tests/custom_completion_test.php @@ -132,7 +132,8 @@ final class custom_completion_test extends advanced_testcase { // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); + $attemptobj->process_grade_submission($timenow); } /** diff --git a/mod/quiz/tests/event/events_test.php b/mod/quiz/tests/event/events_test.php index 6ad7934018f..8cd37eff509 100644 --- a/mod/quiz/tests/event/events_test.php +++ b/mod/quiz/tests/event/events_test.php @@ -118,21 +118,51 @@ final class events_test extends \advanced_testcase { public function test_attempt_submitted(): void { - list($quizobj, $quba, $attempt) = $this->prepare_quiz_data(); + [$quizobj, , $attempt] = $this->prepare_quiz_data(); $attemptobj = quiz_attempt::create($attempt->id); // Catch the event. $sink = $this->redirectEvents(); $timefinish = time(); - $attemptobj->process_finish($timefinish, false); + $attemptobj->process_submit($timefinish, false); + $events = $sink->get_events(); + $sink->close(); + + // Validate the event. + $this->assertCount(1, $events); + $event = $events[0]; + $this->assertInstanceOf('\mod_quiz\event\attempt_submitted', $event); + $this->assertEquals('quiz_attempts', $event->objecttable); + $this->assertEquals($quizobj->get_context(), $event->get_context()); + $this->assertEquals($attempt->userid, $event->relateduserid); + $this->assertEquals(null, $event->other['submitterid']); // Should be the user, but PHP Unit complains... + $this->assertEventContextNotUsed($event); + } + + /** + * The \mod_quiz\event\attempt_graded event should be fired when an attempt is graded. + * + * @return void + * @covers \mod_quiz\quiz_attempt::process_grade_submission + */ + public function test_attempt_graded(): void { + + [$quizobj, , $attempt] = $this->prepare_quiz_data(); + $attemptobj = quiz_attempt::create($attempt->id); + $timefinish = time(); + $attemptobj->process_submit($timefinish, false); + + // Catch the event. + $sink = $this->redirectEvents(); + $attemptobj->process_grade_submission($timefinish); $events = $sink->get_events(); $sink->close(); // Validate the event. $this->assertCount(3, $events); $event = $events[2]; - $this->assertInstanceOf('\mod_quiz\event\attempt_submitted', $event); + $this->assertInstanceOf('\mod_quiz\event\attempt_graded', $event); $this->assertEquals('quiz_attempts', $event->objecttable); $this->assertEquals($quizobj->get_context(), $event->get_context()); $this->assertEquals($attempt->userid, $event->relateduserid); diff --git a/mod/quiz/tests/external/external_test.php b/mod/quiz/tests/external/external_test.php index 09fb3d66a96..151f8f11ecd 100644 --- a/mod/quiz/tests/external/external_test.php +++ b/mod/quiz/tests/external/external_test.php @@ -206,7 +206,8 @@ final class external_test extends externallib_advanced_testcase { $attemptobj->process_submitted_actions(time(), false, $tosubmit); // Finish the attempt. - $attemptobj->process_finish(time(), false); + $attemptobj->process_submit(time(), false); + $attemptobj->process_grade_submission(time()); } return [$quiz, $context, $quizobj, $attempt, $attemptobj, $quba]; } else { @@ -228,6 +229,9 @@ final class external_test extends externallib_advanced_testcase { $record = new \stdClass(); $record->course = $course2->id; $record->intro = ''; + $timeopen = time() - 1; + $record->timeopen = $timeopen; + $record->precreateattempts = 1; $quiz2 = self::getDataGenerator()->create_module('quiz', $record); // Execute real Moodle enrolment as we'll call unenrol() method on the instance later. @@ -257,7 +261,7 @@ final class external_test extends externallib_advanced_testcase { 'browsersecurity', 'delay1', 'delay2', 'showuserpicture', 'showblocks', 'completionattemptsexhausted', 'completionpass', 'autosaveperiod', 'hasquestions', 'overduehandling', 'graceperiod', 'canredoquestions', 'allowofflineattempts']; - $managerfields = ['shuffleanswers', 'timecreated', 'timemodified', 'password', 'subnet']; + $managerfields = ['shuffleanswers', 'timecreated', 'timemodified', 'password', 'subnet', 'precreateattempts']; // Add expected coursemodule and other data. $quiz1 = $this->quiz; @@ -425,6 +429,11 @@ final class external_test extends externallib_advanced_testcase { } + /** + * Test get_user_attempts + * + * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. + */ public function test_get_user_attempts(): void { // Create a quiz with one attempt finished. @@ -432,6 +441,7 @@ final class external_test extends externallib_advanced_testcase { $this->setUser($this->student); $result = mod_quiz_external::get_user_attempts($quiz->id); + $this->assertDebuggingCalled(); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); $this->assertCount(1, $result['attempts']); @@ -441,24 +451,31 @@ final class external_test extends externallib_advanced_testcase { $this->assertEquals(1, $result['attempts'][0]['attempt']); $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); $this->assertEquals(1.0, $result['attempts'][0]['sumgrades']); + $this->assertEquals(quiz_attempt::FINISHED, $result['attempts'][0]['state']); // Test filters. Only finished. + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'finished', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); // Test filters. All attempts. + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'all', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); // Test filters. Unfinished. + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'unfinished', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(0, $result['attempts']); @@ -472,40 +489,55 @@ final class external_test extends externallib_advanced_testcase { quiz_attempt_save_started($quizobj, $quba, $attempt); // Test filters. All attempts. + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'all', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(2, $result['attempts']); // Test filters. Unfinished. + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'unfinished', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); // Test manager can see user attempts. $this->setUser($this->teacher); + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id, 'all'); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(2, $result['attempts']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); // Invalid parameters. try { + $this->resetDebugging(); mod_quiz_external::get_user_attempts($quiz->id, $this->student->id, 'INVALID_PARAMETER'); $this->fail('Exception expected due to missing capability.'); } catch (\invalid_parameter_exception $e) { + $this->assertDebuggingCalled(); $this->assertEquals('invalidparameter', $e->errorcode); } } + /** + * Test get_user_attempts with extra grades + * + * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. + */ public function test_get_user_attempts_with_extra_grades(): void { global $DB; @@ -521,8 +553,10 @@ final class external_test extends externallib_advanced_testcase { $structure->update_slot_grade_item($structure->get_slot_by_number(2), $readinggrade->id); $this->setUser($this->student); + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); @@ -533,8 +567,10 @@ final class external_test extends externallib_advanced_testcase { // Now change the review options, so marks are not displayed, and check the result. $DB->set_field('quiz', 'reviewmarks', 0, ['id' => $quiz->id]); + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); @@ -543,6 +579,8 @@ final class external_test extends externallib_advanced_testcase { /** * Test get_user_attempts with marks hidden + * + * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. */ public function test_get_user_attempts_with_marks_hidden(): void { // Create quiz with one attempt finished and hide the mark. @@ -552,8 +590,10 @@ final class external_test extends externallib_advanced_testcase { // Student cannot see the grades. $this->setUser($this->student); + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); @@ -565,8 +605,10 @@ final class external_test extends externallib_advanced_testcase { // Test manager can see user grades. $this->setUser($this->teacher); + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); @@ -577,6 +619,288 @@ final class external_test extends externallib_advanced_testcase { $this->assertEquals(1.0, $result['attempts'][0]['sumgrades']); } + /** + * Test get_user_attempts when the attempt is in 'submitted' state. + * + * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. + * @covers \mod_quiz_external::get_user_attempts + */ + public function test_get_user_attempts_submitted(): void { + + // Create a quiz with one attempt. + [$quiz, , , $attempt, $attemptobj] = $this->create_quiz_with_questions(true); + // Submit the attempt but do not finish it. + // Process some responses from the student. + $tosubmit = [1 => ['answer' => '3.14']]; + $attemptobj->process_submitted_actions(time(), false, $tosubmit); + $attemptobj->process_submit(time(), false); + + $this->setUser($this->student); + $result = mod_quiz_external::get_user_attempts($quiz->id); + $this->assertDebuggingCalled(); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + $this->assertEquals(1, $result['attempts'][0]['attempt']); + $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); + $this->assertNull($result['attempts'][0]['sumgrades']); // No grades. + $this->assertEquals(quiz_attempt::FINISHED, $result['attempts'][0]['state']); // State is returned as finished. + } + + /** + * Test get_user_attempts when the attempt is in 'notstarted' state. The attempt should not be returned. + * + * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. + * @covers \mod_quiz_external::get_user_attempts + */ + public function test_get_user_attempts_notstarted(): void { + // Create a quiz. + [$quiz, , $quizobj, , ] = $this->create_quiz_with_questions(); + // Create an attempt but do not start it. + // Now, do one attempt. + $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); + $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); + + $timenow = time(); + $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $this->student->id); + quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); + quiz_attempt_save_not_started($quba, $attempt); + + $this->setUser($this->student); + $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id, 'all'); + $this->assertDebuggingCalled(); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + + $this->assertCount(0, $result['attempts']); + } + + /** + * Test get_quiz_user_attempts + * + * @covers \mod_quiz_external::get_user_quiz_attempts + */ + public function test_get_user_quiz_attempts(): void { + + // Create a quiz with one attempt finished. + [$quiz, , $quizobj, $attempt, ] = $this->create_quiz_with_questions(true, true); + + $this->setUser($this->student); + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + $this->assertEquals(1, $result['attempts'][0]['attempt']); + $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); + $this->assertEquals(1.0, $result['attempts'][0]['sumgrades']); + $this->assertEquals(quiz_attempt::FINISHED, $result['attempts'][0]['state']); + + // Test filters. Only finished. + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, 0, 'finished', false); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + + // Test filters. All attempts. + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, 0, 'all', false); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + + // Test filters. Unfinished. + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, 0, 'unfinished', false); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(0, $result['attempts']); + + // Start a new attempt, but not finish it. + $timenow = time(); + $attempt = quiz_create_attempt($quizobj, 2, false, $timenow, false, $this->student->id); + $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); + $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); + + quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); + quiz_attempt_save_started($quizobj, $quba, $attempt); + + // Test filters. All attempts. + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, 0, 'all', false); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(2, $result['attempts']); + + // Test filters. Unfinished. + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, 0, 'unfinished', false); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + + // Test manager can see user attempts. + $this->setUser($this->teacher); + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, $this->student->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, $this->student->id, 'all'); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(2, $result['attempts']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + + // Invalid parameters. + try { + mod_quiz_external::get_user_quiz_attempts($quiz->id, $this->student->id, 'INVALID_PARAMETER'); + $this->fail('Exception expected due to missing capability.'); + } catch (\invalid_parameter_exception $e) { + $this->assertEquals('invalidparameter', $e->errorcode); + } + } + + /** + * Test get_user_quiz_attempts with extra grades + */ + public function test_get_user_quiz_attempts_with_extra_grades(): void { + global $DB; + + // Create a quiz with one attempt finished. + [$quiz, , , $attempt, $attemptobj] = $this->create_quiz_with_questions(true, true); + + // Add some extra grade items. + $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); + $listeninggrade = $quizgenerator->create_grade_item(['quizid' => $attemptobj->get_quizid(), 'name' => 'Listening']); + $readinggrade = $quizgenerator->create_grade_item(['quizid' => $attemptobj->get_quizid(), 'name' => 'Reading']); + $structure = $attemptobj->get_quizobj()->get_structure(); + $structure->update_slot_grade_item($structure->get_slot_by_number(1), $listeninggrade->id); + $structure->update_slot_grade_item($structure->get_slot_by_number(2), $readinggrade->id); + + $this->setUser($this->student); + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + + // Verify additional grades. + $this->assertEquals(['name' => 'Listening', 'grade' => 1, 'maxgrade' => 1], $result['attempts'][0]['gradeitemmarks'][0]); + $this->assertEquals(['name' => 'Reading', 'grade' => 0, 'maxgrade' => 1], $result['attempts'][0]['gradeitemmarks'][1]); + + // Now change the review options, so marks are not displayed, and check the result. + $DB->set_field('quiz', 'reviewmarks', 0, ['id' => $quiz->id]); + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + $this->assertArrayNotHasKey('gradeitemmarks', $result['attempts'][0]); + } + + /** + * Test get_user_quiz_attempts with marks hidden + * + * @covers \mod_quiz_external::get_user_quiz_attempts + */ + public function test_get_user_quiz_attempts_with_marks_hidden(): void { + // Create quiz with one attempt finished and hide the mark. + [$quiz, , , $attempt, ] = $this->create_quiz_with_questions( + true, true, 'deferredfeedback', false, + ['marksduring' => 0, 'marksimmediately' => 0, 'marksopen' => 0, 'marksclosed' => 0]); + + // Student cannot see the grades. + $this->setUser($this->student); + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + $this->assertEquals(1, $result['attempts'][0]['attempt']); + $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); + $this->assertEquals(null, $result['attempts'][0]['sumgrades']); + + // Test manager can see user grades. + $this->setUser($this->teacher); + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, $this->student->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + $this->assertEquals(1, $result['attempts'][0]['attempt']); + $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); + $this->assertEquals(1.0, $result['attempts'][0]['sumgrades']); + } + + /** + * Test get_user_quiz_attempts when the attempt is in 'submitted' state. + * + * @covers \mod_quiz_external::get_user_quiz_attempts + */ + public function test_get_user_quiz_attempts_submitted(): void { + + // Create a quiz with one attempt. + [$quiz, , , $attempt, $attemptobj] = $this->create_quiz_with_questions(true); + // Submit the attempt but do not finish it. + // Process some responses from the student. + $tosubmit = [1 => ['answer' => '3.14']]; + $attemptobj->process_submitted_actions(time(), false, $tosubmit); + $attemptobj->process_submit(time(), false); + + $this->setUser($this->student); + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + $this->assertEquals(1, $result['attempts'][0]['attempt']); + $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); + $this->assertNull($result['attempts'][0]['sumgrades']); // No grades. + $this->assertEquals(quiz_attempt::SUBMITTED, $result['attempts'][0]['state']); + } + + /** + * Test get_user_quiz_attempts when the attempt is in 'notstarted' state. + * + * @covers \mod_quiz_external::get_user_quiz_attempts + */ + public function test_get_user_quiz_attempts_notstarted(): void { + // Create a quiz. + [$quiz, , $quizobj, , ] = $this->create_quiz_with_questions(); + // Create an attempt but do not start it. + // Now, do one attempt. + $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); + $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); + + $timenow = time(); + $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $this->student->id); + quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); + quiz_attempt_save_not_started($quba, $attempt); + + $this->setUser($this->student); + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, $this->student->id, 'all'); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + $this->assertEquals(1, $result['attempts'][0]['attempt']); + $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); + $this->assertNull($result['attempts'][0]['sumgrades']); + $this->assertEquals(quiz_attempt::NOT_STARTED, $result['attempts'][0]['state']); + } + /** * Test get_user_best_grade */ @@ -661,7 +985,8 @@ final class external_test extends externallib_advanced_testcase { $attemptobj->process_submitted_actions($timenow, false, [1 => ['answer' => '3.14']]); // Finish the attempt. - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); + $attemptobj->process_grade_submission($timenow); $result = mod_quiz_external::get_user_best_grade($quizapi1->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result); @@ -723,7 +1048,8 @@ final class external_test extends externallib_advanced_testcase { $attemptobj->process_submitted_actions($timenow, false, [1 => ['answer' => '3.14']]); // Finish the attempt. - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); + $attemptobj->process_grade_submission($timenow); $result = mod_quiz_external::get_user_best_grade($quizapi2->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result); @@ -809,7 +1135,8 @@ final class external_test extends externallib_advanced_testcase { // Now, finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); + $attemptobj->process_grade_submission($timenow); $expected = [ "someoptions" => [ @@ -1026,7 +1353,8 @@ final class external_test extends externallib_advanced_testcase { // Finish the attempt. $attemptobj = quiz_attempt::create($attemptid); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); + $attemptobj->process_grade_submission($timenow); // We should be able to start a new attempt. $result = mod_quiz_external::start_attempt($quiz->id, [["name" => "quizpassword", "value" => 'abc']]); @@ -1127,7 +1455,8 @@ final class external_test extends externallib_advanced_testcase { // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); - $attemptobj->process_finish(time(), false); + $attemptobj->process_submit(time(), false); + $attemptobj->process_grade_submission(time()); try { testable_mod_quiz_external::validate_attempt($params, false); @@ -1208,7 +1537,7 @@ final class external_test extends externallib_advanced_testcase { $this->assertEmpty($result['questions'][0]['mark']); $this->assertEquals(1, $result['questions'][0]['maxmark']); $this->assertEquals(1, $result['questions'][0]['sequencecheck']); - $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']); + $this->assertEquals(\question_attempt_step::TIMECREATED_ON_FIRST_RENDER, $result['questions'][0]['lastactiontime']); $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']); // Now try the last page. @@ -1229,11 +1558,12 @@ final class external_test extends externallib_advanced_testcase { $this->assertFalse($result['questions'][0]['flagged']); $this->assertEquals(1, $result['questions'][0]['page']); $this->assertEquals(1, $result['questions'][0]['sequencecheck']); - $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']); + $this->assertEquals(\question_attempt_step::TIMECREATED_ON_FIRST_RENDER, $result['questions'][0]['lastactiontime']); $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']); // Finish previous attempt. - $attemptobj->process_finish(time(), false); + $attemptobj->process_submit(time(), false); + $attemptobj->process_grade_submission(time()); // Now we should receive the question state. $result = mod_quiz_external::get_attempt_review($attempt->id, 1); @@ -1346,8 +1676,8 @@ final class external_test extends externallib_advanced_testcase { $this->assertEmpty($result['questions'][1]['mark']); $this->assertEquals(1, $result['questions'][0]['sequencecheck']); $this->assertEquals(1, $result['questions'][1]['sequencecheck']); - $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']); - $this->assertGreaterThanOrEqual($timenow, $result['questions'][1]['lastactiontime']); + $this->assertEquals(\question_attempt_step::TIMECREATED_ON_FIRST_RENDER, $result['questions'][0]['lastactiontime']); + $this->assertEquals(\question_attempt_step::TIMECREATED_ON_FIRST_RENDER, $result['questions'][1]['lastactiontime']); $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']); $this->assertEquals(false, $result['questions'][1]['hasautosavedstep']); @@ -1425,7 +1755,7 @@ final class external_test extends externallib_advanced_testcase { $this->assertEquals(1, $result['questions'][0]['sequencecheck']); $this->assertEquals(1, $result['questions'][1]['sequencecheck']); $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']); - $this->assertGreaterThanOrEqual($timenow, $result['questions'][1]['lastactiontime']); + $this->assertEquals(\question_attempt_step::TIMECREATED_ON_FIRST_RENDER, $result['questions'][1]['lastactiontime']); $this->assertEquals(true, $result['questions'][0]['hasautosavedstep']); $this->assertEquals(false, $result['questions'][1]['hasautosavedstep']); @@ -2126,7 +2456,8 @@ final class external_test extends externallib_advanced_testcase { // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); + $attemptobj->process_grade_submission($timenow); // Can we start a new attempt? We shall not! $result = mod_quiz_external::get_attempt_access_information($quiz->id, $attempt->id); diff --git a/mod/quiz/tests/generator/lib.php b/mod/quiz/tests/generator/lib.php index 319fe05cf23..da94f9b80b4 100644 --- a/mod/quiz/tests/generator/lib.php +++ b/mod/quiz/tests/generator/lib.php @@ -181,7 +181,8 @@ class mod_quiz_generator extends testing_module_generator { } if ($finishattempt) { - $attemptobj->process_finish(time(), false); + $attemptobj->process_submit(time(), false); + $attemptobj->process_grade_submission(time()); } } diff --git a/mod/quiz/tests/lib_test.php b/mod/quiz/tests/lib_test.php index f9e8d084def..1cdde5fa2b0 100644 --- a/mod/quiz/tests/lib_test.php +++ b/mod/quiz/tests/lib_test.php @@ -193,7 +193,8 @@ final class lib_test extends \advanced_testcase { quiz_attempt_save_started($quizobj1a, $quba1a, $attempt); $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submitted_actions($timenow, false, [1 => ['answer' => '3.14']]); - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); + $attemptobj->process_grade_submission($timenow); // User 2 goes overdue in quiz 1. $attempt = quiz_create_attempt($quizobj1b, 1, false, $timenow, false, $u2->id); @@ -231,7 +232,8 @@ final class lib_test extends \advanced_testcase { quiz_start_new_attempt($quizobj2a, $quba2a, $attempt, 2, $timenow); quiz_attempt_save_started($quizobj2a, $quba2a, $attempt); $attemptobj = quiz_attempt::create($attempt->id); - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); + $attemptobj->process_grade_submission($timenow); $quba2a = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj2a->get_context()); $quba2a->set_preferred_behaviour($quizobj2a->get_quiz()->preferredbehaviour); @@ -689,7 +691,8 @@ final class lib_test extends \advanced_testcase { // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); + $attemptobj->process_grade_submission($timenow); // Create a calendar event. $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_OPEN); @@ -744,7 +747,8 @@ final class lib_test extends \advanced_testcase { // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); + $attemptobj->process_grade_submission($timenow); // Create a calendar event. $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_OPEN); diff --git a/mod/quiz/tests/privacy/provider_test.php b/mod/quiz/tests/privacy/provider_test.php index 369abf1c974..2fa4a9c61a7 100644 --- a/mod/quiz/tests/privacy/provider_test.php +++ b/mod/quiz/tests/privacy/provider_test.php @@ -273,7 +273,8 @@ final class provider_test extends \core_privacy\tests\provider_testcase { // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); - $attemptobj->process_finish($starttime, false); + $attemptobj->process_submit($starttime, false); + $attemptobj->process_grade_submission($starttime); // Fetch the contexts - no context should be returned. $this->setUser(); @@ -456,7 +457,8 @@ final class provider_test extends \core_privacy\tests\provider_testcase { // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); - $attemptobj->process_finish($starttime, false); + $attemptobj->process_submit($starttime, false); + $attemptobj->process_grade_submission($starttime); $this->setUser(); diff --git a/mod/quiz/tests/quiz_notify_attempt_manual_grading_completed_test.php b/mod/quiz/tests/quiz_notify_attempt_manual_grading_completed_test.php index e1f83c3731a..565fcf440c0 100644 --- a/mod/quiz/tests/quiz_notify_attempt_manual_grading_completed_test.php +++ b/mod/quiz/tests/quiz_notify_attempt_manual_grading_completed_test.php @@ -128,7 +128,8 @@ final class quiz_notify_attempt_manual_grading_completed_test extends advanced_t $attemptobj1 = quiz_attempt::create($attempt1->id); $tosubmit = [2 => ['answer' => 'Student 1 answer', 'answerformat' => FORMAT_HTML]]; $attemptobj1->process_submitted_actions($timenow - 30 * MINSECS, false, $tosubmit); - $attemptobj1->process_finish($timenow - 20 * MINSECS, false); + $attemptobj1->process_submit($timenow - 20 * MINSECS, false); + $attemptobj1->process_grade_submission($timenow - 10 * MINSECS); // Finish the attempt of student (now). $attemptobj1->get_question_usage()->manual_grade(2, 'Good!', 1, FORMAT_HTML); @@ -171,7 +172,8 @@ final class quiz_notify_attempt_manual_grading_completed_test extends advanced_t $attemptobj2 = quiz_attempt::create($attempt2->id); $tosubmit = [2 => ['answer' => 'Answer of student 2.', 'answerformat' => FORMAT_HTML]]; $attemptobj2->process_submitted_actions($timenow - 30 * MINSECS, false, $tosubmit); - $attemptobj2->process_finish($timenow, false); + $attemptobj2->process_submit($timenow, false); + $attemptobj2->process_grade_submission($timenow); // After time to notify, except attempt not graded, so it won't appear. $task = new quiz_notify_attempt_manual_grading_completed(); @@ -189,15 +191,19 @@ final class quiz_notify_attempt_manual_grading_completed_test extends advanced_t // Create an attempt for a user without the capability. $timenow = time(); + $timestart = $timenow - HOURSECS; + $timefinish = $timenow - 20 * MINSECS; + $gradetime = $timenow - 10 * MINSECS; $attempt = quiz_create_attempt($this->quizobj, 3, false, $timenow, false, $this->student->id); - quiz_start_new_attempt($this->quizobj, $this->quba, $attempt, 3, $timenow - HOURSECS); - quiz_attempt_save_started($this->quizobj, $this->quba, $attempt); + quiz_start_new_attempt($this->quizobj, $this->quba, $attempt, 3, $timestart); + quiz_attempt_save_started($this->quizobj, $this->quba, $attempt, $timestart); // Process some responses and submit. $attemptobj = quiz_attempt::create($attempt->id); $tosubmit = [2 => ['answer' => 'Essay answer.', 'answerformat' => FORMAT_HTML]]; $attemptobj->process_submitted_actions($timenow - 30 * MINSECS, false, $tosubmit); - $attemptobj->process_finish($timenow - 20 * MINSECS, false); + $attemptobj->process_submit($timefinish, false); + $attemptobj->process_grade_submission($gradetime); // Grade the attempt. $attemptobj->get_question_usage()->manual_grade(2, 'Good!', 1, FORMAT_HTML); @@ -219,7 +225,7 @@ final class quiz_notify_attempt_manual_grading_completed_test extends advanced_t $task->execute(); $attemptobj = quiz_attempt::create($attempt->id); - $this->assertEquals($attemptobj->get_attempt()->timefinish, $attemptobj->get_attempt()->gradednotificationsenttime); + $this->assertEquals($timefinish, $attemptobj->get_attempt()->gradednotificationsenttime); } /** @@ -241,7 +247,8 @@ final class quiz_notify_attempt_manual_grading_completed_test extends advanced_t $attemptobj = quiz_attempt::create($attempt->id); $tosubmit = [2 => ['answer' => 'Answer of student.', 'answerformat' => FORMAT_HTML]]; $attemptobj->process_submitted_actions($timenow - 30 * MINSECS, false, $tosubmit); - $attemptobj->process_finish($timenow - 20 * MINSECS, false); + $attemptobj->process_submit($timenow - 20 * MINSECS, false); + $attemptobj->process_grade_submission($timenow - 10 * MINSECS); // Finish the attempt of student. $attemptobj->get_question_usage()->manual_grade(2, 'Good!', 1, FORMAT_HTML); diff --git a/mod/quiz/tests/task/precreate_attempts_test.php b/mod/quiz/tests/task/precreate_attempts_test.php new file mode 100644 index 00000000000..ee6d06b23a3 --- /dev/null +++ b/mod/quiz/tests/task/precreate_attempts_test.php @@ -0,0 +1,446 @@ +. + +namespace mod_quiz\task; + +use mod_quiz\quiz_attempt; +use mod_quiz\quiz_settings; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); + +/** + * Unit tests for precreate_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 + * @covers \mod_quiz\task\precreate_attempts + */ +final class precreate_attempts_test extends \advanced_testcase { + use \quiz_question_helper_test_trait; + + /** + * Generate the various possible combinations precreation settings and the corresponding task output. + * + * @return array + */ + public static function precreate_settings_provider(): array { + return [ + [ + 'period' => 0, + 'output' => 'Pre-creation of quiz attempts is disabled. Nothing to do.', + ], + [ + 'period' => 1, + 'output' => 'Found 0 quizzes to create attempts for.', + ], + ]; + } + + /** + * The scheduled task only looks for quizzes to generate if pre-creation is configured. + * + * Only look for quizzes to generate attempts for if precreateperiod is not 0, and precreateattempts is 1 or is unlocked. + * + * @param int $period + * @param string $output + * @dataProvider precreate_settings_provider + */ + public function test_execute_disabled(int $period, string $output): void { + $this->resetAfterTest(); + set_config('precreateperiod', $period, 'quiz'); + + $task = new precreate_attempts(); + ob_start(); + $task->execute(); + $log = ob_get_clean(); + $this->assertMatchesRegularExpression("/{$output}/", $log); + } + + /** + * Test precreate attempts task. + * + * Generate quizzes with a variety of timeopen and precreateperiod settings, and ensure those that match the criteria + * for attempt pre-generation are picked up by the scheduled task. + */ + public function test_execute(): void { + $this->resetAfterTest(); + + // Generate a course. + $course = $this->getDataGenerator()->create_course(); + // Generate 3 users. + $student1 = $this->getDataGenerator()->create_user(); + $student2 = $this->getDataGenerator()->create_user(); + $teacher = $this->getDataGenerator()->create_user(); + // Enrol users on the course with the appropriate roles. + $this->getDataGenerator()->enrol_user($student1->id, $course->id, 'student'); + $this->getDataGenerator()->enrol_user($student2->id, $course->id, 'student'); + $this->getDataGenerator()->enrol_user($teacher->id, $course->id, 'editingteacher'); + + set_config('precreateperiod', 12 * HOURSECS, 'quiz'); + set_config('precreateattempts', 1, 'quiz'); + $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); + // Generate a quiz with timeopen 1 day in the future. + $quizinfuture = $quizgenerator->create_instance([ + 'course' => $course->id, + 'timeopen' => time() + 86400, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + ]); + // Generate a quiz with timeopen 0. + $quiznotimeopen = $quizgenerator->create_instance([ + 'course' => $course->id, + 'precreateperiod' => 43200, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + ]); + // Generate a quiz with timeopen in the past. + $quizinpast = $quizgenerator->create_instance([ + 'course' => $course->id, + 'timeopen' => time() - 86400, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + ]); + // Generate a quiz with timeopen 11 hours in the future. + $quizwithattempts = $quizgenerator->create_instance([ + 'course' => $course->id, + 'timeopen' => time() + 39600, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + ]); + // Generate second quiz with timeopen 11 hours in the future. + $quizinprecreateperiod = $quizgenerator->create_instance([ + 'course' => $course->id, + 'timeopen' => time() + 39600, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + ]); + // Generate second quiz with timeopen 11 hours in the future, + // but do not give it any questions. + $quizwithoutquestions = $quizgenerator->create_instance([ + 'course' => $course->id, + 'timeopen' => time() + 39600, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + ]); + // Add questions to the quizzes. + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $this->add_two_regular_questions($questiongenerator, $quizinfuture); + $this->add_two_regular_questions($questiongenerator, $quiznotimeopen); + $this->add_two_regular_questions($questiongenerator, $quizinpast); + $this->add_two_regular_questions($questiongenerator, $quizwithattempts); + $this->add_two_regular_questions($questiongenerator, $quizinprecreateperiod); + + // Create attempts for one student on quiz 5. + $quiz5settings = quiz_settings::create($quizwithattempts->id); + $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quiz5settings->get_context()); + $quba->set_preferred_behaviour($quiz5settings->get_quiz()->preferredbehaviour); + $attempt = quiz_create_attempt($quiz5settings, 1, false, time(), false, $student1->id); + quiz_start_new_attempt($quiz5settings, $quba, $attempt, 1, time()); + quiz_attempt_save_started($quiz5settings, $quba, $attempt); + + $this->assertEmpty( + quiz_get_user_attempts( + [ + $quizinfuture->id, + $quiznotimeopen->id, + $quizinpast->id, + $quizwithoutquestions->id, + ], + $student1->id, + 'all' + ) + ); + $this->assertEmpty( + quiz_get_user_attempts( + [ + $quizinfuture->id, + $quiznotimeopen->id, + $quizinpast->id, + $quizwithattempts->id, + $quizwithoutquestions->id, + ], + $student2->id, + 'all', + ), + ); + $this->assertEmpty( + quiz_get_user_attempts( + [ + $quizinfuture->id, + $quiznotimeopen->id, + $quizinpast->id, + $quizwithattempts->id, + $quizinprecreateperiod->id, + $quizwithoutquestions->id, + ], + $teacher->id, + 'all', + ), + ); + + $student1existingattempts = quiz_get_user_attempts($quizwithattempts->id, $student1->id, 'all'); + $this->assertCount(1, $student1existingattempts); + $this->assertEquals(reset($student1existingattempts)->state, quiz_attempt::IN_PROGRESS); + $student1precreatedattempts = quiz_get_user_attempts($quizinprecreateperiod->id, $student1->id, 'all'); + $this->assertEmpty($student1precreatedattempts); + $student2precreatedattempts = quiz_get_user_attempts($quizinprecreateperiod->id, $student2->id, 'all'); + $this->assertEmpty($student2precreatedattempts); + + $task = new precreate_attempts(); + ob_start(); + $task->execute(); + $log = ob_get_clean(); + + $this->assertMatchesRegularExpression('/Found 1 quizzes to create attempts for/', $log); + $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quizinfuture->name}/", $log); + $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quiznotimeopen->name}/", $log); + $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quizinpast->name}/", $log); + $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quizwithattempts->name}/", $log); + $this->assertMatchesRegularExpression("/Creating attempts for {$quizinprecreateperiod->name}/", $log); + $this->assertMatchesRegularExpression("/Created 2 attempts for {$quizinprecreateperiod->name}/", $log); + $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quizwithoutquestions->name}/", $log); + $this->assertMatchesRegularExpression('/Created attempts for 1 quizzes./', $log); + + // Students should have no attempts on quizzes that didn't meet criteria for pre-creation. + $this->assertEmpty( + quiz_get_user_attempts( + [ + $quizinfuture->id, + $quiznotimeopen->id, + $quizinpast->id, + $quizwithoutquestions->id, + ], + $student1->id, + 'all' + ) + ); + $this->assertEmpty( + quiz_get_user_attempts( + [ + $quizinfuture->id, + $quiznotimeopen->id, + $quizinpast->id, + $quizwithattempts->id, + $quizwithoutquestions->id, + ], + $student2->id, + 'all', + ), + ); + // Teacher should not have any attempts on any quizzes. + $this->assertEmpty( + quiz_get_user_attempts( + [ + $quizinfuture->id, + $quiznotimeopen->id, + $quizinpast->id, + $quizwithattempts->id, + $quizinprecreateperiod->id, + $quizwithoutquestions->id, + ], + $teacher->id, + 'all', + ), + ); + + // Students existing attempts should remain. + $student1existingattempts = quiz_get_user_attempts($quizwithattempts->id, $student1->id, 'all'); + $this->assertCount(1, $student1existingattempts); + $this->assertEquals(reset($student1existingattempts)->state, quiz_attempt::IN_PROGRESS); + // They should have NOT_STARTED attempts on quizzes that meet the criteria for pre-creation. + $student1precreatedattempts = quiz_get_user_attempts($quizinprecreateperiod->id, $student1->id, 'all'); + $this->assertCount(1, $student1precreatedattempts); + $this->assertEquals(reset($student1precreatedattempts)->state, quiz_attempt::NOT_STARTED); + $student2precreatedattempts = quiz_get_user_attempts($quizinprecreateperiod->id, $student2->id, 'all'); + $this->assertCount(1, $student2precreatedattempts); + $this->assertEquals(reset($student1precreatedattempts)->state, quiz_attempt::NOT_STARTED); + } + + /** + * Processing should stop at the end of a quiz once maxruntime has been reached. + * + * @return void + */ + public function test_execute_maxruntime(): void { + $this->resetAfterTest(); + + // Generate a course. + $course = $this->getDataGenerator()->create_course(); + // Generate 3 users. + $student1 = $this->getDataGenerator()->create_user(); + // Enrol users on the course with the appropriate roles. + $this->getDataGenerator()->enrol_user($student1->id, $course->id, 'student'); + + set_config('precreateperiod', 12 * HOURSECS, 'quiz'); + set_config('precreateattempts', 1, 'quiz'); + $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); + // Generate 3 quizzes within the pre-creation window. + $timenow = time(); + $quiz1 = $quizgenerator->create_instance([ + 'course' => $course->id, + 'timeopen' => $timenow + 39600, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + ]); + // This quiz opens first, so should be processed first. + $quiz2 = $quizgenerator->create_instance([ + 'course' => $course->id, + 'timeopen' => $timenow + 39599, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + ]); + $quiz3 = $quizgenerator->create_instance([ + 'course' => $course->id, + 'timeopen' => $timenow + 39600, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + ]); + // Add questions to the quizzes. + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $this->add_two_regular_questions($questiongenerator, $quiz1); + $this->add_two_regular_questions($questiongenerator, $quiz2); + $this->add_two_regular_questions($questiongenerator, $quiz3); + + // Run the task with a maxruntime of 0, so that it should stop after processing the first quiz. + $task = new precreate_attempts(0); + ob_start(); + $task->execute(); + $log = ob_get_clean(); + + // Verify that the task stopped after the quiz opening soonest. + $this->assertMatchesRegularExpression('/Found 3 quizzes to create attempts for/', $log); + $this->assertMatchesRegularExpression("/Creating attempts for {$quiz2->name}/", $log); + $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quiz1->name}/", $log); + $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quiz3->name}/", $log); + $this->assertMatchesRegularExpression("/Created 1 attempts for {$quiz2->name}/", $log); + $this->assertMatchesRegularExpression('/Time limit reached./', $log); + $this->assertMatchesRegularExpression('/Created attempts for 1 quizzes./', $log); + + // Run the task again with the default maxruntime. + ob_start(); + $task = new precreate_attempts(); + $task->execute(); + $log = ob_get_clean(); + + // Verify that it picks up the remaining quiz for processing. + $this->assertMatchesRegularExpression('/Found 2 quizzes to create attempts for/', $log); + $this->assertMatchesRegularExpression("/Creating attempts for {$quiz1->name}/", $log); + $this->assertMatchesRegularExpression("/Creating attempts for {$quiz3->name}/", $log); + $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quiz2->name}/", $log); + $this->assertMatchesRegularExpression("/Created 1 attempts for {$quiz1->name}/", $log); + $this->assertMatchesRegularExpression("/Created 1 attempts for {$quiz3->name}/", $log); + $this->assertDoesNotMatchRegularExpression('/Time limit reached./', $log); + $this->assertMatchesRegularExpression('/Created attempts for 2 quizzes./', $log); + } + + /** + * Pre-creation is opt in based on quiz setting. + * + * @return void + */ + public function test_execute_optin(): void { + $this->resetAfterTest(); + + // Generate a course. + $course = $this->getDataGenerator()->create_course(); + // Generate 3 users. + $student1 = $this->getDataGenerator()->create_user(); + $student2 = $this->getDataGenerator()->create_user(); + $teacher = $this->getDataGenerator()->create_user(); + // Enrol users on the course with the appropriate roles. + $this->getDataGenerator()->enrol_user($student1->id, $course->id, 'student'); + $this->getDataGenerator()->enrol_user($student2->id, $course->id, 'student'); + $this->getDataGenerator()->enrol_user($teacher->id, $course->id, 'editingteacher'); + + // Precreation is disabled by default. + set_config('precreateperiod', 12 * HOURSECS, 'quiz'); + set_config('precreateattempts', 0, 'quiz'); + $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); + // Generate a quiz with timeopen 11 hours in the future, and precreateattempts set to 1. + $quizprecreate = $quizgenerator->create_instance([ + 'course' => $course->id, + 'timeopen' => time() + 39600, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + 'precreateattempts' => 1, + ]); + // Generate a quiz with timeopen 11 hours in the future, and precreateattempts set to 0. + $quiznoprecreate = $quizgenerator->create_instance([ + 'course' => $course->id, + 'timeopen' => time() + 39600, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + 'precreateattempts' => 0, + ]); + // Generate a quiz with timeopen 11 hours in the future, and precreateattempts set to null. + $quizprecreatenull = $quizgenerator->create_instance([ + 'course' => $course->id, + 'timeopen' => time() + 39600, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + ]); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $this->add_two_regular_questions($questiongenerator, $quizprecreate); + $this->add_two_regular_questions($questiongenerator, $quiznoprecreate); + $this->add_two_regular_questions($questiongenerator, $quizprecreatenull); + + // Run the task. + ob_start(); + $task = new precreate_attempts(); + $task->execute(); + $log = ob_get_clean(); + + // Attempts were now created for the opted-in quiz. + $this->assertMatchesRegularExpression('/Found 1 quizzes to create attempts for/', $log); + $this->assertMatchesRegularExpression("/Creating attempts for {$quizprecreate->name}/", $log); + $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quiznoprecreate->name}/", $log); + $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quizprecreatenull->name}/", $log); + $this->assertMatchesRegularExpression('/Created attempts for 1 quizzes./', $log); + + // Now enabled by default. + set_config('precreateattempts', 1, 'quiz'); + + // Run the task again. + ob_start(); + $task = new precreate_attempts(); + $task->execute(); + $log = ob_get_clean(); + + // The quiz with null now has attempts generated. + $this->assertMatchesRegularExpression('/Found 1 quizzes to create attempts for/', $log); + $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quizprecreate->name}/", $log); + $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quiznoprecreate->name}/", $log); + $this->assertMatchesRegularExpression("/Creating attempts for {$quizprecreatenull->name}/", $log); + $this->assertMatchesRegularExpression('/Created attempts for 1 quizzes./', $log); + } +} diff --git a/mod/quiz/version.php b/mod/quiz/version.php index cc3d953e0ec..135a8b05ff7 100644 --- a/mod/quiz/version.php +++ b/mod/quiz/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2024121800; +$plugin->version = 2025011300; $plugin->requires = 2024100100; $plugin->component = 'mod_quiz'; diff --git a/question/engine/questionattempt.php b/question/engine/questionattempt.php index 4176f60d379..33ca2c1909e 100644 --- a/question/engine/questionattempt.php +++ b/question/engine/questionattempt.php @@ -900,6 +900,7 @@ class question_attempt { */ public function render($options, $number, $page = null) { $this->ensure_question_initialised(); + $this->set_first_step_timecreated(); if (is_null($page)) { global $PAGE; $page = $PAGE; @@ -1713,6 +1714,22 @@ class question_attempt { public function get_steps_with_submitted_response_iterator() { return new question_attempt_steps_with_submitted_response_iterator($this); } + + /** + * If the first step has a timecreated set to TIMECREATED_ON_FIRST_RENDER, set it to the current time. + * + * @return void + */ + protected function set_first_step_timecreated(): void { + global $DB; + $firststep = $this->get_step(0); + if ((int)$firststep->get_timecreated() === question_attempt_step::TIMECREATED_ON_FIRST_RENDER) { + $timenow = time(); + $firststep->set_timecreated($timenow); + $this->observer->notify_step_modified($firststep, $this, 0); + $DB->set_field('question_attempt_steps', 'timecreated', $timenow, ['id' => $firststep->get_id()]); + } + } } diff --git a/question/engine/questionattemptstep.php b/question/engine/questionattemptstep.php index da1750a090e..b845e9d45a0 100644 --- a/question/engine/questionattemptstep.php +++ b/question/engine/questionattemptstep.php @@ -69,6 +69,11 @@ defined('MOODLE_INTERNAL') || die(); * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_attempt_step { + /** + * @var int Indicates that timecreated will be set the first time the attempt is rendered + */ + const TIMECREATED_ON_FIRST_RENDER = -1; + /** * @var integer if this attempts is stored in the question_attempts table, * the id of that row. @@ -107,7 +112,8 @@ class question_attempt_step { * normally created by {@see question_attempt} methods like * {@see question_attempt::process_action()}. * @param array $data the submitted data that defines this step. - * @param int|null $timecreated the time to record for the action. (If not given, use now.) + * @param int|null $timecreated the time to record for the action. If null, use now. If + * {@see self::TIMECREATED_ON_FIRST_RENDER}, the time will be set the first time the attempt is rendered. * @param int|null $userid the user to attribute the aciton to. (If not given, use the current user.) * @param int|null $existingstepid if this step is going to replace an existing step * (for example, during a regrade) this is the id of the previous step we are replacing. @@ -220,6 +226,16 @@ class question_attempt_step { return $this->timecreated; } + /** + * Setter for $this->timecreated. + * + * @param int $timecreated + * @return void + */ + public function set_timecreated(int $timecreated): void { + $this->timecreated = $timecreated; + } + /** * @param string $name the name of a question type variable to look for in the submitted data. * @return bool whether a variable with this name exists in the question type data. diff --git a/question/engine/tests/datalib_reporting_queries_test.php b/question/engine/tests/datalib_reporting_queries_test.php index 8e6bb24a3cb..edfe6abd4fd 100644 --- a/question/engine/tests/datalib_reporting_queries_test.php +++ b/question/engine/tests/datalib_reporting_queries_test.php @@ -373,7 +373,8 @@ final class datalib_reporting_queries_test extends \qbehaviour_walkthrough_test_ // Submit attempt. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submitted_actions($timenow, false); - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); + $attemptobj->process_grade_submission($timenow); // Calculate the statistics. $this->expectOutputRegex('~.*Calculations completed.*~'); diff --git a/question/tests/local/statistics/statistics_bulk_loader_test.php b/question/tests/local/statistics/statistics_bulk_loader_test.php index 785400eac77..d76dd180a73 100644 --- a/question/tests/local/statistics/statistics_bulk_loader_test.php +++ b/question/tests/local/statistics/statistics_bulk_loader_test.php @@ -204,7 +204,8 @@ final class statistics_bulk_loader_test extends advanced_testcase { // Submit attempt. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submitted_actions($timenow, false, $answers); - $attemptobj->process_finish($timenow, false); + $attemptobj->process_submit($timenow, false); + $attemptobj->process_grade_submission($timenow); } /**