This commit is contained in:
Jake Dallimore 2023-03-15 11:31:36 +08:00
commit ecc0f661c5
18 changed files with 832 additions and 21 deletions

View File

@ -0,0 +1,16 @@
define("mod_quiz/reopen_attempt_ui",["exports","core/notification","core/ajax","core/str"],(function(_exports,_notification,_ajax,_str){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0;
/**
* This module has the code to make the Re-open attempt button work, if present.
*
* That is, it looks for buttons with HTML like
* <button type="button" data-action="reopen-attempt" data-attempt-id="227000" data-after-action-url="/mod/quiz/report.php">
* and if that is clicked, it first shows an 'Are you sure' pop-up, and if they are sure,
* the attempt is re-opened, and then the page reloads.
*
* @module mod_quiz/reopen_attempt_ui
* @copyright 2023 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
const reopenButtonClicked=async e=>{if(!(e.target instanceof HTMLElement&&e.target.matches('button[data-action="reopen-attempt"]')))return;e.preventDefault();const attemptId=e.target.dataset.attemptId;try{const messages=(0,_ajax.call)([{methodname:"mod_quiz_get_reopen_attempt_confirmation",args:{attemptid:attemptId}}]);await(0,_notification.saveCancelPromise)((0,_str.get_string)("reopenattemptareyousuretitle","mod_quiz"),messages[0],(0,_str.get_string)("reopenattempt","mod_quiz"),{triggerElement:e.target}),await(0,_ajax.call)([{methodname:"mod_quiz_reopen_attempt",args:{attemptid:attemptId}}])[0],window.location=M.cfg.wwwroot+e.target.dataset.afterActionUrl}catch(error){if("modal-save-cancel:cancel"===error.type)return;await(0,_notification.exception)(error)}};_exports.init=()=>{document.addEventListener("click",reopenButtonClicked)}}));
//# sourceMappingURL=reopen_attempt_ui.min.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"reopen_attempt_ui.min.js","sources":["../src/reopen_attempt_ui.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * This module has the code to make the Re-open attempt button work, if present.\n *\n * That is, it looks for buttons with HTML like\n * &lt;button type=\"button\" data-action=\"reopen-attempt\" data-attempt-id=\"227000\" data-after-action-url=\"/mod/quiz/report.php\">\n * and if that is clicked, it first shows an 'Are you sure' pop-up, and if they are sure,\n * the attempt is re-opened, and then the page reloads.\n *\n * @module mod_quiz/reopen_attempt_ui\n * @copyright 2023 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {exception as displayException} from 'core/notification';\nimport {call as fetchMany} from 'core/ajax';\nimport {get_string as getString} from 'core/str';\nimport {saveCancelPromise} from 'core/notification';\n\n/**\n * Handle a click if it is on one of our buttons.\n *\n * @param {MouseEvent} e the click event.\n */\nconst reopenButtonClicked = async(e) => {\n if (!(e.target instanceof HTMLElement) || !e.target.matches('button[data-action=\"reopen-attempt\"]')) {\n return;\n }\n\n e.preventDefault();\n const attemptId = e.target.dataset.attemptId;\n\n try {\n // We fetch the confirmation message from the server now, so the message is based\n // on the latest state of the attempt, rather than when the containing page loaded.\n const messages = fetchMany([{\n methodname: 'mod_quiz_get_reopen_attempt_confirmation',\n args: {\n \"attemptid\": attemptId,\n },\n }]);\n\n await saveCancelPromise(\n getString('reopenattemptareyousuretitle', 'mod_quiz'),\n messages[0],\n getString('reopenattempt', 'mod_quiz'),\n {triggerElement: e.target},\n );\n\n await (fetchMany([{\n methodname: 'mod_quiz_reopen_attempt',\n args: {\n \"attemptid\": attemptId,\n },\n }])[0]);\n window.location = M.cfg.wwwroot + e.target.dataset.afterActionUrl;\n\n } catch (error) {\n if (error.type === 'modal-save-cancel:cancel') {\n // User clicked Cancel, so do nothing.\n return;\n }\n await displayException(error);\n }\n};\n\nexport const init = () => {\n document.addEventListener('click', reopenButtonClicked);\n};\n"],"names":["reopenButtonClicked","async","e","target","HTMLElement","matches","preventDefault","attemptId","dataset","messages","methodname","args","triggerElement","window","location","M","cfg","wwwroot","afterActionUrl","error","type","document","addEventListener"],"mappings":";;;;;;;;;;;;;MAsCMA,oBAAsBC,MAAAA,SAClBC,EAAEC,kBAAkBC,aAAiBF,EAAEC,OAAOE,QAAQ,gDAI5DH,EAAEI,uBACIC,UAAYL,EAAEC,OAAOK,QAAQD,oBAKzBE,UAAW,cAAU,CAAC,CACxBC,WAAY,2CACZC,KAAM,WACWJ,oBAIf,oCACF,mBAAU,+BAAgC,YAC1CE,SAAS,IACT,mBAAU,gBAAiB,YAC3B,CAACG,eAAgBV,EAAEC,eAGhB,cAAU,CAAC,CACdO,WAAY,0BACZC,KAAM,WACWJ,cAEjB,GACJM,OAAOC,SAAWC,EAAEC,IAAIC,QAAUf,EAAEC,OAAOK,QAAQU,eAErD,MAAOC,UACc,6BAAfA,MAAMC,kBAIJ,2BAAiBD,uBAIX,KAChBE,SAASC,iBAAiB,QAAStB"}

View File

@ -0,0 +1,83 @@
// 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/>.
/**
* This module has the code to make the Re-open attempt button work, if present.
*
* That is, it looks for buttons with HTML like
* &lt;button type="button" data-action="reopen-attempt" data-attempt-id="227000" data-after-action-url="/mod/quiz/report.php">
* and if that is clicked, it first shows an 'Are you sure' pop-up, and if they are sure,
* the attempt is re-opened, and then the page reloads.
*
* @module mod_quiz/reopen_attempt_ui
* @copyright 2023 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {exception as displayException} from 'core/notification';
import {call as fetchMany} from 'core/ajax';
import {get_string as getString} from 'core/str';
import {saveCancelPromise} from 'core/notification';
/**
* Handle a click if it is on one of our buttons.
*
* @param {MouseEvent} e the click event.
*/
const reopenButtonClicked = async(e) => {
if (!(e.target instanceof HTMLElement) || !e.target.matches('button[data-action="reopen-attempt"]')) {
return;
}
e.preventDefault();
const attemptId = e.target.dataset.attemptId;
try {
// We fetch the confirmation message from the server now, so the message is based
// on the latest state of the attempt, rather than when the containing page loaded.
const messages = fetchMany([{
methodname: 'mod_quiz_get_reopen_attempt_confirmation',
args: {
"attemptid": attemptId,
},
}]);
await saveCancelPromise(
getString('reopenattemptareyousuretitle', 'mod_quiz'),
messages[0],
getString('reopenattempt', 'mod_quiz'),
{triggerElement: e.target},
);
await (fetchMany([{
methodname: 'mod_quiz_reopen_attempt',
args: {
"attemptid": attemptId,
},
}])[0]);
window.location = M.cfg.wwwroot + e.target.dataset.afterActionUrl;
} catch (error) {
if (error.type === 'modal-save-cancel:cancel') {
// User clicked Cancel, so do nothing.
return;
}
await displayException(error);
}
};
export const init = () => {
document.addEventListener('click', reopenButtonClicked);
};

View File

@ -19,9 +19,9 @@ namespace mod_quiz;
use core_component;
use mod_quiz\form\preflight_check_form;
use mod_quiz\local\access_rule_base;
use mod_quiz\output\renderer;
use mod_quiz\question\display_options;
use mod_quiz_mod_form;
use mod_quiz\output\renderer;
use moodle_page;
use moodle_url;
use MoodleQuickForm;

View File

@ -0,0 +1,81 @@
<?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\event;
use coding_exception;
use core\event\base;
use moodle_url;
/**
* Event fired when a quiz attempt is reopened.
*
* @property-read array $other {
* Extra information about event.
*
* - int submitterid: id of submitter (null when triggered by CLI script).
* - int quizid: (optional) id of the quiz.
* }
*
* @package mod_quiz
* @since Moodle 4.2
* @copyright 2023 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class attempt_reopened extends base {
protected function init() {
$this->data['objecttable'] = 'quiz_attempts';
$this->data['crud'] = 'u';
$this->data['edulevel'] = self::LEVEL_TEACHING;
}
public function get_description(): string {
return "The user with id '$this->relateduserid' has had their attempt with id '$this->objectid'" .
"for the quiz with course module id '$this->contextinstanceid' re-opened by the user with id '$this->userid'.";
}
public static function get_name(): string {
return get_string('eventquizattemptreopened', 'mod_quiz');
}
public function get_url(): moodle_url {
return new moodle_url('/mod/quiz/review.php', ['attempt' => $this->objectid]);
}
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.');
}
}
public static function get_objectid_mapping(): array {
return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt'];
}
public static function get_other_mapping(): array {
return [
'submitterid' => ['db' => 'user', 'restore' => 'user'],
'quizid' => ['db' => 'quiz', 'restore' => 'quiz'],
];
}
}

View File

@ -0,0 +1,98 @@
<?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\external;
use core_external\external_api;
use core_external\external_description;
use core_external\external_function_parameters;
use core_external\external_value;
use Exception;
use html_writer;
use mod_quiz\quiz_attempt;
use moodle_exception;
/**
* Web service to check a quiz attempt state, and return a confirmation message if it can be reopened now.
*
* The use must have the 'mod/quiz:reopenattempts' capability and the attempt
* must (at least for now) be in the 'Never submitted' state (quiz_attempt::ABANDONED).
*
* @package mod_quiz
* @copyright 2023 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class get_reopen_attempt_confirmation extends external_api {
/**
* Declare the method parameters.
*
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'attemptid' => new external_value(PARAM_INT, 'The id of the attempt to reopen'),
]);
}
/**
* Check a quiz attempt state, and return a confirmation message method implementation.
*
* @param int $attemptid the id of the attempt to reopen.
* @return string a suitable confirmation message (HTML), if the attempt is suitable to be reopened.
* @throws Exception an appropriate exception if the attempt cannot be reopened now.
*/
public static function execute(int $attemptid): string {
global $DB;
['attemptid' => $attemptid] = self::validate_parameters(
self::execute_parameters(), ['attemptid' => $attemptid]);
// Check the request is valid.
$attemptobj = quiz_attempt::create($attemptid);
require_capability('mod/quiz:reopenattempts', $attemptobj->get_context());
self::validate_context($attemptobj->get_context());
if ($attemptobj->get_state() != quiz_attempt::ABANDONED) {
throw new moodle_exception('reopenattemptwrongstate', 'quiz', '',
['attemptid' => $attemptid, 'state' => quiz_attempt_state_name($attemptobj->get_state())]);
}
// Work out what the affect or re-opening will be.
$timestamp = time();
$timeclose = $attemptobj->get_access_manager(time())->get_end_time($attemptobj->get_attempt());
if ($timeclose && $timestamp > $timeclose) {
$expectedoutcome = get_string('reopenedattemptwillbesubmitted', 'quiz');
} else if ($timeclose) {
$expectedoutcome = get_string('reopenedattemptwillbeinprogressuntil', 'quiz', userdate($timeclose));
} else {
$expectedoutcome = get_string('reopenedattemptwillbeinprogress', 'quiz');
}
// Return the required message.
$user = $DB->get_record('user', ['id' => $attemptobj->get_userid()], '*', MUST_EXIST);
return html_writer::tag('p', get_string('reopenattemptareyousuremessage', 'quiz',
['attemptnumber' => $attemptobj->get_attempt_number(), 'attemptuser' => s(fullname($user))])) .
html_writer::tag('p', $expectedoutcome);
}
/**
* Define the webservice response.
*
* @return external_description
*/
public static function execute_returns(): external_description {
return new external_value(PARAM_RAW, 'Confirmation to show the user before the attempt is reopened.');
}
}

View File

@ -0,0 +1,79 @@
<?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\external;
use core_external\external_api;
use core_external\external_description;
use core_external\external_function_parameters;
use core_external\external_value;
use mod_quiz\quiz_attempt;
use moodle_exception;
/**
* Web service method for re-opening a quiz attempt.
*
* The use must have the 'mod/quiz:reopenattempts' capability and the attempt
* must (at least for now) be in the 'Never submitted' state (quiz_attempt::ABANDONED).
*
* @package mod_quiz
* @copyright 2023 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class reopen_attempt extends external_api {
/**
* Declare the method parameters.
*
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'attemptid' => new external_value(PARAM_INT, 'The id of the attempt to reopen'),
]);
}
/**
* Re-opening a submitted attempt method implementation.
*
* @param int $attemptid the id of the attempt to reopen.
*/
public static function execute(int $attemptid): void {
['attemptid' => $attemptid] = self::validate_parameters(
self::execute_parameters(), ['attemptid' => $attemptid]);
// Check the request is valid.
$attemptobj = quiz_attempt::create($attemptid);
require_capability('mod/quiz:reopenattempts', $attemptobj->get_context());
self::validate_context($attemptobj->get_context());
if ($attemptobj->get_state() != quiz_attempt::ABANDONED) {
throw new moodle_exception('reopenattemptwrongstate', 'quiz', '',
['attemptid' => $attemptid, 'state' => quiz_attempt_state_name($attemptobj->get_state())]);
}
// Re-open the attempt.
$attemptobj->process_reopen_abandoned(time());
}
/**
* Define the webservice response.
*
* @return external_description|null always null.
*/
public static function execute_returns(): ?external_description {
return null;
}
}

View File

@ -246,8 +246,10 @@ abstract class attempts_report extends report_base {
* @param array $headers the columns headings. Added to.
*/
protected function add_state_column(&$columns, &$headers) {
global $PAGE;
$columns[] = 'state';
$headers[] = get_string('attemptstate', 'quiz');
$PAGE->requires->js_call_amd('mod_quiz/reopen_attempt_ui', 'init');
}
/**

View File

@ -87,6 +87,9 @@ abstract class attempts_report_table extends \table_sql {
/** @var string strftime format. */
protected $strtimeformat;
/** @var bool|null used by {@see col_state()} to cache the has_capability result. */
protected $canreopen = null;
/**
* Constructor.
*
@ -177,11 +180,27 @@ abstract class attempts_report_table extends \table_sql {
* @return string HTML content to go inside the td.
*/
public function col_state($attempt) {
if (!is_null($attempt->attempt)) {
return quiz_attempt::state_name($attempt->state);
} else {
return '-';
if (is_null($attempt->attempt)) {
return '-';
}
$display = quiz_attempt::state_name($attempt->state);
if ($this->is_downloading()) {
return $display;
}
$this->canreopen ??= has_capability('mod/quiz:reopenattempts', $this->context);
if ($attempt->state == quiz_attempt::ABANDONED && $this->canreopen) {
$display .= ' ' . html_writer::tag('button', get_string('reopenattempt', 'quiz'), [
'type' => 'button',
'class' => 'btn btn-secondary',
'data-action' => 'reopen-attempt',
'data-attempt-id' => $attempt->attempt,
'data-after-action-url' => $this->reporturl->out_as_local_url(false),
]);
}
return $display;
}
/**

View File

@ -1846,6 +1846,39 @@ class quiz_attempt {
$transaction->allow_commit();
}
/**
* This method takes an attempt in the 'Never submitted' state, and reopens it.
*
* If, for this student, time has not expired (perhaps, because an override has
* been added, then the attempt is left open. Otherwise, it is immediately submitted
* for grading.
*
* @param int $timestamp the time to deem as now.
*/
public function process_reopen_abandoned($timestamp) {
global $DB;
// Verify that things are as we expect.
if ($this->get_state() != self::ABANDONED) {
throw new coding_exception('Can only reopen an attempt that was never submitted.');
}
$transaction = $DB->start_delegated_transaction();
$this->attempt->timemodified = $timestamp;
$this->attempt->state = self::IN_PROGRESS;
$this->attempt->timecheckstate = null;
$DB->update_record('quiz_attempts', $this->attempt);
$this->fire_state_transition_event('\mod_quiz\event\attempt_reopened', $timestamp, false);
$timeclose = $this->get_access_manager($timestamp)->get_end_time($this->attempt);
if ($timeclose && $timestamp > $timeclose) {
$this->process_finish($timestamp, false, $timeclose);
}
$transaction->allow_commit();
}
/**
* Fire a state transition event.
*

View File

@ -165,6 +165,16 @@ $capabilities = [
]
],
// Re-open attempts after they are closed.
'mod/quiz:reopenattempts' => [
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
'archetypes' => [
'editingteacher' => CAP_ALLOW,
'manager' => CAP_ALLOW
]
],
// Do not have the time limit imposed. Used for accessibility legislation compliance.
'mod/quiz:ignoretimelimits' => [
'captype' => 'read',

View File

@ -199,4 +199,20 @@ $functions = [
'capabilities' => 'mod/quiz:view',
'ajax' => true,
],
'mod_quiz_reopen_attempt' => [
'classname' => 'mod_quiz\external\reopen_attempt',
'description' => 'Re-open an attempt that is currently in the never submitted state.',
'type' => 'write',
'capabilities' => 'mod/quiz:reopenattempts',
'ajax' => true,
],
'mod_quiz_get_reopen_attempt_confirmation' => [
'classname' => 'mod_quiz\external\get_reopen_attempt_confirmation',
'description' => 'Verify it is OK to re-open a given quiz attempt, and if so, return a suitable confirmation message.',
'type' => 'read',
'capabilities' => 'mod/quiz:reopenattempts',
'ajax' => true,
],
];

View File

@ -370,10 +370,11 @@ $string['eventpagebreakcreated'] = 'Page break created';
$string['eventpagebreakdeleted'] = 'Page break deleted';
$string['eventquestionmanuallygraded'] = 'Question manually graded';
$string['eventquizattemptabandoned'] = 'Quiz attempt abandoned';
$string['eventquizattempttimelimitexceeded'] = 'Quiz attempt time limit exceeded';
$string['eventquizattemptregraded'] = 'Quiz attempt regraded';
$string['eventquizattemptreopened'] = 'Quiz attempt re-openend';
$string['eventquizattemptstarted'] = 'Quiz attempt started';
$string['eventquizattemptsubmitted'] = 'Quiz attempt submitted';
$string['eventquizattempttimelimitexceeded'] = 'Quiz attempt time limit exceeded';
$string['eventquizgradeupdated'] = 'Quiz grade updated';
$string['eventquizrepaginated'] = 'Quiz re-paginated';
$string['eventreportviewed'] = 'Quiz report viewed';
@ -791,6 +792,7 @@ $string['quizordernotrandom'] = 'Order of quiz not shuffled';
$string['quizorderrandom'] = '* Order of quiz is shuffled';
$string['quiz:preview'] = 'Preview quizzes';
$string['quiz:regrade'] = 'Regrade quiz attempts';
$string['quiz:reopenattempts'] = 'Re-open never submitted quiz attempts';
$string['quizreport'] = 'Quiz report';
$string['quiz:reviewmyattempts'] = 'Review your own attempts';
$string['quizsettings'] = 'Quiz settings';
@ -836,6 +838,13 @@ $string['removepagebreak'] = 'Remove page break';
$string['removeselected'] = 'Remove selected';
$string['rename'] = 'Rename';
$string['renderingserverconnectfailed'] = 'The server {$a} failed to process an RQP request. Check that the URL is correct.';
$string['reopenattempt'] = 'Re-open';
$string['reopenattemptareyousuremessage'] = 'Are you sure you wish to re-open quiz attempt {$a->attemptnumber} by {$a->attemptuser}?';
$string['reopenattemptareyousuretitle'] = 'Confirm re-open';
$string['reopenattemptwrongstate'] = 'Attempt {$a->attemptid} is in the wrong state ({$a->state}) to be re-openend.';
$string['reopenedattemptwillbeinprogress'] = 'The re-opened attempt will remain open so that it can be continued.';
$string['reopenedattemptwillbeinprogressuntil'] = 'The re-opened attempt will remain open so that it can be continued. It will be due for submission by {$a}.';
$string['reopenedattemptwillbesubmitted'] = 'The re-opened attempt will be immediately submitted for grading.';
$string['reorderquestions'] = 'Reorder questions';
$string['reordertool'] = 'Show the reordering tool';
$string['repaginate'] = 'Repaginate with {$a} questions per page';

View File

@ -0,0 +1,49 @@
@mod @mod_quiz @quiz @quiz_overview @javascript
Feature: Re-opening Never submitted quiz attempts
In order to cut some slack to students who forgot to submit their quiz attempt
As a teacher
I need to be able to re-open selected attempts.
Background:
Given the following "users" exist:
| username | firstname | lastname |
| teacher | Mark | Allwright |
| student | Freddy | Forgetful |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher | C1 | editingteacher |
| student | C1 | student |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name |
| Test questions | truefalse | TF |
And the following "activities" exist:
| activity | name | course | idnumber |
| quiz | Test quiz | C1 | quiz1 |
And quiz "Test quiz" contains the following questions:
| question | page |
| TF | 1 |
And user "student" has started an attempt at quiz "Test quiz"
And the attempt at "Test quiz" by "student" was never submitted
Scenario: Attempt can be re-opened
Given I am on the "Test quiz" "mod_quiz > Grades report" page logged in as teacher
When I press "Re-open"
And I should see "Are you sure you wish to re-open quiz attempt 1 by Freddy Forgetful?" in the "Confirm re-open" "dialogue"
And I should see "The re-opened attempt will remain open so that it can be continued." in the "Confirm re-open" "dialogue"
And I click on "Re-open" "button" in the "Confirm re-open" "dialogue"
Then I should see "In progress" in the "Freddy Forgetful" "table_row"
And "Re-open" "button" should not exist
Scenario: Re-opening an attempt can be cancelled and then nothing happens
Given I am on the "Test quiz" "mod_quiz > Grades report" page logged in as teacher
And I start watching to see if a new page loads
When I press "Re-open"
And I click on "Cancel" "button" in the "Confirm re-open" "dialogue"
Then a new page should not have loaded since I started watching
And I should see "Never submitted" in the "Freddy Forgetful" "table_row"

View File

@ -16,9 +16,9 @@
namespace mod_quiz;
use moodle_url;
use question_bank;
use question_engine;
use mod_quiz\quiz_settings;
defined('MOODLE_INTERNAL') || die();
@ -28,12 +28,12 @@ require_once($CFG->dirroot . '/mod/quiz/locallib.php');
/**
* Quiz attempt walk through.
*
* @package mod_quiz
* @category test
* @copyright 2013 The Open University
* @author Jamie Pratt <me@jamiep.org>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \quiz_attempt
* @package mod_quiz
* @category test
* @copyright 2013 The Open University
* @author Jamie Pratt <me@jamiep.org>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \mod_quiz\quiz_attempt
*/
class attempt_walkthrough_test extends \advanced_testcase {
@ -154,9 +154,10 @@ class attempt_walkthrough_test extends \advanced_testcase {
* The quiz is set to close 1 hour from now.
* The quiz is set to use a grade period of 1 hour once time expires.
*
* @param string $overduehandling value for the overduehandling quiz setting.
* @return \stdClass the quiz that was created.
*/
protected function create_quiz_with_one_question(): \stdClass {
protected function create_quiz_with_one_question(string $overduehandling = 'graceperiod'): \stdClass {
global $SITE;
$this->resetAfterTest();
@ -166,7 +167,7 @@ class attempt_walkthrough_test extends \advanced_testcase {
$quiz = $quizgenerator->create_instance(
['course' => $SITE->id, 'timeclose' => $timeclose,
'overduehandling' => 'graceperiod', 'graceperiod' => HOURSECS]);
'overduehandling' => $overduehandling, 'graceperiod' => HOURSECS]);
// Create a question.
/** @var \core_question_generator $questiongenerator */
@ -461,4 +462,120 @@ class attempt_walkthrough_test extends \advanced_testcase {
$gradebookgrade = array_shift($gradebookitem->grades);
$this->assertEquals(100, $gradebookgrade->grade);
}
public function test_quiz_attempt_walkthrough_abandoned_attempt_reopened_with_timelimit_override() {
global $DB;
$quiz = $this->create_quiz_with_one_question('autoabandon');
$originaltimeclose = $quiz->timeclose;
// Make a user to do the quiz.
$user = $this->getDataGenerator()->create_user();
$this->setUser($user);
$quizobj = quiz_settings::create($quiz->id, $user->id);
// Start the attempt.
$attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null);
// Process some responses from the student during the attempt.
$attemptobj = quiz_attempt::create($attempt->id);
$attemptobj->process_submitted_actions($originaltimeclose - 30 * MINSECS, false, [1 => ['answer' => 'frog']]);
// Student leaves, so cron closes the attempt when time expires.
$attemptobj->process_abandon($originaltimeclose + 5 * MINSECS, false);
// Verify the attempt state.
$attemptobj = quiz_attempt::create($attempt->id);
$this->assertEquals(quiz_attempt::ABANDONED, $attemptobj->get_state());
$this->assertEquals(0, $attemptobj->get_submitted_date());
$this->assertEquals($user->id, $attemptobj->get_userid());
// The teacher feels kind, so adds an override for the student, and re-opens the attempt.
$sink = $this->redirectEvents();
$overriddentimeclose = $originaltimeclose + HOURSECS;
$DB->insert_record('quiz_overrides', [
'quiz' => $quiz->id,
'userid' => $user->id,
'timeclose' => $overriddentimeclose,
]);
$attemptobj = quiz_attempt::create($attempt->id);
$reopentime = $originaltimeclose + 10 * MINSECS;
$attemptobj->process_reopen_abandoned($reopentime);
// Verify the attempt state.
$attemptobj = quiz_attempt::create($attempt->id);
$this->assertEquals(1, $attemptobj->get_attempt_number());
$this->assertFalse($attemptobj->is_finished());
$this->assertEquals(quiz_attempt::IN_PROGRESS, $attemptobj->get_state());
$this->assertEquals(0, $attemptobj->get_submitted_date());
$this->assertEquals($user->id, $attemptobj->get_userid());
$this->assertEquals($overriddentimeclose,
$attemptobj->get_access_manager($reopentime)->get_end_time($attemptobj->get_attempt()));
// Verify this was logged correctly.
$events = $sink->get_events();
$this->assertCount(1, $events);
$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());
}
public function test_quiz_attempt_walkthrough_abandoned_attempt_reopened_after_close_time() {
$quiz = $this->create_quiz_with_one_question('autoabandon');
$originaltimeclose = $quiz->timeclose;
// Make a user to do the quiz.
$user = $this->getDataGenerator()->create_user();
$this->setUser($user);
$quizobj = quiz_settings::create($quiz->id, $user->id);
// Start the attempt.
$attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null);
// Process some responses from the student during the attempt.
$attemptobj = quiz_attempt::create($attempt->id);
$attemptobj->process_submitted_actions($originaltimeclose - 30 * MINSECS, false, [1 => ['answer' => 'frog']]);
// Student leaves, so cron closes the attempt when time expires.
$attemptobj->process_abandon($originaltimeclose + 5 * MINSECS, false);
// Verify the attempt state.
$attemptobj = quiz_attempt::create($attempt->id);
$this->assertEquals(quiz_attempt::ABANDONED, $attemptobj->get_state());
$this->assertEquals(0, $attemptobj->get_submitted_date());
$this->assertEquals($user->id, $attemptobj->get_userid());
// The teacher reopens the attempt without granting more time, so previously submitted responess are graded.
$sink = $this->redirectEvents();
$reopentime = $originaltimeclose + 10 * MINSECS;
$attemptobj->process_reopen_abandoned($reopentime);
// Verify the attempt state.
$attemptobj = quiz_attempt::create($attempt->id);
$this->assertEquals(1, $attemptobj->get_attempt_number());
$this->assertTrue($attemptobj->is_finished());
$this->assertEquals(quiz_attempt::FINISHED, $attemptobj->get_state());
$this->assertEquals($originaltimeclose, $attemptobj->get_submitted_date());
$this->assertEquals($user->id, $attemptobj->get_userid());
$this->assertEquals(1, $attemptobj->get_sum_marks());
// 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);
$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->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());
}
}

View File

@ -842,10 +842,6 @@ class behat_mod_quiz extends behat_question_base {
/**
* Start a quiz attempt without answers.
*
* Then there should be a number of rows of data, one for each question you want to add.
* There is no need to supply answers to all questions. If so, other qusetions will be
* left unanswered.
*
* @param string $username the username of the user that will attempt.
* @param string $quizname the name of the quiz the user will attempt.
* @Given /^user "([^"]*)" has started an attempt at quiz "([^"]*)"$/
@ -988,6 +984,31 @@ class behat_mod_quiz extends behat_question_base {
$this->set_user();
}
/**
* Finish an existing quiz attempt.
*
* @param string $quizname the name of the quiz the user will attempt.
* @param string $username the username of the user that will attempt.
* @Given the attempt at :quizname by :username was never submitted
*/
public function attempt_was_abandoned($quizname, $username) {
global $DB;
$quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST);
$user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
$this->set_user($user);
$attempt = quiz_get_user_attempt_unfinished($quizid, $user->id);
if (!$attempt) {
throw new coding_exception("No in-progress attempt found for $username and quiz $quizname.");
}
$attemptobj = quiz_attempt::create($attempt->id);
$attemptobj->process_abandon(time(), false);
$this->set_user();
}
/**
* Return a list of the exact named selectors for the component.
*

View File

@ -0,0 +1,177 @@
<?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\external;
use coding_exception;
use core_question_generator;
use externallib_advanced_testcase;
use mod_quiz\quiz_attempt;
use mod_quiz\quiz_settings;
use required_capability_exception;
use stdClass;
/**
* Test for the reopen_attempt and get_reopen_attempt_confirmation services.
*
* @package mod_quiz
* @category external
* @copyright 2023 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \mod_quiz\external\reopen_attempt
* @covers \mod_quiz\external\get_reopen_attempt_confirmation
*/
class reopen_attempt_test extends externallib_advanced_testcase {
/** @var stdClass|null if we make a quiz attempt, we store the student object here. */
protected $student;
public function test_reopen_attempt_service_works() {
[$attemptid] = $this->create_attempt_at_quiz_with_one_shortanswer_question();
reopen_attempt::execute($attemptid);
$attemptobj = quiz_attempt::create($attemptid);
$this->assertEquals(quiz_attempt::IN_PROGRESS, $attemptobj->get_state());
}
public function test_reopen_attempt_service_checks_permissions() {
[$attemptid] = $this->create_attempt_at_quiz_with_one_shortanswer_question();
$unprivilegeduser = $this->getDataGenerator()->create_user();
$this->setUser($unprivilegeduser);
$this->expectException(required_capability_exception::class);
reopen_attempt::execute($attemptid);
}
public function test_reopen_attempt_service_checks_attempt_state() {
[$attemptid] = $this->create_attempt_at_quiz_with_one_shortanswer_question(quiz_attempt::IN_PROGRESS);
$this->expectExceptionMessage("Attempt $attemptid is in the wrong state (In progress) to be re-openend.");
reopen_attempt::execute($attemptid);
}
public function test_get_reopen_attempt_confirmation_staying_open() {
global $DB;
[$attemptid, $quizid] = $this->create_attempt_at_quiz_with_one_shortanswer_question();
$DB->set_field('quiz', 'timeclose', 0, ['id' => $quizid]);
$message = get_reopen_attempt_confirmation::execute($attemptid);
$this->assertEquals('<p>Are you sure you wish to re-open quiz attempt 1 by ' . fullname($this->student) .
'?</p><p>The re-opened attempt will remain open so that it can be continued.</p>',
$message);
}
public function test_get_reopen_attempt_confirmation_staying_open_until() {
global $DB;
[$attemptid, $quizid] = $this->create_attempt_at_quiz_with_one_shortanswer_question();
$timeclose = time() + HOURSECS;
$DB->set_field('quiz', 'timeclose', $timeclose, ['id' => $quizid]);
$message = get_reopen_attempt_confirmation::execute($attemptid);
$this->assertEquals('<p>Are you sure you wish to re-open quiz attempt 1 by ' . fullname($this->student) .
'?</p><p>The re-opened attempt will remain open so that it can be continued. It will be due for submission by ' .
userdate($timeclose) . '.</p>',
$message);
}
public function test_get_reopen_attempt_confirmation_submitting() {
global $DB;
[$attemptid, $quizid] = $this->create_attempt_at_quiz_with_one_shortanswer_question();
$timeclose = time() - HOURSECS;
$DB->set_field('quiz', 'timeclose', $timeclose, ['id' => $quizid]);
$message = get_reopen_attempt_confirmation::execute($attemptid);
$this->assertEquals('<p>Are you sure you wish to re-open quiz attempt 1 by ' . fullname($this->student) .
'?</p><p>The re-opened attempt will be immediately submitted for grading.</p>',
$message);
}
public function test_get_reopen_attempt_confirmation_service_checks_permissions() {
[$attemptid] = $this->create_attempt_at_quiz_with_one_shortanswer_question();
$unprivilegeduser = $this->getDataGenerator()->create_user();
$this->setUser($unprivilegeduser);
$this->expectException(required_capability_exception::class);
get_reopen_attempt_confirmation::execute($attemptid);
}
public function test_get_reopen_attempt_confirmation_service_checks_attempt_state() {
[$attemptid] = $this->create_attempt_at_quiz_with_one_shortanswer_question(quiz_attempt::IN_PROGRESS);
$this->expectExceptionMessage("Attempt $attemptid is in the wrong state (In progress) to be re-openend.");
get_reopen_attempt_confirmation::execute($attemptid);
}
/**
* Create a quiz of one shortanswer question and an attempt in a given state.
*
* @param string $attemptstate the desired attempt state. quiz_attempt::ABANDONED or ::IN_PROGRESS.
* @return array with two elements, the attempt id and the quiz id.
*/
protected function create_attempt_at_quiz_with_one_shortanswer_question(
string $attemptstate = quiz_attempt::ABANDONED
): array {
global $SITE;
$this->resetAfterTest();
// Make a quiz.
$timeclose = time() + HOURSECS;
$quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
$quiz = $quizgenerator->create_instance([
'course' => $SITE->id,
'timeclose' => $timeclose,
'overduehandling' => 'autoabandon'
]);
// Create a question.
/** @var core_question_generator $questiongenerator */
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
// Add them to the quiz.
$quizobj = quiz_settings::create($quiz->id);
quiz_add_quiz_question($saq->id, $quiz, 0, 1);
$quizobj->get_grade_calculator()->recompute_quiz_sumgrades();
// Make a user to do the quiz.
$this->student = $this->getDataGenerator()->create_user();
$this->setUser($this->student);
$quizobj = quiz_settings::create($quiz->id, $this->student->id);
// Start the attempt.
$attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null);
$attemptobj = quiz_attempt::create($attempt->id);
if ($attemptstate === quiz_attempt::ABANDONED) {
// Attempt goes overdue (e.g. if cron ran).
$attemptobj->process_abandon($timeclose + 2 * get_config('quiz', 'graceperiodmin'), false);
} else if ($attemptstate !== quiz_attempt::IN_PROGRESS) {
throw new coding_exception('State ' . $attemptstate . ' not currently supported.');
}
// Set current user to admin before we return.
$this->setAdminUser();
return [$attemptobj->get_attemptid(), $attemptobj->get_quizid()];
}
}

View File

@ -24,6 +24,6 @@
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2022120500;
$plugin->requires = 2022111800;
$plugin->version = 2023030300;
$plugin->requires = 2022111800;
$plugin->component = 'mod_quiz';