Merge branch 'MDL-68806_main' of https://github.com/marxjohnson/moodle
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 '-';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -53,4 +53,13 @@ $tasks = [
|
||||
'month' => '*',
|
||||
'dayofweek' => '*',
|
||||
],
|
||||
[
|
||||
'classname' => 'mod_quiz\task\precreate_attempts',
|
||||
'blocking' => 0,
|
||||
'minute' => 'R',
|
||||
'hour' => '*',
|
||||
'day' => '*',
|
||||
'dayofweek' => '*',
|
||||
'month' => '*',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,6 @@
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$plugin->version = 2024121800;
|
||||
$plugin->version = 2025011300;
|
||||
$plugin->requires = 2024100100;
|
||||
$plugin->component = 'mod_quiz';
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user