diff --git a/mod/quiz/amd/build/reopen_attempt_ui.min.js b/mod/quiz/amd/build/reopen_attempt_ui.min.js new file mode 100644 index 00000000000..fa29ac3e053 --- /dev/null +++ b/mod/quiz/amd/build/reopen_attempt_ui.min.js @@ -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 \ No newline at end of file diff --git a/mod/quiz/amd/build/reopen_attempt_ui.min.js.map b/mod/quiz/amd/build/reopen_attempt_ui.min.js.map new file mode 100644 index 00000000000..17118d1c422 --- /dev/null +++ b/mod/quiz/amd/build/reopen_attempt_ui.min.js.map @@ -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 .\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 * <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"} \ No newline at end of file diff --git a/mod/quiz/amd/src/reopen_attempt_ui.js b/mod/quiz/amd/src/reopen_attempt_ui.js new file mode 100644 index 00000000000..a3517564c75 --- /dev/null +++ b/mod/quiz/amd/src/reopen_attempt_ui.js @@ -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 . + +/** + * 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 + */ + +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); +}; diff --git a/mod/quiz/classes/access_manager.php b/mod/quiz/classes/access_manager.php index 7e4ddb9e0a3..577380e0869 100644 --- a/mod/quiz/classes/access_manager.php +++ b/mod/quiz/classes/access_manager.php @@ -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; diff --git a/mod/quiz/classes/event/attempt_reopened.php b/mod/quiz/classes/event/attempt_reopened.php new file mode 100644 index 00000000000..19538fd394b --- /dev/null +++ b/mod/quiz/classes/event/attempt_reopened.php @@ -0,0 +1,81 @@ +. + +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'], + ]; + } +} diff --git a/mod/quiz/classes/external/get_reopen_attempt_confirmation.php b/mod/quiz/classes/external/get_reopen_attempt_confirmation.php new file mode 100644 index 00000000000..1e08905fa1d --- /dev/null +++ b/mod/quiz/classes/external/get_reopen_attempt_confirmation.php @@ -0,0 +1,98 @@ +. + +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.'); + } +} diff --git a/mod/quiz/classes/external/reopen_attempt.php b/mod/quiz/classes/external/reopen_attempt.php new file mode 100644 index 00000000000..64a29453249 --- /dev/null +++ b/mod/quiz/classes/external/reopen_attempt.php @@ -0,0 +1,79 @@ +. + +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; + } +} diff --git a/mod/quiz/classes/local/reports/attempts_report.php b/mod/quiz/classes/local/reports/attempts_report.php index 682ae120c2c..ad8e5f3addc 100644 --- a/mod/quiz/classes/local/reports/attempts_report.php +++ b/mod/quiz/classes/local/reports/attempts_report.php @@ -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'); } /** diff --git a/mod/quiz/classes/local/reports/attempts_report_table.php b/mod/quiz/classes/local/reports/attempts_report_table.php index 23481184d5b..fb0dbea4d7b 100644 --- a/mod/quiz/classes/local/reports/attempts_report_table.php +++ b/mod/quiz/classes/local/reports/attempts_report_table.php @@ -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; } /** diff --git a/mod/quiz/classes/quiz_attempt.php b/mod/quiz/classes/quiz_attempt.php index 5b21fc3a4dd..ad8fc7ae1dc 100644 --- a/mod/quiz/classes/quiz_attempt.php +++ b/mod/quiz/classes/quiz_attempt.php @@ -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. * diff --git a/mod/quiz/db/access.php b/mod/quiz/db/access.php index a6be96ff6bd..a073c8955bc 100644 --- a/mod/quiz/db/access.php +++ b/mod/quiz/db/access.php @@ -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', diff --git a/mod/quiz/db/services.php b/mod/quiz/db/services.php index cec474a9f18..9cbdb269e4e 100644 --- a/mod/quiz/db/services.php +++ b/mod/quiz/db/services.php @@ -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, + ], ]; diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index 17ff0bdf333..68b1defff2a 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -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'; diff --git a/mod/quiz/report/overview/tests/behat/reopen_attempt.feature b/mod/quiz/report/overview/tests/behat/reopen_attempt.feature new file mode 100644 index 00000000000..ea297098fc2 --- /dev/null +++ b/mod/quiz/report/overview/tests/behat/reopen_attempt.feature @@ -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" diff --git a/mod/quiz/tests/attempt_walkthrough_test.php b/mod/quiz/tests/attempt_walkthrough_test.php index 3610c605369..c2a294f33ff 100644 --- a/mod/quiz/tests/attempt_walkthrough_test.php +++ b/mod/quiz/tests/attempt_walkthrough_test.php @@ -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 - * @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 + * @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()); + } } diff --git a/mod/quiz/tests/behat/behat_mod_quiz.php b/mod/quiz/tests/behat/behat_mod_quiz.php index dc16c7f7d04..3ddddfb0bf9 100644 --- a/mod/quiz/tests/behat/behat_mod_quiz.php +++ b/mod/quiz/tests/behat/behat_mod_quiz.php @@ -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. * diff --git a/mod/quiz/tests/external/reopen_attempt_test.php b/mod/quiz/tests/external/reopen_attempt_test.php new file mode 100644 index 00000000000..d81c242e1e2 --- /dev/null +++ b/mod/quiz/tests/external/reopen_attempt_test.php @@ -0,0 +1,177 @@ +. + +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('

Are you sure you wish to re-open quiz attempt 1 by ' . fullname($this->student) . + '?

The re-opened attempt will remain open so that it can be continued.

', + $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('

Are you sure you wish to re-open quiz attempt 1 by ' . fullname($this->student) . + '?

The re-opened attempt will remain open so that it can be continued. It will be due for submission by ' . + userdate($timeclose) . '.

', + $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('

Are you sure you wish to re-open quiz attempt 1 by ' . fullname($this->student) . + '?

The re-opened attempt will be immediately submitted for grading.

', + $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()]; + } +} diff --git a/mod/quiz/version.php b/mod/quiz/version.php index d03cd894795..0e8ed97e262 100644 --- a/mod/quiz/version.php +++ b/mod/quiz/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2022120500; -$plugin->requires = 2022111800; +$plugin->version = 2023030300; +$plugin->requires = 2022111800; $plugin->component = 'mod_quiz';