This commit is contained in:
Shamim Rezaie
2025-03-29 23:22:33 +11:00
52 changed files with 2094 additions and 168 deletions
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
}
}
@@ -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()];
}
+1 -1
View File
@@ -1619,7 +1619,7 @@
<FIELD NAME="sequencenumber" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Numbers the steps in a question attempt sequentially from 0."/>
<FIELD NAME="state" TYPE="char" LENGTH="13" NOTNULL="true" SEQUENCE="false" COMMENT="One of the constants defined by the question_state class, giving the state of the question at the end of this step."/>
<FIELD NAME="fraction" TYPE="number" LENGTH="12" NOTNULL="false" SEQUENCE="false" DECIMALS="7" COMMENT="The grade for this question, when graded out of 1. Needs to be multiplied by question_attempt.maxmark to get the actual mark for the question."/>
<FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Time-stamp of the action that lead to this state being created."/>
<FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Time-stamp of the action that lead to this state being created. If this is -1 (quiz_attempt_step::TIMECREATED_ON_FIRST_RENDER), it will be set the first time the question attempt is rendered."/>
<FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="The user whose action lead to this state being created."/>
</FIELDS>
<KEYS>
@@ -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();
@@ -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);
+104
View File
@@ -0,0 +1,104 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* The mod_quiz attempt submitted event.
*
* @package mod_quiz
* @copyright 2024 Catalyst IT Europe Ltd.
* @author Mark Johnson <[email protected]>
* @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;
}
}
+179 -5
View File
@@ -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 {
@@ -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
@@ -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');
}
@@ -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 '-';
+74 -11
View File
@@ -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) {
@@ -0,0 +1,155 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
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 <[email protected]>
* @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;
}
}
+2 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?>
<XMLDB PATH="mod/quiz/db" VERSION="20230804" COMMENT="XMLDB file for Moodle mod/quiz"
<XMLDB PATH="mod/quiz/db" VERSION="20240523" COMMENT="XMLDB file for Moodle mod/quiz"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
>
@@ -48,6 +48,7 @@
<FIELD NAME="completionattemptsexhausted" TYPE="int" LENGTH="1" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="completionminattempts" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="allowofflineattempts" TYPE="int" LENGTH="1" NOTNULL="false" DEFAULT="0" SEQUENCE="false" COMMENT="Whether to allow the quiz to be attempted offline in the mobile app"/>
<FIELD NAME="precreateattempts" TYPE="int" LENGTH="1" NOTNULL="false" SEQUENCE="false" COMMENT="Pre-create attempts for this quiz? This setting has no effect unless the precreateperiod config setting is set and unlocked."/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+11 -1
View File
@@ -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',
+9
View File
@@ -53,4 +53,13 @@ $tasks = [
'month' => '*',
'dayofweek' => '*',
],
[
'classname' => 'mod_quiz\task\precreate_attempts',
'blocking' => 0,
'minute' => 'R',
'hour' => '*',
'day' => '*',
'dayofweek' => '*',
'month' => '*',
],
];
+12
View File
@@ -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;
}
+47 -5
View File
@@ -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?';
+2 -1
View File
@@ -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':
+98 -29
View File
@@ -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();
+19
View File
@@ -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();
@@ -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 | - | - | - |
@@ -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"
+26 -6
View File
@@ -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());
@@ -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
+49 -18
View File
@@ -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
+10 -11
View File
@@ -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));
+17
View File
@@ -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);
}
}
+132 -18
View File
@@ -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());
}
}
+12 -3
View File
@@ -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]);
@@ -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"
+15 -1
View File
@@ -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);
}
}
@@ -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 "<answer>" to "1"
And I press "Finish attempt ..."
@@ -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 | <period> | 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 "<timeopen>"
And I expand all fieldsets
Then I <exists> see "Pre-create attempts"
And "Pre-create attempts" "select" <exists> be visible
And I <exists> see "Yes, 1 hours before quiz open time"
Examples:
| period | timeopen | exists |
| 3600 | 1 | should |
| 3600 | 0 | should not |
| 0 | 1 | should not |
@@ -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;
@@ -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];
+2 -1
View File
@@ -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);
}
/**
+33 -3
View File
@@ -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);
+345 -14
View File
@@ -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 = '<button>Test with HTML allowed.</button>';
$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);
+2 -1
View File
@@ -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());
}
}
+8 -4
View File
@@ -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);
+4 -2
View File
@@ -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();
@@ -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);
@@ -0,0 +1,446 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
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 <[email protected]>
* @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);
}
}
+1 -1
View File
@@ -24,6 +24,6 @@
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2024121800;
$plugin->version = 2025011300;
$plugin->requires = 2024100100;
$plugin->component = 'mod_quiz';
+17
View File
@@ -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()]);
}
}
}
+17 -1
View File
@@ -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.
@@ -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.*~');
@@ -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);
}
/**