Skip to content

Commit

Permalink
MDL-35745 quiz: let teachers to re-open a Never submitted attempt
Browse files Browse the repository at this point in the history
In the quiz reports, for any 'Never submitted' attempt, there is
now a 'Re-open' button next to where it says the attempt state.

If that is clicked, there is an 'Are you sure?' pop-up. If the user
continues, the attempt is reopened. If the student now has time left,
the attempt is put into the In progress state. If there is not time
left the attempt is immediately submitted and graded. The
'Are you sure? pop-up says which of those two things will happen.
  • Loading branch information
timhunt committed Mar 14, 2023
1 parent 5e1df25 commit c051fbd
Show file tree
Hide file tree
Showing 18 changed files with 832 additions and 21 deletions.
16 changes: 16 additions & 0 deletions mod/quiz/amd/build/reopen_attempt_ui.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions mod/quiz/amd/build/reopen_attempt_ui.min.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 83 additions & 0 deletions 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 <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);
};
2 changes: 1 addition & 1 deletion mod/quiz/classes/access_manager.php
Expand Up @@ -19,9 +19,9 @@
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;
Expand Down
81 changes: 81 additions & 0 deletions mod/quiz/classes/event/attempt_reopened.php
@@ -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'],
];
}
}
98 changes: 98 additions & 0 deletions mod/quiz/classes/external/get_reopen_attempt_confirmation.php
@@ -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.');
}
}
79 changes: 79 additions & 0 deletions mod/quiz/classes/external/reopen_attempt.php
@@ -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;
}
}
2 changes: 2 additions & 0 deletions mod/quiz/classes/local/reports/attempts_report.php
Expand Up @@ -246,8 +246,10 @@ protected function configure_user_columns($table) {
* @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');
}

/**
Expand Down

0 comments on commit c051fbd

Please sign in to comment.