Commit 6d6f57d3 authored by Huong Nguyen's avatar Huong Nguyen
Browse files

MDL-74984 qbank: Add quick Duplicate question option to question bank

Including in this commit:
 - Created a new qbank_duplicatequestion plugin that allows users to duplicate the question immediately
 - Replaced the old Duplicate feature with the new quick Duplicate feature
 - Behat and Unit Test for the new feature
 - Created a new event called question_duplicated for question duplication
parent ceb41588
......@@ -152,6 +152,7 @@ $string['eventquestioncategoryupdated'] = 'Question category updated';
$string['eventquestioncategoryviewed'] = 'Question category viewed';
$string['eventquestioncreated'] = 'Question created';
$string['eventquestiondeleted'] = 'Question deleted';
$string['eventquestionduplicated'] = 'Question duplicated';
$string['eventquestionmoved'] = 'Question moved';
$string['eventquestionviewed'] = 'Question viewed';
$string['eventquestionsexported'] = 'Questions exported';
......
<?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/>.
/**
* Question duplicated event.
*
* @package core
* @copyright 2022 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since Moodle 4.1
*/
namespace core\event;
use moodle_url;
/**
* Question duplicated event class.
*
* @property-read array $other {
* Extra information about the event.
*
* - int categoryid: The ID of the category where the question resides
*
* @package core
* @copyright 2022 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since Moodle 4.1
*/
class question_duplicated extends question_base {
/**
* Init method.
*/
protected function init() {
parent::init();
$this->data['crud'] = 'c';
}
/**
* Returns localised general event name.
*
* @return string
*/
public static function get_name(): string {
return get_string('eventquestionduplicated', 'question');
}
/**
* Returns description of what happened.
*
* @return string
*/
public function get_description(): string {
return "The user with id '$this->userid' duplicated a question with the id of '$this->objectid'" .
" in the category with the id '" . $this->other['categoryid'] . "'.";
}
/**
* Returns relevant URL.
*
* @return moodle_url
*/
public function get_url(): moodle_url {
if ($this->courseid) {
if ($this->contextlevel == CONTEXT_MODULE) {
return new moodle_url('/question/bank/previewquestion/preview.php',
['cmid' => $this->contextinstanceid, 'id' => $this->objectid]);
}
return new moodle_url('/question/bank/previewquestion/preview.php',
['courseid' => $this->courseid, 'id' => $this->objectid]);
}
// Let's try editing from the frontpage for contexts above course.
return new moodle_url('/question/bank/previewquestion/preview.php', ['courseid' => SITEID, 'id' => $this->objectid]);
}
}
......@@ -83,4 +83,6 @@ $renamedclasses = [
'core_question\\form\\tags' => 'qbank_tagquestion\\form\\tags_form',
'context_to_string_translator' => 'core_question\\local\\bank\\context_to_string_translator',
'question_edit_contexts' => 'core_question\\local\\bank\\question_edit_contexts',
// Since Moodle 4.1.
'qbank_editquestion\\copy_action_column' => 'qbank_duplicatequestion\\duplicate_action_column',
];
......@@ -22,6 +22,7 @@ information provided here is intended especially for developers.
attribute may encounter different behaviours between older Moodle versions (<v3.11.8, <v4.0.2) and the later ones. We recommend
plugin developers to not use this attribute for Moodle versions 4.0 and below in order to avoid this problem.
* Added $CFG->proxylogunsafe and proxyfixunsafe to detect code which doesn't honor the proxy config
* New question_duplicated event was added to support the Question duplication feature.
=== 4.0 ===
......
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
// 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/>.
/**
* A javascript module to handle question duplicating.
*
* @module qbank_duplicatequestion/duplicate
* @copyright 2022 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 4.1
*/
import Notification from 'core/notification';
import Prefetch from 'core/prefetch';
import {get_string as getString} from 'core/str';
import * as Modal from 'core/modal_factory';
import * as ModalEvents from 'core/modal_events';
import * as DuplicateQuestionRepository from 'qbank_duplicatequestion/repository';
const SELECTOR = {
duplicateQuestion: '.path-question [data-action="duplicatequestion"]'
};
/**
* Register events for duplicate links.
*/
const registerEventListeners = () => {
document.addEventListener('click', e => {
const duplicateAction = e.target.closest(SELECTOR.duplicateQuestion);
if (duplicateAction) {
e.preventDefault();
const contextId = duplicateAction.dataset.contextid;
const questionId = duplicateAction.dataset.questionid;
const questionName = duplicateAction.dataset.questionname;
let body = '';
if (questionName === "") {
body = getString('duplicatequestioncheck_without_name', 'qbank_duplicatequestion');
} else {
body = getString('duplicatequestioncheck', 'qbank_duplicatequestion', questionName);
}
const url = duplicateAction.dataset.url;
const modalPromise = Modal.create({
type: Modal.types.SAVE_CANCEL,
title: getString('confirmation', 'admin'),
body: body,
buttons: {
save: getString('yes')
},
}).then(modal => {
modal.show();
return modal;
});
modalPromise.then(modal => {
modal.getRoot().on(ModalEvents.save, () => {
DuplicateQuestionRepository.duplicateQuestion(questionId, contextId, url,).then(data => {
if (data.status) {
return window.location.assign(data.returnurl);
} else {
return Notification.alert(getString('error'), data.warnings[0].message);
}
}).catch(Notification.exception);
});
return modal;
}).catch(Notification.exception);
}
});
};
/**
* Initialises.
*/
export const init = () => {
Prefetch.prefetchStrings('moodle', ['yes', 'error']);
Prefetch.prefetchStrings('core_admin', ['confirmation']);
Prefetch.prefetchStrings('qbank_duplicatequestion', ['duplicatequestioncheck', 'duplicatequestioncheck_without_name']);
registerEventListeners();
};
// 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/>.
/**
* A javascript module to handle qbank_duplicatequestion ajax actions.
*
* @module qbank_duplicatequestion/repository
* @copyright 2022 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 4.1
*/
import Ajax from 'core/ajax';
/**
* Duplicate the question in the question bank.
*
* @param {int} questionId The question id
* @param {int} contextId Is random question or not
* @param {string} returnUrl The quiz id
* @return {promise}
*/
export const duplicateQuestion = (questionId, contextId, returnUrl) => {
const request = {
methodname: 'qbank_duplicatequestion_make_duplicate',
args: {
questionid: questionId,
contextid: contextId,
returnurl: returnUrl,
}
};
return Ajax.call([request])[0];
};
<?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/>.
/**
* Question bank column for the quick duplicate action icon.
*
* @package qbank_duplicatequestion
* @copyright 2022 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace qbank_duplicatequestion;
use action_menu_link;
use action_menu_link_secondary;
use core_question\local\bank\action_column_base;
use core_question\local\bank\menuable_action;
use html_writer;
use moodle_url;
use pix_icon;
use question_bank;
use stdClass;
/**
* Question bank column for the quick duplicate action icon.
*
* @package qbank_duplicatequestion
* @copyright 2022 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class duplicate_action_column extends action_column_base implements menuable_action {
/** @var string avoids repeated calls to get_string('duplicate'). */
protected $strcopy;
/**
* Init.
*
* @return void
*/
protected function init(): void {
global $PAGE;
parent::init();
$this->strcopy = get_string('duplicate');
$PAGE->requires->js_call_amd('qbank_duplicatequestion/duplicate', 'init', ['#questionscontainer']);
}
/**
* Get the internal name for this column. Used for CSS class.
*
* @return string
*/
public function get_name(): string {
return 'duplicateaction';
}
/**
* Output the contents of this column.
*
* @param object $question the row from the $question table, augmented with extra information.
* @param string $rowclasses CSS class names that should be applied to this row of output.
*/
protected function display_content($question, $rowclasses): void {
global $OUTPUT;
if (question_has_capability_on($question, 'add') &&
(question_has_capability_on($question, 'edit') || question_has_capability_on($question, 'view')) &&
question_bank::is_qtype_installed($question->qtype)) {
[$url, $attributes] = $this->get_link_url_and_attributes($question);
echo html_writer::link($url, $OUTPUT->pix_icon('t/copy', $this->strcopy), $attributes);
}
}
/**
* Generate the link and attributes for given question.
*
* @param object $question the row from the $question table, augmented with extra information.
* @return array Array contains the url and attributes
*/
protected function get_link_url_and_attributes(object $question): array {
$url = new moodle_url($this->qbank->returnurl);
$attributes = [
'data-action' => 'duplicatequestion',
'data-contextid' => $this->qbank->get_most_specific_context()->id,
'data-questionid' => $question->id,
// If the user has disabled the qbank_viewquestioname plugin, the question object will not contain the name.
// We need to check that before using it.
'data-questionname' => $question->name ?? '',
'data-url' => $url->out(),
];
return [$url, $attributes];
}
/**
* Return the appropriate action menu link, or null if it does not apply to this question.
*
* @param stdClass $question the row from the $question table, augmented with extra information.
* @return action_menu_link|null the action, if applicable to this question.
*/
public function get_action_menu_link(stdClass $question): ?action_menu_link {
if (question_has_capability_on($question, 'add') &&
(question_has_capability_on($question, 'edit') || question_has_capability_on($question, 'view')) &&
question_bank::is_qtype_installed($question->qtype)) {
[$url, $attributes] = $this->get_link_url_and_attributes($question);
return new action_menu_link_secondary($url, new pix_icon('t/copy', ''), $this->strcopy, $attributes);
}
return null;
}
}
<?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/>.
/**
* Question bank external API for duplicating the question.
*
* @package qbank_duplicatequestion
* @category external
* @copyright 2022 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace qbank_duplicatequestion\external;
use context;
use core\event\question_duplicated;
use core_question\local\bank\question_edit_contexts;
use external_api;
use external_function_parameters;
use external_single_structure;
use external_value;
use external_warnings;
use moodle_url;
use question_bank;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/question/engine/bank.php');
require_once($CFG->dirroot . '/question/format/xml/format.php');
/**
* Question bank external API for duplicating the question.
*
* @package qbank_duplicatequestion
* @category external
* @copyright 2022 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since Moodle 4.1
*/
class duplicate extends external_api {
/** @var int Current processing question id. */
protected static $currentprocessingquestionid = 0;
/**
* Describes the parameters for duplicating the question.
*
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'questionid' => new external_value(PARAM_INT, 'The question id'),
'contextid' => new external_value(PARAM_INT, 'The context id'),
'returnurl' => new external_value(PARAM_URL, 'The redirect url')
]);
}
/**
* External function to duplicate question in the question bank.
*
* @param int $questionid The question id that need to be duplicated.
* @param int $contextid The editing context id.
* @param string $returnurl The return url.
* @return array
*/
public static function execute(int $questionid, int $contextid, string $returnurl): array {
global $COURSE, $DB;
// Parameter validation.
[
'questionid' => $questionid,
'contextid' => $contextid,
'returnurl' => $returnurl
] = self::validate_parameters(self::execute_parameters(), [
'questionid' => $questionid,
'contextid' => $contextid,
'returnurl' => $returnurl
]);
// Set the current question id.
self::$currentprocessingquestionid = $questionid;
// Context validation.
$editingcontext = context::instance_by_id($contextid);
self::validate_context($editingcontext);
$response = [
'status' => false,
'warnings' => []
];
// Verify that the question is existed.
if (!$DB->record_exists('question', ['id' => $questionid])) {
$response['warnings'][] = self::get_error_response('questiondoesnotexist', 'question');
return $response;
}
// Load the necessary data.
$contexts = new question_edit_contexts($editingcontext);
$questiondata = question_bank::load_question_data($questionid);
// Check permissions.
if (!question_has_capability_on($questiondata, 'add') ||
(!question_has_capability_on($questiondata, 'edit') && !question_has_capability_on($questiondata, 'view'))) {
$response['warnings'][] = self::get_error_response('nopermissions', 'error', get_string('duplicate'));
return $response;
}
// Get the suitable name for the question.
$questiondata->name = get_string('questionnamecopy', 'question', $questiondata->name);
// Export the question to temporary file.
$eformat = new \qformat_xml();
$eformat->setContexts($contexts->having_one_edit_tab_cap('export'));
$eformat->setCourse($COURSE);
$eformat->setQuestions([$questiondata]);
$eformat->setCattofile(true);
$eformat->setContexttofile(true);
if (!$eformat->exportpreprocess()) {
$response['warnings'][] = self::get_error_response('filenotfound', 'error');
return $response;
}
if (!$content = $eformat->exportprocess(true)) {
$response['warnings'][] = self::get_error_response('filenotfound', 'error');
return $response;
}
// Create temporary directory.
$tempfile = tempnam(make_temp_directory('qbank_duplicatequestion'), 'tmp');
file_put_contents($tempfile, $content);
// Import the question again.
$iformat = new \qformat_xml();
$iformat->set_display_progress(false);
$iformat->setContexts($contexts->having_one_edit_tab_cap('import'));
$iformat->setFilename($tempfile);
$iformat->setCatfromfile(true);
$iformat->setContextfromfile(true);
$iformat->setStoponerror(true);
if (!$iformat->importpreprocess()) {
$response['warnings'][] = self::get_error_response('filenotfound', 'error');
return $response;
}
if (!$iformat->importprocess()) {
$response['warnings'][] = self::get_error_response('filenotfound', 'error');
return $response;
}
if (!$iformat->importpostprocess()) {
$response['warnings'][] = self::get_error_response('filenotfound', 'error');
return $response;
}
// Delete the temporary file.
fulldelete($tempfile);
// Get the duplicated question.
$duplicatedquestiondata = question_bank::load_question_data($iformat->questionids[0]);
// If the original question has the idnumber, find the next unused idnumber and set it for the duplicated one.
$newidnumber = isset($questiondata->idnumber) ?
core_question_find_next_unused_idnumber($questiondata->idnumber, $questiondata->category) : '';
if ($newidnumber != '') {
$questionbankentry = get_question_bank_entry($duplicatedquestiondata->id);
$questionbankentry->idnumber = $newidnumber;
$DB->update_record('question_bank_entries', $questionbankentry);
}
// Log the duplication of this question.
$context = context::instance_by_id($iformat->category->contextid);
$event = question_duplicated::create_from_question_instance($duplicatedquestiondata, $context);
$event->trigger();
// Add the lastchanged param with the duplicated question id to highlight it in the question bank.
$newreturnurl = new moodle_url($returnurl);
$newreturnurl->param('lastchanged', $duplicatedquestiondata->id);
return [
'status' => true,
'createdquestionid' => $duplicatedquestiondata->id,
'returnurl' => $newreturnurl->out(false),
'warnings' => []
];
}
/**
* Describes the data returned from the external function.
*
* @return external_single_structure
*/
public static function execute_returns(): external_single_structure {
return new external_single_structure([
'status' => new external_value(PARAM_BOOL, 'status: true if success'),
'createdquestionid' => new external_value(PARAM_INT, 'The duplicated question id', VALUE_OPTIONAL),
'returnurl' => new external_value(PARAM_URL, 'The duplicated question id', VALUE_OPTIONAL),
'warnings' => new external_warnings()
]);
}