Merge branch 'master_MDL-71696-versioning-integration' of https://github.com/catalyst/moodle-MDL-70329

This commit is contained in:
Sara Arjona 2022-02-03 13:25:27 +01:00
commit b841a811be
223 changed files with 7768 additions and 2899 deletions

View file

@ -38,7 +38,9 @@ class helper {
list($usql, $params) = $DB->get_in_or_equal($questionids);
$sql = "SELECT q.*, c.contextid
FROM {question} q
JOIN {question_categories} c ON c.id = q.category
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} c ON c.id = qbe.questioncategoryid
WHERE q.id
{$usql}";
$questions = $DB->get_records_sql($sql, $params);

View file

@ -25,6 +25,7 @@
require_once(__DIR__ . '/../../../config.php');
require_once(__DIR__ . '/../../editlib.php');
global $DB, $OUTPUT, $PAGE, $COURSE;
$moveselected = optional_param('move', false, PARAM_BOOL);
@ -52,7 +53,7 @@ if ($cmid) {
throw new moodle_exception('missingcourseorcmid', 'question');
}
$contexts = new question_edit_contexts($thiscontext);
$contexts = new core_question\local\bank\question_edit_contexts($thiscontext);
$url = new moodle_url('/question/bank/bulkmove/move.php');
$PAGE->set_url($url);

View file

@ -16,6 +16,8 @@
namespace qbank_bulkmove;
use core_question\local\bank\question_edit_contexts;
defined('MOODLE_INTERNAL') || die();
global $CFG;
@ -53,7 +55,7 @@ class helper_test extends \advanced_testcase {
protected $context;
/**
* @var \question_edit_contexts $contexts
* @var \core_question\local\bank\question_edit_contexts $contexts
*/
protected $contexts;
@ -87,7 +89,7 @@ class helper_test extends \advanced_testcase {
$this->context = \context_course::instance($this->course->id);
// Create a question in the default category.
$this->contexts = new \question_edit_contexts($this->context);
$this->contexts = new question_edit_contexts($this->context);
$this->cat = question_make_default_categories($this->contexts->all());
$this->questiondata1 = $questiongenerator->create_question('numerical', null,
['name' => 'Example question', 'category' => $this->cat->id]);
@ -202,7 +204,7 @@ class helper_test extends \advanced_testcase {
public function test_get_displaydata() {
$this->helper_setup();
$coursecontext = \context_course::instance($this->course->id);
$contexts = new \question_edit_contexts($coursecontext);
$contexts = new question_edit_contexts($coursecontext);
$addcontexts = $contexts->having_cap('moodle/question:add');
$url = new \moodle_url('/question/bank/bulkmove/move.php');
$displaydata = \qbank_bulkmove\helper::get_displaydata($addcontexts, $url, $url);

View file

@ -137,7 +137,8 @@ class qbank_comment_backup_restore_test extends \advanced_testcase {
* across the backup and restore process.
*/
public function test_backup_restore() {
global $DB;
global $DB, $CFG;
require_once($CFG->dirroot . '/comment/lib.php');
$this->resetAfterTest();
$this->setAdminUser();
@ -195,12 +196,18 @@ class qbank_comment_backup_restore_test extends \advanced_testcase {
$this->restore_course($backupid, $coursefullname, $courseshortname . '_2', $newcategory->id);
// The questions and their associated comments should have been restored.
$newquestion1 = $DB->get_record('question', ['idnumber' => 'q1']);
$sql =
'SELECT q.*
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE qbe.idnumber = ?';
$newquestion1 = $DB->get_record_sql($sql, ['idnumber' => 'q1']);
$args->itemid = $newquestion1->id;
$commentobj = new \comment($args);
$this->assertEquals($commentobj->count(), 2);
$newquestion2 = $DB->get_record('question', ['idnumber' => 'q2']);
$newquestion2 = $DB->get_record_sql($sql, ['idnumber' => 'q2']);
$args->itemid = $newquestion2->id;
$commentobj = new \comment($args);
$this->assertEquals($commentobj->count(), 1);

View file

@ -104,7 +104,7 @@ class behat_qbank_comment extends behat_question_base {
if ($this->running_javascript()) {
$commentstextarea = $this->find('css',
'.question-comment-view .comment-area textarea', $exception);
'.comment-area textarea', $exception);
$commentstextarea->setValue($comment);
// We delay 1 second which is all we need.
@ -115,6 +115,31 @@ class behat_qbank_comment extends behat_question_base {
}
}
/**
* Deletes the specified comment from the current question comment preview.
*
* @Then I delete :arg comment from question preview
* @param string $comment
*/
public function i_delete_comment_from_question_preview($comment) {
$exception = new ElementNotFoundException($this->getSession(), '"' . $comment . '" comment ');
// Using xpath liternal to avoid possible problems with comments containing quotes.
$commentliteral = behat_context_helper::escape($comment);
$commentxpath = "//*[contains(concat(' ', normalize-space(@class), ' '), ' comment-ctrl ')]" .
"/descendant::div[@class='comment-message'][contains(., $commentliteral)]";
// Click on delete icon.
$this->execute('behat_general::i_click_on_in_the',
["Delete comment posted by", "icon", $this->escape($commentxpath), "xpath_element"]
);
// Wait for the animation to finish, in theory is just 1 sec, adding 4 just in case.
$this->getSession()->wait(4 * 1000);
}
/**
* Deletes the specified comment from the current question comment modal.
*

View file

@ -17,7 +17,7 @@ Feature: A Teacher can comment in a question
| activity | name | course | idnumber |
| quiz | Test quiz | C1 | quiz1 |
And the following "question categories" exist:
| contextlevel | reference | name |
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
@ -63,6 +63,7 @@ Feature: A Teacher can comment in a question
And I navigate to "Question bank" in current page administration
And I set the field "Select a category" to "Test questions"
And I choose "Preview" action for "First question" in the question bank
And I click on "Comments" "link"
Then I should see "Save comment"
And I add "Super test comment 01" comment to question preview
And I click on "Save comment" "link"
@ -71,7 +72,8 @@ Feature: A Teacher can comment in a question
And I click on "Close preview" "button"
Then I should see "1" on the comments column
And I choose "Preview" action for "First question" in the question bank
And I delete "Super test comment 01" comment from question
And I click on "Comments" "link"
And I delete "Super test comment 01" comment from question preview
And I should not see "Super test comment 01"
And I click on "Close preview" "button"
Then I should see "0" on the comments column
@ -100,6 +102,7 @@ Feature: A Teacher can comment in a question
And I press "id_submitbutton"
Then I should see "Essay 01 new"
And I choose "Preview" action for "Essay 01 new" in the question bank
And I click on "Comments" "link"
Then I should see "Save comment"
And I log out
Then I log in as "teacher2"
@ -112,3 +115,34 @@ Feature: A Teacher can comment in a question
And I choose "Preview" action for "Essay 01 new" in the question bank
Then I should not see "Save comment"
And I click on "Close preview" "button"
@javascript
Scenario: Comments added from the quiz page are visible
Given I log in as "teacher1"
And I am on the "Test quiz" "quiz activity" page
When I navigate to "Edit quiz" in current page administration
And I press "Add"
And I follow "from question bank"
And I click on "Select" "checkbox" in the "First question" "table_row"
And I click on "Add selected questions to the quiz" "button"
And I click on "Preview question" "link"
And I switch to "questionpreview" window
And I press "Comments"
And I set the field "content" to "Some new comment"
And I click on "Save comment" "link"
And I should see "Some new comment"
And I switch to the main window
And I am on the "Test quiz" "quiz activity" page
And I navigate to "Question bank > Questions" in current page administration
And I choose "Preview" action for "First question" in the question bank
And I click on "Comments" "link"
And I should see "Some new comment"
And I should see "T1 Teacher1"
And I delete "Some new comment" comment from question preview
And I should not see "Some new comment"
And I am on the "Test quiz" "quiz activity" page
And I navigate to "Edit quiz" in current page administration
And I click on "Preview question" "link"
And I switch to "questionpreview" window
And I press "Comments"
Then I should not see "Some new comment"

View file

@ -22,7 +22,6 @@ use comment;
use context;
use context_course;
use core_question_generator;
use question_edit_contexts;
use stdClass;
/**
@ -66,7 +65,7 @@ class comment_created_deleted_test extends advanced_testcase {
$this->context = context_course::instance($this->course->id);
// Create a question in the default category.
$contexts = new question_edit_contexts($this->context);
$contexts = new \core_question\local\bank\question_edit_contexts($this->context);
$cat = question_make_default_categories($contexts->all());
$this->questiondata = $questiongenerator->create_question('numerical', null,
['name' => 'Example question', 'category' => $cat->id]);

View file

@ -287,7 +287,12 @@ class qbank_customfields_customfield_testcase extends advanced_testcase {
$this->restore_course($backupid, $coursefullname, $courseshortname . '_2', $newcategory->id);
// The questions and their associated custom fields should have been restored.
$newquestion1 = $DB->get_record('question', ['idnumber' => 'q1']);
$sql = 'SELECT q.*
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE qbe.idnumber = ?';
$newquestion1 = $DB->get_record_sql($sql, ['q1']);
$newquestion1cfdata = $customfieldhandler->export_instance_data_object($newquestion1->id);
$this->assertEquals('some text', $newquestion1cfdata->f1);
$this->assertEquals('Yes', $newquestion1cfdata->f2);
@ -295,7 +300,7 @@ class qbank_customfields_customfield_testcase extends advanced_testcase {
$this->assertEquals('b', $newquestion1cfdata->f4);
$this->assertEquals('test', $newquestion1cfdata->f5);
$newquestion2 = $DB->get_record('question', ['idnumber' => 'q2']);
$newquestion2 = $DB->get_record_sql($sql, ['q2']);
$newquestion2cfdata = $customfieldhandler->export_instance_data_object($newquestion2->id);
$this->assertEquals('some more text', $newquestion2cfdata->f1);
$this->assertEquals('No', $newquestion2cfdata->f2);

View file

@ -24,6 +24,7 @@
namespace qbank_deletequestion;
use core_question\local\bank\question_version_status;
use core_question\local\bank\menu_action_column_base;
/**
@ -82,7 +83,7 @@ class delete_action_column extends menu_action_column_base {
if (!question_has_capability_on($question, 'edit')) {
return [null, null, null];
}
if ($question->hidden) {
if ($question->status === question_version_status::QUESTION_STATUS_HIDDEN) {
$hiddenparams = array(
'unhide' => $question->id,
'sesskey' => sesskey());
@ -102,7 +103,6 @@ class delete_action_column extends menu_action_column_base {
public function get_required_fields(): array {
$required = parent::get_required_fields();
$required[] = 'q.hidden';
return $required;
}
}

View file

@ -51,7 +51,7 @@ if ($cmid) {
throw new moodle_exception('missingcourseorcmid', 'question');
}
$contexts = new question_edit_contexts($thiscontext);
$contexts = new core_question\local\bank\question_edit_contexts($thiscontext);
$url = new moodle_url('/question/bank/deletequestion/delete.php');
$PAGE->set_url($url);
@ -62,7 +62,8 @@ $PAGE->set_heading($COURSE->fullname);
// Unhide a question.
if (($unhide = optional_param('unhide', '', PARAM_INT)) and confirm_sesskey()) {
question_require_capability_on($unhide, 'edit');
$DB->set_field('question', 'hidden', 0, array('id' => $unhide));
$DB->set_field('question_versions', 'status',
\core_question\local\bank\question_version_status::QUESTION_STATUS_READY, ['questionid' => $unhide]);
// Purge these questions from the cache.
\question_bank::notify_question_edited($unhide);
@ -81,7 +82,8 @@ if ($deleteselected && ($confirm = optional_param('confirm', '', PARAM_ALPHANUM)
$questionid = (int)$questionid;
question_require_capability_on($questionid, 'edit');
if (questions_in_use(array($questionid))) {
$DB->set_field('question', 'hidden', 1, array('id' => $questionid));
$DB->set_field('question_versions', 'status',
\core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN, ['questionid' => $questionid]);
} else {
question_delete_question($questionid);
}

View file

@ -55,7 +55,8 @@ Feature: Use the qbank plugin manager page for deletequestion
Given I log in as "admin"
And I am on the "Test quiz" "quiz activity" page
And I navigate to "Question bank > Questions" in current page administration
And I click on "Select all" "checkbox"
And I click on "First question" "checkbox"
And I click on "First question second" "checkbox"
And I click on "With selected" "button"
And I click on question bulk action "deleteselected"
And I click on "Delete" "button" in the "Confirm" "dialogue"

View file

@ -0,0 +1,2 @@
function _typeof(a){"@babel/helpers - typeof";if("function"==typeof Symbol&&"symbol"==typeof Symbol.iterator){_typeof=function(a){return typeof a}}else{_typeof=function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a}}return _typeof(a)}define ("qbank_editquestion/question_status",["exports","core/fragment","core/str","core/modal_factory","core/notification","core/modal_events","core/ajax"],function(a,b,c,d,e,f,g){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;b=j(b);c=i(c);d=j(d);e=j(e);f=j(f);g=j(g);function h(){if("function"!=typeof WeakMap)return null;var a=new WeakMap;h=function(){return a};return a}function i(a){if(a&&a.__esModule){return a}if(null===a||"object"!==_typeof(a)&&"function"!=typeof a){return{default:a}}var b=h();if(b&&b.has(a)){return b.get(a)}var c={},d=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var e in a){if(Object.prototype.hasOwnProperty.call(a,e)){var f=d?Object.getOwnPropertyDescriptor(a,e):null;if(f&&(f.get||f.set)){Object.defineProperty(c,e,f)}else{c[e]=a[e]}}}c.default=a;if(b){b.set(a,c)}return c}function j(a){return a&&a.__esModule?a:{default:a}}var k=function(a,c){return b.default.loadFragment("qbank_editquestion","question_status",c,a)},l=function(a,b){return g.default.call([{methodname:"qbank_editquestion_set_status",args:{questionid:a,formdata:b}}])[0]},m=function(a,b,c){var d=a.getBody().find("form").serialize();l(b,d).then(function(a){if(a.status){c.innerText=a.statusname}}).catch(e.default.exception)},n=function(a,b,c){o({questionid:a},b).then(function(b){b.show();var d=b.getRoot();d.on(f.default.save,function(d){d.preventDefault();d.stopPropagation();m(b,a,c);b.hide()});return b}).catch(e.default.exception)},o=function(a,b){return d.default.create({type:d.default.types.SAVE_CANCEL,title:c.get_string("questionstatusheader","qbank_editquestion"),body:k(a,b),large:!1})};a.init=function init(a,b){var c=document.querySelector(a),d=c.getAttribute("data-questionid");c.addEventListener("click",function(){n(d,b,c)})}});
//# sourceMappingURL=question_status.min.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,135 @@
// 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/>.
/**
* Status column selector js.
*
* @module qbank_editquestion/question_status
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Fragment from 'core/fragment';
import * as Str from 'core/str';
import ModalFactory from 'core/modal_factory';
import Notification from 'core/notification';
import ModalEvents from 'core/modal_events';
import Ajax from 'core/ajax';
/**
* Get the fragment.
*
* @method getFragment
* @param {{questioned: Number}} args
* @param {Number} contextId
* @return {String}
*/
const getFragment = (args, contextId) => {
return Fragment.loadFragment('qbank_editquestion', 'question_status', contextId, args);
};
/**
* Set the question status.
*
* @param {Number} questionId The question id.
* @param {String} formData The question tag form data in a URI encoded param string
* @return {Array} The modified question status
*/
const setQuestionStatus = (questionId, formData) => Ajax.call([{
methodname: 'qbank_editquestion_set_status',
args: {
questionid: questionId,
formdata: formData
}
}])[0];
/**
* Save the status.
*
* @method getFragment
* @param {object} modal
* @param {Number} questionId
* @param {HTMLElement} target
*/
const save = (modal, questionId, target) => {
const formData = modal.getBody().find('form').serialize();
setQuestionStatus(questionId, formData)
.then(result => {
if (result.status) {
target.innerText = result.statusname;
}
return;
})
.catch(Notification.exception);
};
/**
* Event listeners for the module.
*
* @method clickEvent
* @param {Number} questionId
* @param {Number} contextId
* @param {HTMLElement} target
*/
const statusEvent = (questionId, contextId, target) => {
let args = {
questionid: questionId
};
getStatusModal(args, contextId)
.then((modal) => {
modal.show();
let root = modal.getRoot();
root.on(ModalEvents.save, function(e) {
e.preventDefault();
e.stopPropagation();
save(modal, questionId, target);
modal.hide();
});
return modal;
})
.catch(Notification.exception);
};
/**
* Get the status modal to display.
*
* @param {{questionid: Number}} args
* @param {Number} contextId
* @return {HTMLElement}
*/
const getStatusModal = (args, contextId) => ModalFactory.create({
type: ModalFactory.types.SAVE_CANCEL,
title: Str.get_string('questionstatusheader', 'qbank_editquestion'),
body: getFragment(args, contextId),
large: false,
});
/**
* Entrypoint of the js.
*
* @method init
* @param {String} questionSelector the question status identifier.
* @param {Number} contextId The context id of the question.
*/
export const init = (questionSelector, contextId) => {
let target = document.querySelector(questionSelector);
let questionId = target.getAttribute('data-questionid');
target.addEventListener('click', () => {
// Call for the event listener to listed for clicks in any usage count row.
statusEvent(questionId, contextId, target);
});
};

View file

@ -27,6 +27,8 @@
namespace qbank_editquestion;
use core_question\local\bank\question_version_status;
/**
* Class editquestion_helper for methods related to add/edit/copy
*
@ -83,4 +85,27 @@ class editquestion_helper {
}
return $PAGE->get_renderer('qbank_editquestion')->render_create_new_question_button($addquestiondisplay);
}
/**
* Get the string for the status of the question.
*
* @param string $status
* @return string
*/
public static function get_question_status_string($status): string {
return get_string('questionstatus' . $status, 'qbank_editquestion');
}
/**
* Get the array of status of the questions.
*
* @return array
*/
public static function get_question_status_list(): array {
$statuslist = [];
$statuslist[question_version_status::QUESTION_STATUS_READY] = get_string('questionstatusready', 'qbank_editquestion');
$statuslist[question_version_status::QUESTION_STATUS_DRAFT] = get_string('questionstatusdraft', 'qbank_editquestion');
return $statuslist;
}
}

View file

@ -0,0 +1,109 @@
<?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 qbank_editquestion\external;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . '/externallib.php');
require_once($CFG->dirroot . '/question/engine/bank.php');
use external_api;
use external_function_parameters;
use external_single_structure;
use external_value;
use qbank_editquestion\editquestion_helper;
use question_bank;
/**
* Update question status external api.
*
* @package qbank_editquestion
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class update_question_version_status extends \external_api {
/**
* Returns description of method parameters.
*
* @return external_function_parameters.
*/
public static function execute_parameters() {
return new external_function_parameters([
'questionid' => new external_value(PARAM_INT, 'The question id'),
'formdata' => new external_value(PARAM_RAW, 'The data from the status form'),
]);
}
/**
* Handles the status form submission.
*
* @param int $questionid The question id.
* @param string $formdata The question tag form data in a URI encoded param string
* @return array The created or modified question tag
*/
public static function execute($questionid, $formdata) {
global $DB;
$data = [];
$result = [
'status' => false,
'statusname' => ''
];
// Parameter validation.
$params = self::validate_parameters(self::execute_parameters(), [
'questionid' => $questionid,
'formdata' => $formdata
]);
parse_str($params['formdata'], $data);
$question = question_bank::load_question($params['questionid']);
$editingcontext = \context::instance_by_id($question->contextid);
self::validate_context($editingcontext);
$canedit = question_has_capability_on($question, 'edit');
$mform = new \qbank_editquestion\form\question_status_form(null, null, 'post', '', null, $canedit, $data);
if ($validateddata = $mform->get_data()) {
if ($canedit && isset($validateddata->status)) {
$versionrecord = $DB->get_record('question_versions', ['questionid' => $params['questionid']]);
$versionrecord->status = $validateddata->status;
$DB->update_record('question_versions', $versionrecord);
question_bank::notify_question_edited($question->id);
$result = [
'status' => true,
'statusname' => editquestion_helper::get_question_status_string($versionrecord->status)
];
$event = \core\event\question_updated::create_from_question_instance($question, $editingcontext);
$event->trigger();
}
}
return $result;
}
/**
* Returns description of method result value.
*/
public static function execute_returns() {
return new external_single_structure([
'status' => new external_value(PARAM_BOOL, 'status: true if success'),
'statusname' => new external_value(PARAM_RAW, 'statusname: name of the status')
]);
}
}

View file

@ -0,0 +1,39 @@
<?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 qbank_editquestion\form;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/lib/formslib.php');
/**
* Class question_status_form to change the question status using a modal.
*
* @package qbank_editquestion
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_status_form extends \moodleform {
public function definition() {
$mform = $this->_form;
$mform->disable_form_change_checker();
$mform->addElement('select', 'status', get_string('status', 'qbank_editquestion'),
\qbank_editquestion\editquestion_helper::get_question_status_list());
}
}

View file

@ -57,4 +57,14 @@ class renderer extends \plugin_renderer_base {
return $this->render_from_template('qbank_editquestion/add_new_question', $addquestiondata);
}
/**
* Render question information for edit form.
*
* @param array $questiondata
* @return bool|string
*/
public function render_question_info($questiondata) {
return $this->render_from_template('qbank_editquestion/question_info', $questiondata);
}
}

View file

@ -38,7 +38,8 @@ class plugin_feature extends \core_question\local\bank\plugin_features_base{
public function get_question_columns($qbank): array {
return [
new edit_action_column($qbank),
new copy_action_column($qbank)
new copy_action_column($qbank),
new question_status_column($qbank)
];
}

View file

@ -0,0 +1,59 @@
<?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 qbank_editquestion;
use core_question\local\bank\column_base;
use core_question\local\bank\question_version_status;
/**
* A column to show the status of the question.
*
* @package qbank_editquestion
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_status_column extends column_base {
public function get_name(): string {
return 'questionstatus';
}
protected function get_title(): string {
return get_string('questionstatus', 'qbank_editquestion');
}
protected function display_content($question, $rowclasses): void {
global $PAGE;
$attributes = [];
if (question_has_capability_on($question, 'edit')
&& $question->status !== question_version_status::QUESTION_STATUS_HIDDEN) {
$target = 'questionstatus_' . $question->id;
$datatarget = '[data-target="' . $target . '"]';
$PAGE->requires->js_call_amd('qbank_editquestion/question_status', 'init', [$datatarget, $question->contextid]);
$attributes = [
'data-target' => $target,
'data-questionid' => $question->id,
'data-courseid' => $this->qbank->course->id,
'class' => 'link-primary comment-pointer',
'href' => '#'
];
}
echo \html_writer::tag('a', editquestion_helper::get_question_status_string($question->status), $attributes);
}
}

View file

@ -15,24 +15,21 @@
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Definition of question/type/random scheduled tasks.
* External services definition for qbank_editquestion.
*
* @package qtype_random
* @category task
* @copyright 2018 Bo Pierce <email.bO.pierce@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @package qbank_editquestion
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$tasks = array(
array(
'classname' => 'qtype_random\task\remove_unused_questions',
'blocking' => 0,
'minute' => 'R',
'hour' => '*',
'day' => '*',
'month' => '*',
'dayofweek' => '*'
)
);
$functions = [
'qbank_editquestion_set_status' => [
'classname' => 'qbank_editquestion\external\update_question_version_status',
'description' => 'Update the question status.',
'type' => 'write',
'ajax' => true,
],
];

View file

@ -25,3 +25,14 @@
$string['pluginname'] = 'Edit questions';
$string['privacy:metadata'] = 'The Edit questions question bank plugin does not store any user data.';
// Question status.
$string['questionstatus'] = 'Status';
$string['questionstatusready'] = 'Ready';
$string['questionstatushidden'] = 'Hidden';
$string['questionstatusdraft'] = 'Draft';
$string['questionstatusheader'] = 'Change question status';
// Edit form.
$string['versioninfo'] = 'Version';
$string['status'] = 'Question status';

View file

@ -0,0 +1,41 @@
<?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/>.
/**
* Helper functions and callbacks.
*
* @package qbank_editquestion
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* Question status fragment callback.
*
* @param array $args
* @return string rendered output
*/
function qbank_editquestion_output_fragment_question_status($args): string {
global $CFG;
require_once($CFG->dirroot . '/question/engine/bank.php');
$question = question_bank::load_question($args['questionid']);
$mform = new \qbank_editquestion\form\question_status_form();
$data = ['status' => $question->status];
$mform->set_data($data);
return $mform->render();
}

View file

@ -108,7 +108,7 @@ if ($cmid) {
} else {
throw new moodle_exception('missingcourseorcmid', 'question');
}
$contexts = new question_edit_contexts($thiscontext);
$contexts = new core_question\local\bank\question_edit_contexts($thiscontext);
$PAGE->set_pagelayout('admin');
if (optional_param('addcancel', false, PARAM_BOOL)) {
@ -174,10 +174,12 @@ if ($id) {
if (!$formeditable) {
question_require_capability_on($question, 'view');
}
$question->beingcopied = false;
if ($makecopy) {
// If we are duplicating a question, add some indication to the question name.
$question->name = get_string('questionnamecopy', 'question', $question->name);
$question->idnumber = core_question_find_next_unused_idnumber($question->idnumber, $category->id);
$question->idnumber = isset($question->idnumber) ?
core_question_find_next_unused_idnumber($question->idnumber, $category->id) : '';
$question->beingcopied = true;
}
@ -210,6 +212,17 @@ if ($formeditable && $id) {
$toform->appendqnumstring = $appendqnumstring;
$toform->returnurl = $originalreturnurl;
$toform->makecopy = $makecopy;
$toform->idnumber = null;
if (isset($question->id)) {
$questionobject = question_bank::load_question($question->id);
$toform->status = $questionobject->status;
$toform->idnumber = $questionobject->idnumber;
} else {
$toform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
}
if ($makecopy) {
$toform->idnumber = core_question_find_next_unused_idnumber($toform->idnumber, $category->id);
}
if ($cm !== null) {
$toform->cmid = $cm->id;
$toform->courseid = $cm->course;
@ -236,7 +249,13 @@ if ($mform->is_cancelled()) {
// If we are saving as a copy, break the connection to the old question.
if ($makecopy) {
$question->id = 0;
$question->hidden = 0; // Copies should not be hidden.
// Copies should not be hidden.
$question->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
}
// If is will be added directly to a module send the module name to be referenced.
if ($appendqnumstring && $cm) {
$fromform->modulename = 'mod_' . $cm->modname;
}
// Process the combination of usecurrentcat, categorymoveto and category form
@ -248,7 +267,7 @@ if ($mform->is_cancelled()) {
// If we are moving a question, check we have permission to move it from
// whence it came (Where we are moving to is validated by the form).
list($newcatid, $newcontextid) = explode(',', $fromform->category);
if (!empty($question->id) && $newcatid != $question->category) {
if (!empty($question->id) && $newcatid != $question->categoryobject->id) {
$contextid = $newcontextid;
question_require_capability_on($question, 'move');
} else {

View file

@ -1,33 +1,32 @@
{{!
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/>.
}}
{{!
@template qbank_viewcreator/modifier_display
@template qbank_editquestion/question_info
Example context (json):
{
"displaydata": [
{
"modifier": "Marc Ghaly",
"date": "2 June 2021, 5:32 PM",
}
"elements": [
{"pluginhtml":"<div>Version: 1</div>"},
{"pluginhtml":"<div>Usage: 1</div>"}
]
}
}}
<span class="qbank-creator-name">
{{modifier}}
</span>
<br>
<span class="date">
{{date}}
</span>
<div class="question-edit-elements">
{{#editelements}}
{{{pluginhtml}}}
{{/editelements}}
</div>

View file

@ -0,0 +1,39 @@
@qbank @qbank_editquestion
Feature: Use the qbank plugin manager page for editquestion
In order to check the plugin behaviour with enable and disable
Background:
Given the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "activities" exist:
| activity | name | course | idnumber |
| quiz | Test quiz | C1 | quiz1 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | truefalse | First question | Answer the first question |
| Test questions | truefalse | First question second | Answer the first question |
Scenario: Enable/disable edit question columns from the base view
Given I log in as "admin"
When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration
And I should see "Edit question"
And I click on "Disable" "link" in the "Edit question" "table_row"
And I am on the "Test quiz" "quiz activity" page
And I navigate to "Question bank > Questions" in current page administration
Then I should not see "Status"
And I click on ".dropdown-toggle" "css_element" in the "First question" "table_row"
And I should not see "Edit question" in the "region-main" "region"
And I should not see "Duplicate" in the "region-main" "region"
And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration
And I click on "Enable" "link" in the "Edit question" "table_row"
And I am on the "Test quiz" "quiz activity" page
And I navigate to "Question bank > Questions" in current page administration
And I click on ".dropdown-toggle" "css_element" in the "First question" "table_row"
Then I should see "Status"
And I click on ".dropdown-toggle" "css_element" in the "First question" "table_row"
And I should see "Edit question" in the "region-main" "region"
And I should see "Duplicate" in the "region-main" "region"

View file

@ -0,0 +1,31 @@
@qbank @qbank_editquestion
Feature: Use the qbank base view to test the status change using
the pop up
Background:
Given the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "activities" exist:
| activity | name | course | idnumber |
| quiz | Test quiz | C1 | quiz1 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | truefalse | First question | Answer the first question |
@javascript
Scenario: Question status modal should change the status of the question
Given I log in as "admin"
And I am on the "Test quiz" "quiz activity" page
And I navigate to "Question bank > Questions" in current page administration
And I set the field "Select a category" to "Test questions"
And I should see "Test questions"
And I should see "Ready" in the "First question" "table_row"
When I click on "Ready" "link" in the "First question" "table_row"
Then I should see "Change question status"
And I should see "Question status"
And I click on "Close" "button" in the ".modal-dialog" "css_element"
And I should see "Ready" in the "First question" "table_row"

View file

@ -0,0 +1,91 @@
<?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 qbank_editquestion;
use qbank_editquestion\external\update_question_version_status;
/**
* Submit status external api test.
*
* @package qbank_editquestion
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @coversDefaultClass \core_question\local\bank\question_version_status
* @coversDefaultClass \qbank_editquestion\form\question_status_form
* @coversDefaultClass \qbank_editquestion\editquestion_helper
*/
class update_question_version_status_test extends \advanced_testcase {
/**
* Called before every test.
*/
public function setUp(): void {
global $USER;
parent::setUp();
$this->setAdminUser();
$this->course = $this->getDataGenerator()->create_course();
$this->user = $USER;
}
/**
* Test if the submit status webservice changes the status of the question.
*
* @covers ::mock_generate_submit_keys
* @covers ::execute
* @covers ::get_question_status_string
*/
public function test_submit_status_updates_the_question_status() {
global $DB;
$this->resetAfterTest();
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$numq = $questiongenerator->create_question('essay', null,
['category' => $cat->id, 'name' => 'This is the first version']);
$data = ['status' => 2];
$mform = \qbank_editquestion\form\question_status_form::mock_generate_submit_keys($data);
$this->expectException('moodle_exception');
list($result, $statusname) = update_question_version_status::execute($numq->id, http_build_query($mform, '', '&'));
// Test if the version actually changed.
$currentstatus = $DB->get_record('question_versions', ['questionid' => $numq->id]);
$this->assertEquals($data['status'], $currentstatus->status);
$this->assertEquals(editquestion_helper::get_question_status_string($currentstatus->status), $statusname);
}
/**
* Test that updating the status does not create a new version.
*
* @covers ::mock_generate_submit_keys
* @covers ::execute
*/
public function test_submit_status_does_not_create_a_new_version() {
global $DB;
$this->resetAfterTest();
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$numq = $questiongenerator->create_question('essay', null,
['category' => $cat->id, 'name' => 'This is the first version']);
$countcurrentrecords = $DB->count_records('question_versions');
$this->assertEquals(1, $countcurrentrecords);
$data = ['status' => 2];
$mform = \qbank_editquestion\form\question_status_form::mock_generate_submit_keys($data);
$this->expectException('moodle_exception');
list($result, $statusname) = update_question_version_status::execute($numq->id, http_build_query($mform, '', '&'));
$countafterupdate = $DB->count_records('question_versions');
$this->assertEquals($countcurrentrecords, $countafterupdate);
}
}

View file

@ -26,6 +26,6 @@
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'qbank_editquestion';
$plugin->version = 2021062800;
$plugin->version = 2021110800;
$plugin->requires = 2021052500;
$plugin->maturity = MATURITY_STABLE;

View file

@ -50,7 +50,7 @@ if ($cmid) {
require_sesskey();
// Load the necessary data.
$contexts = new question_edit_contexts($thiscontext);
$contexts = new core_question\local\bank\question_edit_contexts($thiscontext);
$questiondata = question_bank::load_question_data($questionid);
// Check permissions.

View file

@ -51,7 +51,7 @@ $category->context = $categorycontext;
// This page can be called without courseid or cmid in which case.
// We get the context from the category object.
if ($contexts === null) { // Need to get the course from the chosen category.
$contexts = new question_edit_contexts($categorycontext);
$contexts = new core_question\local\bank\question_edit_contexts($categorycontext);
$thiscontext = $contexts->lowest();
if ($thiscontext->contextlevel == CONTEXT_COURSE) {
require_login($thiscontext->instanceid, false);

View file

@ -92,6 +92,8 @@ if ($param->moveupcontext || $param->movedowncontext) {
$category->contextid = $param->tocontext;
$event = \core\event\question_category_moved::create_from_question_category_instance($category);
$event->trigger();
// Update the set_reference records when moving a category to a different context.
move_question_set_references($catid, $catid, $oldcat->contextid, $category->contextid);
$qcobject->update_category($catid, "{$newtopcat->id},{$param->tocontext}", $oldcat->name, $oldcat->info);
// The previous line does a redirect().
}
@ -102,7 +104,8 @@ if ($param->delete) {
}
helper::question_remove_stale_questions_from_category($param->delete);
$questionstomove = $DB->count_records("question", ["category" => $param->delete]);
$questionstomove = $DB->count_records('question_bank_entries', ['questioncategoryid' => $param->delete]);
// Second pass, if we still have questions to move, setup the form.
if ($questionstomove) {

View file

@ -17,6 +17,7 @@
namespace qbank_managecategories;
use context;
use core_question\local\bank\question_version_status;
use moodle_exception;
use html_writer;
@ -60,14 +61,19 @@ class helper {
public static function question_remove_stale_questions_from_category(int $categoryid): void {
global $DB;
$select = 'category = :categoryid AND (qtype = :qtype OR hidden = :hidden)';
$params = ['categoryid' => $categoryid, 'qtype' => 'random', 'hidden' => 1];
$questions = $DB->get_recordset_select("question", $select, $params, '', 'id');
$sql = "SELECT q.id
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE qbe.questioncategoryid = :categoryid
AND (q.qtype = :qtype OR qv.status = :status)";
$params = ['categoryid' => $categoryid, 'qtype' => 'random', 'status' => question_version_status::QUESTION_STATUS_HIDDEN];
$questions = $DB->get_records_sql($sql, $params);
foreach ($questions as $question) {
// The function question_delete_question does not delete questions in use.
question_delete_question($question->id);
}
$questions->close();
}
/**
@ -240,22 +246,41 @@ class helper {
* Get all the category objects, including a count of the number of questions in that category,
* for all the categories in the lists $contexts.
*
* @param mixed $contexts either a single contextid, or a comma-separated list of context ids.
* @param context $contexts
* @param string $sortorder used as the ORDER BY clause in the select statement.
* @param bool $top Whether to return the top categories or not.
* @param int $showallversions 1 to show all versions not only the latest.
* @return array of category objects.
* @throws \dml_exception
*/
public static function get_categories_for_contexts($contexts, string $sortorder = 'parent, sortorder, name ASC',
bool $top = false): array {
bool $top = false, int $showallversions = 0): array {
global $DB;
$topwhere = $top ? '' : 'AND c.parent <> 0';
return $DB->get_records_sql("
SELECT c.*, (SELECT count(1) FROM {question} q
WHERE c.id = q.category AND q.hidden='0' AND q.parent='0') AS questioncount
FROM {question_categories} c
WHERE c.contextid IN ($contexts) $topwhere
ORDER BY $sortorder");
$statuscondition = "AND (qv.status = '". question_version_status::QUESTION_STATUS_READY . "' " .
" OR qv.status = '" . question_version_status::QUESTION_STATUS_DRAFT . "' )";
$sql = "SELECT c.*,
(SELECT COUNT(1)
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE q.parent = '0'
$statuscondition
AND c.id = qbe.questioncategoryid
AND ($showallversions = 1
OR (qv.version = (SELECT MAX(v.version)
FROM {question_versions} v
JOIN {question_bank_entries} be ON be.id = v.questionbankentryid
WHERE be.id = qbe.id)
)
)
) AS questioncount
FROM {question_categories} c
WHERE c.contextid IN ($contexts) $topwhere
ORDER BY $sortorder";
return $DB->get_records_sql($sql);
}
/**

View file

@ -328,8 +328,15 @@ class question_category_object {
*/
public function move_questions(int $oldcat, int $newcat): void {
global $DB;
$questionids = $DB->get_records_select_menu('question',
'category = ? AND (parent = 0 OR parent = id)', [$oldcat], '', 'id,1');
$sql = "SELECT q.id, 1
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE qbe.questioncategoryid = ?
AND (q.parent = 0 OR q.parent = q.id)";
$questionids = $DB->get_records_sql_menu($sql, [$oldcat]);
question_move_questions_to_category(array_keys($questionids), $newcat);
}
@ -479,14 +486,25 @@ class question_category_object {
// If the category name has changed, rename any random questions in that category.
if ($oldcat->name != $cat->name) {
$where = "qtype = 'random' AND category = ? AND " . $DB->sql_compare_text('questiontext') . " = ?";
// Get the question ids for each question category.
$sql = "SELECT q.id
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE qbe.questioncategoryid = ?";
$randomqtype = question_bank::get_qtype('random');
$randomqname = $randomqtype->question_name($cat, false);
$DB->set_field_select('question', 'name', $randomqname, $where, [$cat->id, '0']);
$questionids = $DB->get_records_sql($sql, [$cat->id]);
$randomqname = $randomqtype->question_name($cat, true);
$DB->set_field_select('question', 'name', $randomqname, $where, [$cat->id, '1']);
foreach ($questionids as $question) {
$where = "qtype = 'random' AND id = ? AND " . $DB->sql_compare_text('questiontext') . " = ?";
$randomqtype = question_bank::get_qtype('random');
$randomqname = $randomqtype->question_name($cat, false);
$DB->set_field_select('question', 'name', $randomqname, $where, [$question->id, '0']);
$randomqname = $randomqtype->question_name($cat, true);
$DB->set_field_select('question', 'name', $randomqname, $where, [$question->id, '1']);
}
}
if ($oldcat->contextid != $tocontextid) {

View file

@ -72,52 +72,73 @@ class helper_test extends \advanced_testcase {
$qcat1 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
$q1a = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcat1->id]); // Will be hidden.
$DB->set_field('question', 'hidden', 1, ['id' => $q1a->id]);
$DB->set_field('question_versions', 'status', 'hidden', ['questionid' => $q1a->id]);
$qcat2 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
$q2a = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcat2->id]); // Will be hidden.
$q2b = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcat2->id]); // Will be hidden but used.
$DB->set_field('question', 'hidden', 1, ['id' => $q2a->id]);
$DB->set_field('question', 'hidden', 1, ['id' => $q2b->id]);
$DB->set_field('question_versions', 'status', 'hidden', ['questionid' => $q2a->id]);
$DB->set_field('question_versions', 'status', 'hidden', ['questionid' => $q2b->id]);
quiz_add_quiz_question($q2b->id, $this->quiz);
// Adding a new random question does not add a new question, adds a question_set_references record.
quiz_add_random_questions($this->quiz, 0, $qcat2->id, 1, false);
// We added one random question to the quiz and we expect the quiz to have only one random question.
$q2d = $DB->get_record_sql("SELECT q.*
FROM {question} q
JOIN {quiz_slots} s ON s.questionid = q.id
WHERE q.qtype = :qtype
AND s.quizid = :quizid",
['qtype' => 'random', 'quizid' => $this->quiz->id], MUST_EXIST);
$q2d = $DB->get_record_sql("SELECT qsr.*
FROM {quiz_slots} qs
JOIN {question_set_references} qsr ON qsr.itemid = qs.id
WHERE qs.quizid = ?",
['quizid' => $this->quiz->id], MUST_EXIST);
// The following 2 lines have to be after the quiz_add_random_questions() call above.
// Otherwise, quiz_add_random_questions() will to be "smart" and use them instead of creating a new "random" question.
$q1b = $this->qgenerator->create_question('random', null, ['category' => $qcat1->id]); // Will not be used.
$q2c = $this->qgenerator->create_question('random', null, ['category' => $qcat2->id]); // Will not be used.
$this->assertEquals(2, $DB->count_records('question', ['category' => $qcat1->id]));
$this->assertEquals(4, $DB->count_records('question', ['category' => $qcat2->id]));
$sql = "SELECT count(q.id)
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE qbe.questioncategoryid = ?";
$this->assertEquals(2, $DB->count_records_sql($sql, [$qcat1->id]));
$this->assertEquals(3, $DB->count_records_sql($sql, [$qcat2->id]));
// Non-existing category, nothing will happen.
helper::question_remove_stale_questions_from_category(0);
$this->assertEquals(2, $DB->count_records('question', ['category' => $qcat1->id]));
$this->assertEquals(4, $DB->count_records('question', ['category' => $qcat2->id]));
$sql = "SELECT count(q.id)
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE qbe.questioncategoryid = ?";
$this->assertEquals(2, $DB->count_records_sql($sql, [$qcat1->id]));
$this->assertEquals(3, $DB->count_records_sql($sql, [$qcat2->id]));
// First category, should be empty afterwards.
helper::question_remove_stale_questions_from_category($qcat1->id);
$this->assertEquals(0, $DB->count_records('question', ['category' => $qcat1->id]));
$this->assertEquals(4, $DB->count_records('question', ['category' => $qcat2->id]));
$sql = "SELECT count(q.id)
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE qbe.questioncategoryid = ?";
$this->assertEquals(0, $DB->count_records_sql($sql, [$qcat1->id]));
$this->assertEquals(3, $DB->count_records_sql($sql, [$qcat2->id]));
$this->assertFalse($DB->record_exists('question', ['id' => $q1a->id]));
$this->assertFalse($DB->record_exists('question', ['id' => $q1b->id]));
// Second category, used questions should be left untouched.
helper::question_remove_stale_questions_from_category($qcat2->id);
$this->assertEquals(0, $DB->count_records('question', ['category' => $qcat1->id]));
$this->assertEquals(2, $DB->count_records('question', ['category' => $qcat2->id]));
$sql = "SELECT count(q.id)
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE qbe.questioncategoryid = ?";
$this->assertEquals(0, $DB->count_records_sql($sql, [$qcat1->id]));
$this->assertEquals(1, $DB->count_records_sql($sql, [$qcat2->id]));
$this->assertFalse($DB->record_exists('question', ['id' => $q2a->id]));
$this->assertTrue($DB->record_exists('question', ['id' => $q2b->id]));
$this->assertFalse($DB->record_exists('question', ['id' => $q2c->id]));
$this->assertTrue($DB->record_exists('question', ['id' => $q2d->id]));
$this->assertTrue($DB->record_exists('question_set_references', ['id' => $q2d->id]));
}
/**
@ -184,7 +205,7 @@ class helper_test extends \advanced_testcase {
public function test_question_category_select_menu() {
$this->qgenerator->create_question_category(['contextid' => $this->context->id, 'name' => 'Test this question category']);
$contexts = new \question_edit_contexts($this->context);
$contexts = new \core_question\local\bank\question_edit_contexts($this->context);
ob_start();
helper::question_category_select_menu($contexts->having_cap('moodle/question:add'));
@ -210,7 +231,7 @@ class helper_test extends \advanced_testcase {
$qcategory2 = $this->qgenerator->create_question_category(['contextid' => $this->context->id, 'parent' => $qcategory1->id]);
$qcategory3 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
$contexts = new \question_edit_contexts($this->context);
$contexts = new \core_question\local\bank\question_edit_contexts($this->context);
// Validate that we have the array with the categories tree.
$categorycontexts = helper::question_category_options($contexts->having_cap('moodle/question:add'));

View file

@ -25,7 +25,7 @@ use context;
use context_course;
use context_module;
use moodle_url;
use question_edit_contexts;
use core_question\local\bank\question_edit_contexts;
use stdClass;
/**

View file

@ -1,2 +1,2 @@
define ("qbank_previewquestion/preview",["exports","jquery"],function(a,b){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;b=function(a){return a&&a.__esModule?a:{default:a}}(b);a.init=function init(a){if(!a){var b=document.getElementById("close-previewquestion-page");b.onclick=function(){window.close()}}c("responseform")};var c=function(a){var b=document.getElementById(a);if(b){d(b);e(b);f(".questionflagsavebutton",b);g(b)}},d=function(a){a.setAttribute("autocomplete","off")},e=function(a){a.addEventListener("submit",function(){(0,b.default)(this).submit(function(){return!1});return!0})},f=function(a,b){b.querySelectorAll(a).forEach(function(a){return a.remove()})},g=function(a){var b=window.location.href.match(/^.*[?&]scrollpos=(\d*)(?:&|$|#).*$/,"$1");if(b){window.scrollTo(0,b[1]);a.addEventListener("DOMContentLoaded",function(){window.scrollTo(0,b[1])})}}});
define ("qbank_previewquestion/preview",["exports","jquery"],function(a,b){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;b=function(a){return a&&a.__esModule?a:{default:a}}(b);a.init=function init(a,b){if(!a){var d=document.getElementById("close-previewquestion-page");d.onclick=function(){if(null===window.opener){location.href=b}else{window.close()}}}c("responseform")};var c=function(a){var b=document.getElementById(a);if(b){d(b);e(b);f(".questionflagsavebutton",b);g(b)}},d=function(a){a.setAttribute("autocomplete","off")},e=function(a){a.addEventListener("submit",function(){(0,b.default)(this).submit(function(){return!1});return!0})},f=function(a,b){b.querySelectorAll(a).forEach(function(a){return a.remove()})},g=function(a){var b=window.location.href.match(/^.*[?&]scrollpos=(\d*)(?:&|$|#).*$/,"$1");if(b){window.scrollTo(0,b[1]);a.addEventListener("DOMContentLoaded",function(){window.scrollTo(0,b[1])})}}});
//# sourceMappingURL=preview.min.js.map

File diff suppressed because one or more lines are too long

View file

@ -29,12 +29,17 @@ import $ from 'jquery';
*
* @method init
* @param {bool} redirect Redirect.
* @param {string} url url to redirect.
*/
export const init = (redirect) => {
export const init = (redirect, url) => {
if (!redirect) {
let closeButton = document.getElementById('close-previewquestion-page');
closeButton.onclick = () => {
window.close();
if (window.opener === null) {
location.href = url;
} else {
window.close();
}
};
}
// Set up the form to be displayed.

View file

@ -42,8 +42,12 @@ class preview_options_form extends moodleform {
question_display_options::VISIBLE => get_string('shown', 'question'),
];
$mform->addElement('header', 'attemptoptionsheader', get_string('attemptoptions', 'question'));
$mform->addElement('header', 'attemptoptionsheader', get_string('previewoptions', 'qbank_previewquestion'));
$mform->setExpanded('attemptoptionsheader', false);
$versions = $this->_customdata['versions'];
$currentversion = $this->_customdata['questionversion'];
$select = $mform->addElement('select', 'version', get_string('questionversion', 'qbank_previewquestion'), $versions);
$select->setSelected($currentversion);
$behaviours = question_engine::get_behaviour_options(
$this->_customdata['quba']->get_preferred_behaviour());
$mform->addElement('select', 'behaviour',
@ -63,6 +67,7 @@ class preview_options_form extends moodleform {
get_string('restartwiththeseoptions', 'question'));
$mform->addElement('header', 'displayoptionsheader', get_string('displayoptions', 'question'));
$mform->setExpanded('displayoptionsheader', false);
$mform->addElement('select', 'correctness', get_string('whethercorrect', 'question'),
$hiddenorvisible);

View file

@ -16,8 +16,21 @@
namespace qbank_previewquestion;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/question/editlib.php');
use action_menu;
use comment;
use context_module;
use context;
use core\plugininfo\qbank;
use core_question\local\bank\edit_menu_column;
use core_question\local\bank\view;
use core_question\local\bank\question_edit_contexts;
use moodle_url;
use question_bank;
use question_definition;
use question_display_options;
use question_engine;
use stdClass;
@ -44,7 +57,7 @@ class helper {
* @param int $slot the relevant slot within the usage.
* @param array $args the remaining bits of the file path.
* @param bool $forcedownload whether the user must be forced to download the file.
* @param array $fileoptions
* @param array $fileoptions options for the stored files
* @return void false if file not found, does not return if found - justsend the file
*/
public static function question_preview_question_pluginfile($course, $context, $component,
@ -85,11 +98,11 @@ class helper {
/**
* The the URL to use for actions relating to this preview.
*
* @param int $questionid the question being previewed.
* @param int $qubaid the id of the question usage for this preview.
* @param question_preview_options $options the options in use.
* @param context $context
* @param moodle_url $returnurl
* @param int $questionid the question being previewed
* @param int $qubaid the id of the question usage for this preview
* @param question_preview_options $options the options in use
* @param context $context context for the question preview
* @param moodle_url $returnurl url of the page to return to
* @return moodle_url
*/
public static function question_preview_action_url($questionid, $qubaid,
@ -112,10 +125,11 @@ class helper {
/**
* The the URL to use for actions relating to this preview.
* @param int $questionid the question being previewed.
* @param context $context the current moodle context.
* @param int $previewid optional previewid to sign post saved previewed answers.
* @param moodle_url $returnurl
*
* @param int $questionid the question being previewed
* @param context $context the current moodle context
* @param int $previewid optional previewid to sign post saved previewed answers
* @param moodle_url $returnurl url of the page to return to
* @return moodle_url
*/
public static function question_preview_form_url($questionid, $context, $previewid = null, $returnurl = null): moodle_url {
@ -138,13 +152,16 @@ class helper {
/**
* Delete the current preview, if any, and redirect to start a new preview.
* @param int $previewid
* @param int $questionid
* @param object $displayoptions
* @param object $context
* @param moodle_url $returnurl
*
* @param int $previewid id of the preview while restarting it
* @param int $questionid id of the question in preview
* @param object $displayoptions display options for the question in preview
* @param object $context context of the question for preview
* @param moodle_url $returnurl url of the page to return to
* @param int|null $version version of the question in preview
*/
public static function restart_preview($previewid, $questionid, $displayoptions, $context, $returnurl = null): void {
public static function restart_preview($previewid, $questionid, $displayoptions, $context,
$returnurl = null, $version = null): void {
global $DB;
if ($previewid) {
@ -153,27 +170,33 @@ class helper {
$transaction->allow_commit();
}
redirect(self::question_preview_url($questionid, $displayoptions->behaviour,
$displayoptions->maxmark, $displayoptions, $displayoptions->variant, $context, $returnurl));
$displayoptions->maxmark, $displayoptions, $displayoptions->variant, $context, $returnurl, $version));
}
/**
* Generate the URL for starting a new preview of a given question with the given options.
* @param integer $questionid the question to preview.
* @param string $preferredbehaviour the behaviour to use for the preview.
* @param float $maxmark the maximum to mark the question out of.
* @param question_display_options $displayoptions the display options to use.
*
* @param integer $questionid the question to preview
* @param string $preferredbehaviour the behaviour to use for the preview
* @param float $maxmark the maximum to mark the question out of
* @param question_display_options $displayoptions the display options to use
* @param int $variant the variant of the question to preview. If null, one will
* be picked randomly.
* be picked randomly
* @param object $context context to run the preview in (affects things like
* filter settings, theme, lang, etc.) Defaults to $PAGE->context.
* @param moodle_url $returnurl
* @return moodle_url the URL.
* filter settings, theme, lang, etc.) Defaults to $PAGE->context
* @param moodle_url $returnurl url of the page to return to
* @param int $version version of the question
* @return moodle_url the URL
*/
public static function question_preview_url($questionid, $preferredbehaviour = null,
$maxmark = null, $displayoptions = null, $variant = null, $context = null, $returnurl = null): moodle_url {
$maxmark = null, $displayoptions = null, $variant = null, $context = null, $returnurl = null,
$version = null): moodle_url {
$params = ['id' => $questionid];
if (!is_null($version)) {
$params['id'] = $version;
}
if (is_null($context)) {
global $PAGE;
$context = $PAGE->context;
@ -215,6 +238,7 @@ class helper {
/**
* Popup params for the question preview.
*
* @return array that can be passed as $params to the {@see popup_action} constructor.
*/
public static function question_preview_popup_params(): array {
@ -227,11 +251,11 @@ class helper {
/**
* Get the extra elements for preview from qbank plugins.
*
* @param \question_definition $question
* @param int $courseid
* @param question_definition $question question definition object
* @param int $courseid id of the course
* @return array
*/
public static function get_preview_extra_elements(\question_definition $question, int $courseid): array {
public static function get_preview_extra_elements(question_definition $question, int $courseid): array {
$plugintype = 'qbank';
$functionname = 'preview_display';
$extrahtml = [];
@ -247,4 +271,46 @@ class helper {
}
return [$comment, $extrahtml];
}
/**
* Checks if question is the latest version.
*
* @param string $version Question version to check
* @param string $questionbankentryid Entry to check against
* @return bool
*/
public static function is_latest(string $version, string $questionbankentryid) : bool {
global $DB;
$sql = 'SELECT MAX(version) AS max
FROM {question_versions}
WHERE questionbankentryid = ?';
$latestversion = $DB->get_record_sql($sql, [$questionbankentryid]);
if (isset($latestversion->max)) {
return ($version === $latestversion->max) ? true : false;
}
return false;
}
/**
* Loads question version ids for current question.
*
* @param string $questionbankentryid Question bank entry id
* @return array $questionids Array containing question id as key and version as value.
*/
public static function load_versions(string $questionbankentryid) : array {
global $DB;
$questionids = [];
$sql = 'SELECT version, questionid
FROM {question_versions}
WHERE questionbankentryid = ?';
$versions = $DB->get_records_sql($sql, [$questionbankentryid]);
foreach ($versions as $key => $version) {
$questionids[$version->questionid] = $key;
}
return $questionids;
}
}

View file

@ -25,3 +25,14 @@
$string['pluginname'] = 'Preview question';
$string['privacy:metadata'] = 'The Preview question question bank plugin does not store any personal data.';
// Tag related errors.
$string['tagclosebutton'] = 'Close';
$string['tagerror'] = 'No question was found with following tags: {$a}. Please change or remove tags filtering.';
$string['tagsnotfound'] = 'Tags not found';
// Form string(s).
$string['previewoptions'] = 'Preview options';
$string['questionversion'] = 'Question version';
// Preview title.
$string['versiontitle'] = 'Version {$a}';
$string['versiontitlelatest'] = 'Version {$a} (latest)';

View file

@ -31,6 +31,7 @@
require_once(__DIR__ . '/../../../config.php');
require_once($CFG->libdir . '/questionlib.php');
use \core\notification;
use qbank_previewquestion\form\preview_options_form;
use qbank_previewquestion\question_preview_options;
use qbank_previewquestion\helper;
@ -48,6 +49,7 @@ define('QUESTION_PREVIEW_MAX_VARIANTS', 100);
// Get and validate question id.
$id = required_param('id', PARAM_INT);
$returnurl = optional_param('returnurl', null, PARAM_RAW);
$question = question_bank::load_question($id);
if ($returnurl) {
@ -132,8 +134,10 @@ $options->behaviour = $quba->get_preferred_behaviour();
$options->maxmark = $quba->get_question_max_mark($slot);
// Create the settings form, and initialise the fields.
$optionsform = new preview_options_form(helper::question_preview_form_url($question->id, $context, $previewid, $returnurl),
['quba' => $quba, 'maxvariant' => $maxvariant]);
$versionids = helper::load_versions($question->questionbankentryid);
$optionsform = new preview_options_form(helper::
question_preview_form_url($question->id, $context, $previewid, $returnurl),
['quba' => $quba, 'maxvariant' => $maxvariant, 'versions' => $versionids, 'questionversion' => $id]);
$optionsform->set_data($options);
// Process change of settings, if that was requested.
@ -144,7 +148,7 @@ if ($newoptions = $optionsform->get_submitted_data()) {
$newoptions->variant = $options->variant;
}
if (isset($newoptions->saverestart)) {
helper::restart_preview($previewid, $question->id, $newoptions, $context, $returnurl);
helper::restart_preview($previewid, $question->id, $newoptions, $context, $returnurl, $newoptions->version);
}
}
@ -249,6 +253,17 @@ $PAGE->set_heading($title);
echo $OUTPUT->header();
$previewdata = [];
$previewdata['questionicon'] = print_question_icon($question);
$previewdata['questionidumber'] = $question->idnumber;
$previewdata['questiontitle'] = $question->name;
$islatestversion = is_latest($question->version, $question->questionbankentryid);
if ($islatestversion) {
$previewdata['versiontitle'] = get_string('versiontitlelatest', 'qbank_previewquestion', $question->version);
} else {
$previewdata['versiontitle'] = get_string('versiontitle', 'qbank_previewquestion', $question->version);
}
$previewdata['actionurl'] = $actionurl;
$previewdata['session'] = sesskey();
$previewdata['slot'] = $slot;
@ -265,21 +280,6 @@ foreach ($technical as $info) {
}
$previewdata['techinfo'] .= print_collapsible_region_end(true);
// Output a link to export this single question.
if (question_has_capability_on($question, 'view')) {
if (class_exists('qbank_exporttoxml\\helper')) {
if (\core\plugininfo\qbank::is_plugin_enabled('qbank_exporttoxml')) {
$exportfunction = '\\qbank_exporttoxml\\helper::question_get_export_single_question_url';
$previewdata['exporttoxml'] = html_writer::link($exportfunction($question),
get_string('exportonequestion', 'question'));
}
} else {
$exportfunction = 'question_get_export_single_question_url';
$previewdata['exporttoxml'] = html_writer::link($exportfunction($question),
get_string('exportonequestion', 'question'));
}
}
// Display the settings form.
$previewdata['options'] = $optionsform->render();
@ -304,16 +304,12 @@ if (!is_null($returnurl)) {
$previewdata['redirect'] = true;
$previewdata['redirecturl'] = $returnurl;
}
$closeurl = new moodle_url('/question/edit.php', ['courseid' => $COURSE->id]);
echo $PAGE->get_renderer('qbank_previewquestion')->render_preview_page($previewdata);
// Log the preview of this question.
$event = \core\event\question_viewed::create_from_question_instance($question, $context);
$event->trigger();
$PAGE->requires->js_call_amd('qbank_previewquestion/preview', 'init', [$previewdata['redirect']]);
$PAGE->requires->js_call_amd('core_form/changechecker', 'watchFormById', ['responseform']);
$PAGE->requires->js_call_amd('core_form/submit', 'init', ['id_save_question_preview']);
$PAGE->requires->js_call_amd('core_form/submit', 'init', ['id_finish_question_preview']);
$PAGE->requires->js_call_amd('core_form/submit', 'init', ['id_restart_question_preview']);
$PAGE->requires->js_call_amd('qbank_previewquestion/preview', 'init', [$previewdata['redirect'], $closeurl->__toString()]);
echo $OUTPUT->footer();

View file

@ -22,6 +22,10 @@
* session - Moodle session
* slot - The identifying number of the first question that was added to this usage
* question - The html of the actual question from the engine
* questionicon - The icon of the question type
* questiontitle - The name of the question
* versiontitle - The string for displaying the version
* questionidumber - The idnumber of the question
* restartdisabled - The attributes to enable or disable the button, same for finishdisabled and filldisabled
* techinfo - Technical information like fraction, state, behaviour etc
* exporttoxml - Link to export the question to xml
@ -31,25 +35,39 @@
Example context (json):
{
"previewdata": [
{
"actionurl": "/",
"session": "E2PwCfrnzz",
"slot": "1",
"question": "<div>question html</div>",
"restartdisabled": "disabled='disabled'",
"finishdisabled": "disabled='disabled'",
"filldisabled": "disabled='disabled'",
"techinfo": "<div>Behaviour being used: Deferred feedback</div>",
"redirecturl": "/",
"exporttoxml": "Download this question in Moodle XML format",
"comments": "html from comments api",
"extrapreviewelements": "<div>callback to get html from plugins need to show info in preview</div>"
}
]
"actionurl": "/",
"session": "E2PwCfrnzz",
"slot": "1",
"question": "<div>question html</div>",
"questionicon": "<i class='icon fa fa-search-plus fa-fw' title='Preview question' aria-label='Preview question'></i>",
"questiontitle": "Question title",
"versiontitle": "Version 3 (latest)",
"questionidumber": "qidnumber1",
"restartdisabled": "disabled='disabled'",
"finishdisabled": "disabled='disabled'",
"filldisabled": "disabled='disabled'",
"techinfo": "<div>Behaviour being used: Deferred feedback</div>",
"redirecturl": "/",
"exporttoxml": "Download this question in Moodle XML format",
"comments": "html from comments api",
"extrapreviewelements": "<div>callback to get html from plugins need to show info in preview</div>"
}
}}
<form id="responseform" method="post" action="{{{actionurl}}}" enctype="multipart/form-data" autocomplete="off">
<div class="d-flex">
<h2 class="mt-2">{{{questionicon}}}</h2>
<h2 class="ml-2 mt-2"> {{questiontitle}}</h2>
<h3 class="px-2 py-1 ml-2 mt-2">
<span class="badge bg-primary text-light">{{versiontitle}}</span>
</h3>
</div>
<div class="d-flex">
<div class="badge-primary h-50 px-2 mt-n2">
<span class="accesshide">ID number</span>
{{questionidumber}}
</div>
</div>
<br>
<div>
<input type="hidden" name="sesskey" value="{{session}}">
<input type="hidden" name="slots" value="{{slot}}">
@ -69,21 +87,21 @@
{{/redirect}}
</div>
</form>
{{{techinfo}}}
{{{exporttoxml}}}
<br>
{{#comments}}
<div class="row">
<div class="col-6 text-left">
{{{options}}}
</div>
<div class="col-6 question-comment-view">
{{{comments}}}
</div>
<a data-toggle="collapse" href="#commentcollapse" role="button" aria-expanded="false" aria-controls="commentcollapse">
{{#pix}} t/collapsed, core {{/pix}}
{{#str}} commentplural, qbank_comment{{/str}}
</a>
<div class="collapse" id="commentcollapse">
{{{comments}}}
</div>
{{{options}}}
{{/comments}}
{{^comments}}
{{{options}}}
{{/comments}}
{{{techinfo}}}
{{#extrapreviewelements}}
{{{extrapreviewelements}}}
{{/extrapreviewelements}}

View file

@ -30,10 +30,19 @@ Feature: A teacher can preview questions in the question bank
Scenario: Question preview shows the question and other information
Then the state of "What is pi to two d.p.?" question is shown as "Not yet answered"
And I should see "(latest)"
And I should see "Marked out of 1.00"
And I should see "Technical information"
And I should see "Attempt options"
And I should see "Display options"
And I should see "Preview options"
And I should see "Comments"
And I click on "Comments" "link"
And I should see "Save comment"
And I should see "ID number"
And "Numerical" "icon" should exist
And I should see "Version"
And I click on "Preview options" "link"
And I should see "Question version"
Scenario: Preview lets the teacher see what happens when an answer is saved
When I set the field "Answer:" to "1"
@ -48,14 +57,18 @@ Feature: A teacher can preview questions in the question bank
Scenario: Preview lets the teacher see what happens with different review options
Given I set the field "Answer:" to "3.14"
And I press "Submit and finish"
And I press "Display options"
When I set the field "Whether correct" to "Not shown"
And I set the field "Decimal places in grades" to "5"
And I press "Update display options"
And I set the field "Answer:" to "3.14"
And I press "Submit and finish"
Then the state of "What is pi to two d.p.?" question is shown as "Complete"
And I should see "1.00000"
Scenario: Preview lets the teacher see what happens with different behaviours
When I set the field "How questions behave" to "Immediate feedback"
When I press "Preview options"
And I set the field "How questions behave" to "Immediate feedback"
And I set the field "Marked out of" to "3"
And I press "Start again with these options"
And I set the field "Answer:" to "3.1"
@ -74,10 +87,28 @@ Feature: A teacher can preview questions in the question bank
When I press "Fill in correct responses"
Then the field "Answer:" matches value "3.14"
Scenario: Preview has an option to export the individual quesiton.
Then following "Download this question in Moodle XML format" should download between "1000" and "2500" bytes
Scenario: Preview a question with very small grade
When I set the field "Marked out of" to "0.00000123456789"
When I press "Preview options"
And I set the field "Marked out of" to "0.00000123456789"
And I press "Start again with these options"
Then the field "Marked out of" matches value "0.00000123456789"
Scenario: Question version is updated when edited and teacher can change question version
And I should see "Version 1"
And I press "Close preview"
And I choose "Edit question" action for "Test question to be previewed" in the question bank
And I set the field "Question name" to "New version"
And I set the field "Question text" to "New text version"
And I click on "submitbutton" "button"
And I choose "Preview" action for "New version" in the question bank
Then I should see "Version 2"
And I should see "(latest)"
And I should see "New version"
And I should see "New text version"
And I should not see "Test question to be previewed"
And I should not see "Version 1"
Scenario: Question preview can be closed
And I press "Close preview"
Then I should not see "(latest)"
And I should see "Course 1"

View file

@ -16,7 +16,12 @@
namespace qbank_previewquestion;
use context_course;
use moodle_url;
use core\plugininfo\qbank;
use question_bank;
use question_engine;
use stdClass;
/**
* Helper tests for question preview.
@ -67,17 +72,18 @@ class helper_test extends \advanced_testcase {
$questiongenerator = $generator->get_plugin_generator('core_question');
// Create a course.
$course = $generator->create_course();
$this->context = \context_course::instance($course->id);
$this->context = context_course::instance($course->id);
// Create a question in the default category.
$contexts = new \question_edit_contexts($this->context);
$contexts = new \core_question\local\bank\question_edit_contexts($this->context);
$cat = question_make_default_categories($contexts->all());
$this->questiondata = $questiongenerator->create_question('numerical', null,
['name' => 'Example question', 'category' => $cat->id]);
$this->quba = \question_engine::make_questions_usage_by_activity('core_question_preview', \context_user::instance($USER->id));
$this->quba = question_engine::make_questions_usage_by_activity('core_question_preview',
\context_user::instance($USER->id));
$this->options = new question_preview_options($this->questiondata);
$this->options->load_user_defaults();
$this->options->set_from_request();
$this->returnurl = new \moodle_url('/question/edit.php');
$this->returnurl = new moodle_url('/question/edit.php');
}
/**
@ -95,7 +101,7 @@ class helper_test extends \advanced_testcase {
'courseid' => $this->context->instanceid
];
$params = array_merge($params, $this->options->get_url_params());
$expectedurl = new \moodle_url('/question/bank/previewquestion/preview.php', $params);
$expectedurl = new moodle_url('/question/bank/previewquestion/preview.php', $params);
$this->assertEquals($expectedurl, $actionurl);
}
@ -112,7 +118,7 @@ class helper_test extends \advanced_testcase {
'returnurl' => $this->returnurl,
'courseid' => $this->context->instanceid
];
$expectedurl = new \moodle_url('/question/bank/previewquestion/preview.php', $params);
$expectedurl = new moodle_url('/question/bank/previewquestion/preview.php', $params);
$this->assertEquals($expectedurl, $formurl);
}
@ -138,7 +144,7 @@ class helper_test extends \advanced_testcase {
$params['generalfeedback'] = (bool) $this->options->generalfeedback;
$params['rightanswer'] = (bool) $this->options->rightanswer;
$params['history'] = (bool) $this->options->history;
$expectedurl = new \moodle_url('/question/bank/previewquestion/preview.php', $params);
$expectedurl = new moodle_url('/question/bank/previewquestion/preview.php', $params);
$this->assertEquals($expectedurl, $previewurl);
}
@ -159,4 +165,27 @@ class helper_test extends \advanced_testcase {
$this->assertEquals('', $comment);
}
}
/**
* Test method load_versions().
*
* @covers ::load_versions
*/
public function test_load_versions() {
global $DB;
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$qcat1 = $generator->create_question_category(['name' => 'My category', 'sortorder' => 1, 'idnumber' => 'myqcat']);
$questiongenerated = $generator->create_question('description', null, ['name' => 'q1', 'category' => $qcat1->id]);
$qtypeobj = question_bank::get_qtype($questiongenerated->qtype);
$question = question_bank::load_question($questiongenerated->id);
$versionids = helper::load_versions($question->questionbankentryid);
$this->assertCount(1, $versionids);
$fromform = new stdClass();
$fromform->name = 'Name edited';
$fromform->category = $qcat1->id;
$qtypeobj->save_question($questiongenerated, $fromform);
$versionids = helper::load_versions($question->questionbankentryid);
$this->assertCount(2, $versionids);
}
}

View file

@ -79,15 +79,17 @@ class submit_tags extends external_api {
if (!$question = $DB->get_record_sql('
SELECT q.*, qc.contextid
FROM {question} q
JOIN {question_categories} qc ON qc.id = q.category
WHERE q.id = ?', [$params['questionid']])) {
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE q.id = ?', [$questionid])) {
throw new \moodle_exception('questiondoesnotexist', 'question');
}
$cantag = question_has_capability_on($question, 'tag');
$questioncontext = \context::instance_by_id($question->contextid);
$contexts = new \question_edit_contexts($editingcontext);
$contexts = new \core_question\local\bank\question_edit_contexts($editingcontext);
$formoptions = [
'editingcontext' => $editingcontext,

View file

@ -53,9 +53,15 @@ function qbank_tagquestion_output_fragment_tags_form($args) {
$filtercourses = null;
}
$category = $DB->get_record('question_categories', ['id' => $question->category]);
$sql = "SELECT qc.*
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE q.id = :id";
$category = $DB->get_record_sql($sql, ['id' => $question->id]);
$questioncontext = \context::instance_by_id($category->contextid);
$contexts = new \question_edit_contexts($editingcontext);
$contexts = new \core_question\local\bank\question_edit_contexts($editingcontext);
// Load the question tags and filter the course tags by the current course.
if (core_tag_tag::is_enabled('core_question', 'question')) {

View file

@ -37,7 +37,7 @@ class helper {
$sql = 'SELECT COUNT(*) FROM (' . self::question_usage_sql() . ') quizid';
return $DB->count_records_sql($sql, [$question->id, $question->id]);
return $DB->count_records_sql($sql, [$question->id, $question->questionbankentryid]);
}
/**
@ -58,11 +58,14 @@ class helper {
AND q.id = ?)
UNION
(SELECT qz.id as quizid,
qz.name as modulename,
qz.course as courseid
qz.name as modulename,
qz.course as courseid
FROM {quiz_slots} slot
JOIN {quiz} qz ON qz.id = slot.quizid
WHERE slot.questionid = ?)";
JOIN {question_references} qr ON qr.itemid = slot.id
JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid
JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id
WHERE qv.questionbankentryid = ?)";
return $sqlset;
}
@ -73,18 +76,31 @@ class helper {
* @param int $quizid
* @return int
*/
public static function get_question_attempts_count_in_quiz(int $questionid, int $quizid): int {
public static function get_question_attempts_count_in_quiz(int $questionid, $quizid = null): int {
global $DB;
$sql = 'SELECT COUNT(qatt.id)
FROM {quiz} qz
JOIN {quiz_attempts} qa ON qa.quiz = qz.id
JOIN {question_usages} qu ON qu.id = qa.uniqueid
JOIN {question_attempts} qatt ON qatt.questionusageid = qu.id
JOIN {question} q ON q.id = qatt.questionid
WHERE qatt.questionid = :questionid
AND qa.preview = 0
AND qz.id = :quizid';
return $DB->count_records_sql($sql, [ 'questionid' => $questionid, 'quizid' => $quizid]);
if ($quizid) {
$sql = 'SELECT COUNT(qatt.id)
FROM {quiz} qz
JOIN {quiz_attempts} qa ON qa.quiz = qz.id
JOIN {question_usages} qu ON qu.id = qa.uniqueid
JOIN {question_attempts} qatt ON qatt.questionusageid = qu.id
JOIN {question} q ON q.id = qatt.questionid
WHERE qatt.questionid = :questionid
AND qa.preview = 0
AND qz.id = :quizid';
$param = ['questionid' => $questionid, 'quizid' => $quizid];
} else {
$sql = 'SELECT COUNT(qatt.id)
FROM {quiz_slots} qs
JOIN {quiz_attempts} qa ON qa.quiz = qs.quizid
JOIN {question_usages} qu ON qu.id = qa.uniqueid
JOIN {question_attempts} qatt ON qatt.questionusageid = qu.id
JOIN {question} q ON q.id = qatt.questionid
WHERE qatt.questionid = ?
AND qa.preview = 0';
$param = ['questionid' => $questionid];
}
return $DB->count_records_sql($sql, $param);
}
}

View file

@ -84,7 +84,7 @@ class question_usage_table extends table_sql {
}
$sql = helper::question_usage_sql();
$params = [$this->question->id, $this->question->id];
$params = [$this->question->id, $this->question->questionbankentryid];
if (!$this->is_downloading()) {
$this->rawdata = $DB->get_records_sql($sql, $params, $this->get_page_start(), $this->get_page_size());

View file

@ -98,7 +98,7 @@ class helper_test extends \advanced_testcase {
*/
public function test_get_question_entry_usage_count() {
foreach ($this->questions as $question) {
$count = helper::get_question_entry_usage_count($question);
$count = helper::get_question_entry_usage_count(\question_bank::load_question($question->id));
// Test that the attempt data matches the usage data for the count.
$this->assertEquals(1, $count);
}

View file

@ -1,74 +0,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/>.
namespace qbank_viewcreator;
use core_question\local\bank\column_base;
/**
* A column type for the name of the question last modifier.
*
* @package qbank_viewcreator
* @copyright 2009 Tim Hunt
* @author 2021 Ghaly Marc-Alexandre <marc-alexandreghaly@catalyst-ca.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class modifier_name_column extends column_base {
public function get_name(): string {
return 'modifiername';
}
protected function get_title(): string {
return get_string('lastmodifiedby', 'question');
}
protected function display_content($question, $rowclasses): void {
global $PAGE;
$displaydata = [];
if (!empty($question->modifierfirstname) && !empty($question->modifierlastname)) {
$u = new \stdClass();
$u = username_load_fields_from_object($u, $question, 'modifier');
$displaydata['date'] = userdate($question->timemodified, get_string('strftimedatetime', 'langconfig'));
$displaydata['modifier'] = fullname($u);
echo $PAGE->get_renderer('qbank_viewcreator')->render_modifier_name($displaydata);
}
}
public function get_extra_joins(): array {
return ['um' => 'LEFT JOIN {user} um ON um.id = q.modifiedby'];
}
public function get_required_fields(): array {
$allnames = \core_user\fields::get_name_fields();
$requiredfields = [];
foreach ($allnames as $allname) {
$requiredfields[] = 'um.' . $allname . ' AS modifier' . $allname;
}
$requiredfields[] = 'q.timemodified';
return $requiredfields;
}
public function is_sortable(): array {
return [
'firstname' => ['field' => 'um.firstname', 'title' => get_string('firstname')],
'lastname' => ['field' => 'um.lastname', 'title' => get_string('lastname')],
'timemodified' => ['field' => 'q.timemodified', 'title' => get_string('date')]
];
}
}

View file

@ -36,13 +36,13 @@ class renderer extends \plugin_renderer_base {
}
/**
* Render question modifier.
* Render question edit form callback.
*
* @param array $displaydata
* @return string
*/
public function render_modifier_name($displaydata) {
return $this->render_from_template('qbank_viewcreator/modifier_display', $displaydata);
public function render_version_info($displaydata) {
return $this->render_from_template('qbank_viewcreator/version_info', $displaydata);
}
}

View file

@ -30,8 +30,7 @@ class plugin_feature extends plugin_features_base {
public function get_question_columns($qbank): array {
return [
new creator_name_column($qbank),
new modifier_name_column($qbank)
new creator_name_column($qbank)
];
}
}

View file

@ -24,4 +24,5 @@
*/
$string['pluginname'] = 'View creator';
$string['privacy:metadata'] = 'The View creator question bank plugin does not store any personal data.';
$string['privacy:metadata'] = 'View creator question bank plugin does not store any user data.';
$string['version'] = 'Version {$a}';

View file

@ -0,0 +1,46 @@
<?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/>.
/**
* Helper functions and callbacks.
*
* @package qbank_viewcreator
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* Edit page callback for information.
*
* @param object $question
* @return string
*/
function qbank_viewcreator_edit_form_display($question): string {
global $DB, $PAGE;
$versiondata = [];
$questionversion = $DB->get_record('question_versions', ['questionid' => $question->id])->version;
$versiondata['versionnumber'] = $questionversion;
if (!empty($question->createdby)) {
$a = new stdClass();
$a->time = userdate($question->timecreated);
$a->user = fullname($DB->get_record('user', ['id' => $question->createdby]));
$versiondata['createdby'] = get_string('created', 'question') . ' ' .
get_string('byandon', 'question', $a);
}
return $PAGE->get_renderer('qbank_viewcreator')->render_version_info($versiondata);
}

View file

@ -0,0 +1,37 @@
{{!
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/>.
}}
{{!
@template qbank_editquestion/version_info
Example context (json):
{
"addquestiondata": [
{
"versionnumber": 1,
"createdby": "Admin User on Wednesday, 20 October 2021, 5:33 AM"
}
]
}
}}
<div class="question-version-number">
<a><u>{{#str}} version, qbank_viewcreator, {{versionnumber}} {{/str}}</u></a>
</div>
{{#createdby}}
<div class="question-creator-info">
<a>{{{createdby}}}</a>
</div>
{{/createdby}}

View file

@ -24,10 +24,8 @@ Feature: Use the qbank plugin manager page for viewcreator plugin
And I am on the "Test quiz" "quiz activity" page
And I navigate to "Question bank" in current page administration
Then I should not see "Created by"
And I should not see "Last modified by"
And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration
And I click on "Enable" "link" in the "View creator" "table_row"
And I am on the "Test quiz" "quiz activity" page
And I navigate to "Question bank" in current page administration
Then I should see "Created by"
And I should see "Last modified by"

View file

@ -64,14 +64,14 @@ class question_name_idnumber_tags_column extends viewquestionname_column_helper
public function get_required_fields(): array {
$fields = parent::get_required_fields();
$fields[] = 'q.idnumber';
$fields[] = 'qbe.idnumber';
return $fields;
}
public function is_sortable(): array {
return [
'name' => ['field' => 'q.name', 'title' => get_string('questionname', 'question')],
'idnumber' => ['field' => 'q.idnumber', 'title' => get_string('idnumber', 'question')],
'idnumber' => ['field' => 'qbe.idnumber', 'title' => get_string('idnumber', 'question')],
];
}

View file

@ -60,12 +60,8 @@ class question_text_row extends row_base {
echo $text;
}
public function get_extra_joins(): array {
return ['qc' => 'JOIN {question_categories} qc ON qc.id = q.category'];
}
public function get_required_fields(): array {
return ['q.id', 'q.questiontext', 'q.questiontextformat', 'qc.contextid'];
return ['q.questiontext', 'q.questiontextformat'];
}
public function has_preference(): bool {

View file

@ -92,7 +92,7 @@ class category_condition extends condition {
$categoryids = [$this->category->id];
}
list($catidtest, $this->params) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED, 'cat');
$this->where = 'q.category ' . $catidtest;
$this->where = 'qbe.questioncategoryid ' . $catidtest;
}
/**

View file

@ -25,6 +25,8 @@
namespace core_question\bank\search;
use core_question\local\bank\question_version_status;
/**
* This class controls whether hidden / deleted questions are hidden in the list.
*
@ -46,7 +48,8 @@ class hidden_condition extends condition {
public function __construct($hide = true) {
$this->hide = $hide;
if ($hide) {
$this->where = 'q.hidden = 0';
$this->where = "qv.status = '" . question_version_status::QUESTION_STATUS_READY . "' " .
" OR qv.status = '" . question_version_status::QUESTION_STATUS_DRAFT . "' ";
}
}

View file

@ -173,7 +173,7 @@ class core_question_external extends external_api {
$cantag = question_has_capability_on($question, 'tag');
$questioncontext = \context::instance_by_id($question->contextid);
$contexts = new \question_edit_contexts($editingcontext);
$contexts = new \core_question\local\bank\question_edit_contexts($editingcontext);
$formoptions = [
'editingcontext' => $editingcontext,
@ -300,7 +300,7 @@ class core_question_external extends external_api {
$categorycontextid = $DB->get_field('question_categories', 'contextid', ['id' => $categoryid], MUST_EXIST);
$categorycontext = \context::instance_by_id($categorycontextid);
$editcontexts = new \question_edit_contexts($categorycontext);
$editcontexts = new \core_question\local\bank\question_edit_contexts($categorycontext);
// The user must be able to view all questions in the category that they are requesting.
$editcontexts->require_cap('moodle/question:viewall');

View file

@ -47,7 +47,9 @@ abstract class action_column_base extends column_base {
}
public function get_extra_joins(): array {
return ['qc' => 'JOIN {question_categories} qc ON qc.id = q.category'];
return ['qv' => 'JOIN {question_versions} qv ON qv.questionid = q.id',
'qbe' => 'JOIN {question_bank_entries} qbe on qbe.id = qv.questionbankentryid',
'qc' => 'JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid'];
}
public function get_required_fields(): array {

View file

@ -0,0 +1,92 @@
<?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 core_question\local\bank;
/**
* Converts contextlevels to strings and back to help with reading/writing contexts to/from import/export files.
*
* @package core_question
* @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class context_to_string_translator {
/**
* @var array used to translate between contextids and strings for this context.
*/
protected $contexttostringarray = [];
/**
* context_to_string_translator constructor.
*
* @param \context[] $contexts
*/
public function __construct($contexts) {
$this->generate_context_to_string_array($contexts);
}
/**
* Context to string.
*
* @param int $contextid
* @return mixed
*/
public function context_to_string($contextid) {
return $this->contexttostringarray[$contextid];
}
/**
* String to context.
*
* @param string $contextname
* @return false|int|string
*/
public function string_to_context($contextname) {
return array_search($contextname, $this->contexttostringarray);
}
/**
* Generate context to array.
*
* @param \context[] $contexts
*/
protected function generate_context_to_string_array($contexts) {
if (!$this->contexttostringarray) {
$catno = 1;
foreach ($contexts as $context) {
switch ($context->contextlevel) {
case CONTEXT_MODULE :
$contextstring = 'module';
break;
case CONTEXT_COURSE :
$contextstring = 'course';
break;
case CONTEXT_COURSECAT :
$contextstring = "cat$catno";
$catno++;
break;
case CONTEXT_SYSTEM :
$contextstring = 'system';
break;
}
$this->contexttostringarray[$context->id] = $contextstring;
}
}
}
}

View file

@ -97,4 +97,12 @@ class edit_menu_column extends column_base {
return ['q.qtype'];
}
/**
* Get menuable actions.
*
* @return menuable_action Menuable actions.
*/
public function get_actions(): array {
return $this->actions;
}
}

View file

@ -0,0 +1,222 @@
<?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 core_question\local\bank;
/**
* Tracks all the contexts related to the one we are currently editing questions and provides helper methods to check permissions.
*
* @package core_question
* @copyright 2007 Jamie Pratt me@jamiep.org
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_edit_contexts {
/**
* @var \string[][] array of the capabilities.
*/
public static $caps = [
'editq' => [
'moodle/question:add',
'moodle/question:editmine',
'moodle/question:editall',
'moodle/question:viewmine',
'moodle/question:viewall',
'moodle/question:usemine',
'moodle/question:useall',
'moodle/question:movemine',
'moodle/question:moveall'],
'questions' => [
'moodle/question:add',
'moodle/question:editmine',
'moodle/question:editall',
'moodle/question:viewmine',
'moodle/question:viewall',
'moodle/question:movemine',
'moodle/question:moveall'],
'categories' => [
'moodle/question:managecategory'],
'import' => [
'moodle/question:add'],
'export' => [
'moodle/question:viewall',
'moodle/question:viewmine']];
/**
* @var array of contexts.
*/
protected $allcontexts;
/**
* Constructor
* @param \context $thiscontext the current context.
*/
public function __construct(\context $thiscontext) {
$this->allcontexts = array_values($thiscontext->get_parent_contexts(true));
}
/**
* Get all the contexts.
*
* @return \context[] all parent contexts
*/
public function all() {
return $this->allcontexts;
}
/**
* Get the lowest context.
*
* @return \context lowest context which must be either the module or course context
*/
public function lowest() {
return $this->allcontexts[0];
}
/**
* Get the contexts having cap.
*
* @param string $cap capability
* @return \context[] parent contexts having capability, zero based index
*/
public function having_cap($cap) {
$contextswithcap = [];
foreach ($this->allcontexts as $context) {
if (has_capability($cap, $context)) {
$contextswithcap[] = $context;
}
}
return $contextswithcap;
}
/**
* Get the contexts having at least one cap.
*
* @param array $caps capabilities
* @return \context[] parent contexts having at least one of $caps, zero based index
*/
public function having_one_cap($caps) {
$contextswithacap = [];
foreach ($this->allcontexts as $context) {
foreach ($caps as $cap) {
if (has_capability($cap, $context)) {
$contextswithacap[] = $context;
break; // Done with caps loop.
}
}
}
return $contextswithacap;
}
/**
* Context having at least one cap.
*
* @param string $tabname edit tab name
* @return \context[] parent contexts having at least one of $caps, zero based index
*/
public function having_one_edit_tab_cap($tabname) {
return $this->having_one_cap(self::$caps[$tabname]);
}
/**
* Contexts for adding question and also using it.
*
* @return \context[] those contexts where a user can add a question and then use it.
*/
public function having_add_and_use() {
$contextswithcap = [];
foreach ($this->allcontexts as $context) {
if (!has_capability('moodle/question:add', $context)) {
continue;
}
if (!has_any_capability(['moodle/question:useall', 'moodle/question:usemine'], $context)) {
continue;
}
$contextswithcap[] = $context;
}
return $contextswithcap;
}
/**
* Has at least one parent context got the cap $cap?
*
* @param string $cap capability
* @return boolean
*/
public function have_cap($cap) {
return (count($this->having_cap($cap)));
}
/**
* Has at least one parent context got one of the caps $caps?
*
* @param array $caps capability
* @return boolean
*/
public function have_one_cap($caps) {
foreach ($caps as $cap) {
if ($this->have_cap($cap)) {
return true;
}
}
return false;
}
/**
* Has at least one parent context got one of the caps for actions on $tabname
*
* @param string $tabname edit tab name
* @return boolean
*/
public function have_one_edit_tab_cap($tabname) {
return $this->have_one_cap(self::$caps[$tabname]);
}
/**
* Throw error if at least one parent context hasn't got the cap $cap
*
* @param string $cap capability
*/
public function require_cap($cap) {
if (!$this->have_cap($cap)) {
throw new \moodle_exception('nopermissions', '', '', $cap);
}
}
/**
* Throw error if at least one parent context hasn't got one of the caps $caps
*
* @param array $caps capabilities
*/
public function require_one_cap($caps) {
if (!$this->have_one_cap($caps)) {
$capsstring = join(', ', $caps);
throw new \moodle_exception('nopermissions', '', '', $capsstring);
}
}
/**
* Throw error if at least one parent context hasn't got one of the caps $caps
*
* @param string $tabname edit tab name
*/
public function require_one_edit_tab_cap($tabname) {
if (!$this->have_one_edit_tab_cap($tabname)) {
throw new \moodle_exception('nopermissions', '', '', 'access question edit tab '.$tabname);
}
}
}

View file

@ -0,0 +1,43 @@
<?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 core_question\local\bank;
/**
* Class question_version_status contains the statuses for a question.
*
* @package core_question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_version_status {
/**
* Const if the question is ready to use.
*/
const QUESTION_STATUS_READY = 'ready';
/**
* Const if the question is hidden.
*/
const QUESTION_STATUS_HIDDEN = 'hidden';
/**
* const if the question is in draft.
*/
const QUESTION_STATUS_DRAFT = 'draft';
}

View file

@ -290,7 +290,25 @@ class random_question_loader {
$fieldsstring = implode(',', $fields);
}
return $DB->get_records_list('question', 'id', $questionids, 'id', $fieldsstring, $offset, $limit);
// Create the query to get the questions (validate that at least we have a question id. If not, do not execute the sql).
$hasquestions = false;
if (!empty($questionids)) {
$hasquestions = true;
}
if ($hasquestions) {
list($condition, $param) = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid');
$condition = 'WHERE q.id ' . $condition;
$sql = "SELECT {$fieldsstring}
FROM (SELECT q.*, qbe.questioncategoryid as category
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
{$condition}) q";
return $DB->get_records_sql($sql, $param, $offset, $limit);
} else {
return [];
}
}
/**

View file

@ -70,7 +70,7 @@ class view {
protected $editquestionurl;
/**
* @var \question_edit_contexts
* @var \core_question\local\bank\question_edit_contexts
*/
protected $contexts;
@ -157,7 +157,7 @@ class view {
/**
* Constructor for view.
*
* @param \question_edit_contexts $contexts
* @param \core_question\local\bank\question_edit_contexts $contexts
* @param \moodle_url $pageurl
* @param object $course course settings
* @param object $cm (optional) activity settings.
@ -244,8 +244,9 @@ class view {
'preview_action_column',
'delete_action_column',
'export_xml_action_column',
'question_status_column',
'creator_name_column',
'modifier_name_column'
'comment_count_column'
];
if (question_get_display_preference('qbshowtext', 0, PARAM_BOOL, new \moodle_url(''))) {
$corequestionbankcolumns[] = 'question_text_row';
@ -560,7 +561,7 @@ class view {
protected function build_query(): void {
// Get the required tables and fields.
$joins = [];
$fields = ['q.hidden', 'q.category'];
$fields = ['qv.status', 'qc.id', 'qv.version', 'qv.id as versionid', 'qbe.id as questionbankentryid'];
if (!empty($this->requiredcolumns)) {
foreach ($this->requiredcolumns as $column) {
$extrajoins = $column->get_extra_joins();
@ -583,7 +584,12 @@ class view {
}
// Build the where clause.
$tests = ['q.parent = 0'];
$latestversion = 'qv.version = (SELECT MAX(v.version)
FROM {question_versions} v
JOIN {question_bank_entries} be
ON be.id = v.questionbankentryid
WHERE be.id = qbe.id)';
$tests = ['q.parent = 0', $latestversion];
$this->sqlparams = [];
foreach ($this->searchconditions as $searchcondition) {
if ($searchcondition->where()) {
@ -1153,7 +1159,7 @@ class view {
*/
protected function get_row_classes($question, $rowcount): array {
$classes = [];
if ($question->hidden) {
if ($question->status === question_version_status::QUESTION_STATUS_HIDDEN) {
$classes[] = 'dimmed_text';
}
if ($question->id == $this->lastchangedid) {
@ -1219,4 +1225,20 @@ class view {
$this->searchconditions[] = $searchcondition;
}
/**
* Gets visible columns.
* @return array $this->visiblecolumns Visible columns.
*/
public function get_visiblecolumns(): array {
return $this->visiblecolumns;
}
/**
* Get required columns.
*
* @return array Required columns.
*/
public function get_requiredcolumns(): array {
return $this->requiredcolumns;
}
}

View file

@ -126,6 +126,10 @@ class provider implements
// The 'question_statistics' table contains aggregated statistics about responses.
// It does not contain any identifiable user data.
$items->add_database_table('question_bank_entries', [
'ownerid' => 'privacy:metadata:database:question_bank_entries:ownerid',
], 'privacy:metadata:database:question_bank_entries');
// The question subsystem makes use of the qtype, qformat, and qbehaviour plugin types.
$items->add_plugintype_link('qtype', [], 'privacy:metadata:link:qtype');
$items->add_plugintype_link('qformat', [], 'privacy:metadata:link:qformat');
@ -336,12 +340,13 @@ class provider implements
// A user may have created or updated a question.
// Questions are linked against a question category, which has a contextid field.
$sql = "SELECT cat.contextid
$sql = "SELECT qc.contextid
FROM {question} q
INNER JOIN {question_categories} cat ON cat.id = q.category
WHERE
q.createdby = :useridcreated OR
q.modifiedby = :useridmodified";
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE q.createdby = :useridcreated
OR q.modifiedby = :useridmodified";
$params = [
'useridcreated' => $userid,
'useridmodified' => $userid,
@ -363,9 +368,10 @@ class provider implements
// Questions are linked against a question category, which has a contextid field.
$sql = "SELECT q.createdby, q.modifiedby
FROM {question} q
JOIN {question_categories} cat
ON cat.id = q.category
WHERE cat.contextid = :contextid";
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid = :contextid";
$params = [
'contextid' => $context->id
@ -487,7 +493,8 @@ class provider implements
/**
* Delete all data for all users in the specified context.
*
* @param context $context The specific context to delete data for.
* @param \context $context The specific context to delete data for.
* @throws \dml_exception
*/
public static function delete_data_for_all_users_in_context(\context $context) {
global $DB;
@ -496,17 +503,19 @@ class provider implements
// user. They are still exported in the list of a users data, but they are not removed.
// The userid is instead anonymised.
$DB->set_field_select('question', 'createdby', 0,
'category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)',
[
'contextid' => $context->id,
]);
$sql = 'SELECT q.*
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid = ?';
$DB->set_field_select('question', 'modifiedby', 0,
'category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)',
[
'contextid' => $context->id,
]);
$questions = $DB->get_records_sql($sql, [$context->id]);
foreach ($questions as $question) {
$question->createdby = 0;
$question->modifiedby = 0;
$DB->update_record('question', $question);
}
}
/**
@ -523,15 +532,36 @@ class provider implements
list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
$contextparams['createdby'] = $contextlist->get_user()->id;
$DB->set_field_select('question', 'createdby', 0, "
category IN (SELECT id FROM {question_categories} WHERE contextid {$contextsql})
AND createdby = :createdby", $contextparams);
$questiondata = $DB->get_records_sql(
"SELECT q.*
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid {$contextsql}
AND q.createdby = :createdby", $contextparams);
foreach ($questiondata as $question) {
$question->createdby = 0;
$DB->update_record('question', $question);
}
list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
$contextparams['modifiedby'] = $contextlist->get_user()->id;
$DB->set_field_select('question', 'modifiedby', 0, "
category IN (SELECT id FROM {question_categories} WHERE contextid {$contextsql})
AND modifiedby = :modifiedby", $contextparams);
$questiondata = $DB->get_records_sql(
"SELECT q.*
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid {$contextsql}
AND q.modifiedby = :modifiedby", $contextparams);
foreach ($questiondata as $question) {
$question->modifiedby = 0;
$DB->update_record('question', $question);
}
}
/**
@ -554,12 +584,32 @@ class provider implements
$params = ['contextid' => $context->id];
$DB->set_field_select('question', 'createdby', 0, "
category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)
AND createdby {$createdbysql}", $params + $createdbyparams);
$questiondata = $DB->get_records_sql(
"SELECT q.*
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid = :contextid
AND q.createdby {$createdbysql}", $params + $createdbyparams);
$DB->set_field_select('question', 'modifiedby', 0, "
category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)
AND modifiedby {$modifiedbysql}", $params + $modifiedbyparams);
foreach ($questiondata as $question) {
$question->createdby = 0;
$DB->update_record('question', $question);
}
$questiondata = $DB->get_records_sql(
"SELECT q.*
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid = :contextid
AND q.modifiedby {$modifiedbysql}", $params + $modifiedbyparams);
foreach ($questiondata as $question) {
$question->modifiedby = 0;
$DB->update_record('question', $question);
}
}
}

View file

@ -42,7 +42,7 @@ class all_calculated_for_qubaid_condition {
/**
* @var object[]
*/
public $subquestions;
public $subquestions = [];
/**
* Holds slot (position) stats and stats for variants of questions in slots.

View file

@ -346,7 +346,15 @@ class calculator {
* @param calculated $stats question stats to update.
*/
protected function initial_question_walker($stats) {
$stats->markaverage = $stats->totalmarks / $stats->s;
if ($stats->s != 0) {
$stats->markaverage = $stats->totalmarks / $stats->s;
$stats->othermarkaverage = $stats->totalothermarks / $stats->s;
$stats->summarksaverage = $stats->totalsummarks / $stats->s;
} else {
$stats->markaverage = 0;
$stats->othermarkaverage = 0;
$stats->summarksaverage = 0;
}
if ($stats->maxmark != 0) {
$stats->facility = $stats->markaverage / $stats->maxmark;
@ -354,10 +362,6 @@ class calculator {
$stats->facility = null;
}
$stats->othermarkaverage = $stats->totalothermarks / $stats->s;
$stats->summarksaverage = $stats->totalsummarks / $stats->s;
sort($stats->markarray, SORT_NUMERIC);
sort($stats->othermarksarray, SORT_NUMERIC);

View file

@ -50,40 +50,60 @@ function get_module_from_cmid($cmid) {
return array($modrec, $cmrec);
}
/**
* Function to read all questions for category into big array
*
* @param int $category category number
* @param bool $noparent if true only questions with NO parent will be selected
* @param bool $recurse include subdirectories
* @param bool $export set true if this is called by questionbank export
*/
function get_questions_category( $category, $noparent=false, $recurse=true, $export=true ) {
* Function to read all questions for category into big array
*
* @param object $category category number
* @param bool $noparent if true only questions with NO parent will be selected
* @param bool $recurse include subdirectories
* @param bool $export set true if this is called by questionbank export
* @param bool $latestversion if only the latest versions needed
* @return array
*/
function get_questions_category(object $category, bool $noparent, bool $recurse = true, bool $export = true,
bool $latestversion = false): array {
global $DB;
// Build sql bit for $noparent
// Build sql bit for $noparent.
$npsql = '';
if ($noparent) {
$npsql = " and parent='0' ";
$npsql = " and q.parent='0' ";
}
// Get list of categories
// Get list of categories.
if ($recurse) {
$categorylist = question_categorylist($category->id);
} else {
$categorylist = array($category->id);
$categorylist = [$category->id];
}
// Get the list of questions for the category
// Get the list of questions for the category.
list($usql, $params) = $DB->get_in_or_equal($categorylist);
$questions = $DB->get_records_select('question', "category {$usql} {$npsql}", $params, 'category, qtype, name');
// Iterate through questions, getting stuff we need
$qresults = array();
// Get the latest version of a question.
$version = '';
if ($latestversion) {
$version = 'AND (qv.version = (SELECT MAX(v.version)
FROM {question_versions} v
JOIN {question_bank_entries} be
ON be.id = v.questionbankentryid
WHERE be.id = qbe.id) OR qv.version is null)';
}
$questions = $DB->get_records_sql("SELECT q.*, qv.status, qc.id AS category
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.id {$usql} {$npsql} {$version}
ORDER BY qc.id, q.qtype, q.name", $params);
// Iterate through questions, getting stuff we need.
$qresults = [];
foreach($questions as $key => $question) {
$question->export_process = $export;
$qtype = question_bank::get_qtype($question->qtype, false);
if ($export && $qtype->name() == 'missingtype') {
if ($export && $qtype->name() === 'missingtype') {
// Unrecognised question type. Skip this question when exporting.
continue;
}
@ -307,7 +327,7 @@ function question_build_edit_resources($edittab, $baseurl, $params) {
}
if ($thiscontext){
$contexts = new question_edit_contexts($thiscontext);
$contexts = new core_question\local\bank\question_edit_contexts($thiscontext);
$contexts->require_one_edit_tab_cap($edittab);
} else {
$contexts = null;

View file

@ -419,8 +419,10 @@ abstract class question_bank {
list($categorysql, $params) = $DB->get_in_or_equal($categories);
$sql = "SELECT DISTINCT q.qtype
FROM {question} q
WHERE q.category $categorysql";
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE qbe.questioncategoryid $categorysql";
$qtypes = $DB->get_fieldset_sql($sql, $params);
return $qtypes;
@ -454,7 +456,7 @@ class question_finder implements cache_data_source {
}
/**
* @return get the question definition cache we are using.
* @return cache_application the question definition cache we are using.
*/
protected function get_data_cache() {
// Do not double cache here because it may break cache resetting.
@ -496,12 +498,17 @@ class question_finder implements cache_data_source {
if ($extraconditions) {
$extraconditions = ' AND (' . $extraconditions . ')';
}
$qcparams['readystatus'] = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$sql = "SELECT q.id, q.id AS id2
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE qbe.questioncategoryid {$qcsql}
AND q.parent = 0
AND qv.status = :readystatus
{$extraconditions}";
return $DB->get_records_select_menu('question',
"category {$qcsql}
AND parent = 0
AND hidden = 0
{$extraconditions}", $qcparams + $extraparams, '', 'id,id AS id2');
return $DB->get_records_sql_menu($sql, $qcparams + $extraparams);
}
/**
@ -543,14 +550,23 @@ class question_finder implements cache_data_source {
list($qcsql, $qcparams) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED, 'qc');
$readystatus = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$select = "q.id, (SELECT COUNT(1)
FROM " . $qubaids->from_question_attempts('qa') . "
WHERE qa.questionid = q.id AND " . $qubaids->where() . "
) AS previous_attempts";
$from = "{question} q";
$where = "q.category {$qcsql}
$join = "JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid";
$from = $from . " " . $join;
$where = "qbe.questioncategoryid {$qcsql}
AND q.parent = 0
AND q.hidden = 0";
AND qv.status = '$readystatus'
AND qv.version = (SELECT MAX(v.version)
FROM {question_versions} v
JOIN {question_bank_entries} be
ON be.id = v.questionbankentryid
WHERE be.id = qbe.id)";
$params = $qcparams;
if (!empty($tagids)) {
@ -580,20 +596,32 @@ class question_finder implements cache_data_source {
}
return $DB->get_records_sql_menu("SELECT $select
FROM $from
WHERE $where $extraconditions
ORDER BY previous_attempts",
FROM $from
WHERE $where $extraconditions
ORDER BY previous_attempts",
$qubaids->from_where_params() + $params + $extraparams);
}
/* See cache_data_source::load_for_cache. */
public function load_for_cache($questionid) {
global $DB;
$questiondata = $DB->get_record_sql('
SELECT q.*, qc.contextid
FROM {question} q
JOIN {question_categories} qc ON q.category = qc.id
WHERE q.id = :id', array('id' => $questionid), MUST_EXIST);
$sql = 'SELECT q.id, qc.id as category, q.parent, q.name, q.questiontext, q.questiontextformat,
q.generalfeedback, q.generalfeedbackformat, q.defaultmark, q.penalty, q.qtype,
q.length, q.stamp, q.timecreated, q.timemodified,
q.createdby, q.modifiedby, qbe.idnumber,
qc.contextid,
qv.status,
qv.id as versionid,
qv.version,
qv.questionbankentryid
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE q.id = :id';
$questiondata = $DB->get_record_sql($sql, ['id' => $questionid], MUST_EXIST);
get_question_options($questiondata);
return $questiondata;
}
@ -602,15 +630,26 @@ class question_finder implements cache_data_source {
public function load_many_for_cache(array $questionids) {
global $DB;
list($idcondition, $params) = $DB->get_in_or_equal($questionids);
$questiondata = $DB->get_records_sql('
SELECT q.*, qc.contextid
FROM {question} q
JOIN {question_categories} qc ON q.category = qc.id
WHERE q.id ' . $idcondition, $params);
$sql = 'SELECT q.id, qc.id as category, q.parent, q.name, q.questiontext, q.questiontextformat,
q.generalfeedback, q.generalfeedbackformat, q.defaultmark, q.penalty, q.qtype,
q.length, q.stamp, q.timecreated, q.timemodified,
q.createdby, q.modifiedby, qbe.idnumber,
qc.contextid,
qv.status,
qv.id as versionid,
qv.version,
qv.questionbankentryid
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE q.id ';
$questiondata = $DB->get_records_sql($sql . $idcondition, $params);
foreach ($questionids as $id) {
if (!array_key_exists($id, $questiondata)) {
throw new dml_missing_record_exception('question', '', array('id' => $id));
throw new dml_missing_record_exception('question', '', ['id' => $id]);
}
get_question_options($questiondata[$id]);
}

View file

@ -117,12 +117,13 @@ abstract class question_test_helper {
*/
public static function get_question_editing_form($cat, $questiondata) {
$catcontext = context::instance_by_id($cat->contextid, MUST_EXIST);
$contexts = new question_edit_contexts($catcontext);
$contexts = new core_question\local\bank\question_edit_contexts($catcontext);
$dataforformconstructor = new stdClass();
$dataforformconstructor->createdby = $questiondata->createdby;
$dataforformconstructor->qtype = $questiondata->qtype;
$dataforformconstructor->contextid = $questiondata->contextid = $catcontext->id;
$dataforformconstructor->category = $questiondata->category = $cat->id;
$dataforformconstructor->status = $questiondata->status;
$dataforformconstructor->formoptions = new stdClass();
$dataforformconstructor->formoptions->canmove = true;
$dataforformconstructor->formoptions->cansaveasnew = true;
@ -178,8 +179,8 @@ class test_question_maker {
$q->penalty = 0.3333333;
$q->length = 1;
$q->stamp = make_unique_id_code();
$q->version = make_unique_id_code();
$q->hidden = 0;
$q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$q->version = 1;
$q->timecreated = time();
$q->timemodified = time();
$q->createdby = $USER->id;
@ -200,8 +201,8 @@ class test_question_maker {
$qdata->penalty = 0.3333333;
$qdata->length = 1;
$qdata->stamp = make_unique_id_code();
$qdata->version = make_unique_id_code();
$qdata->hidden = 0;
$qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$qdata->version = 1;
$qdata->timecreated = time();
$qdata->timemodified = time();
$qdata->createdby = $USER->id;

View file

@ -136,7 +136,7 @@ class qformat_default {
*/
public function setContexts($contexts) {
$this->contexts = $contexts;
$this->translator = new context_to_string_translator($this->contexts);
$this->translator = new core_question\local\bank\context_to_string_translator($this->contexts);
}
/**
@ -411,8 +411,8 @@ class qformat_default {
// Id number not really set. Get rid of it.
unset($question->idnumber);
} else {
if ($DB->record_exists('question',
['idnumber' => $question->idnumber, 'category' => $question->category])) {
if ($DB->record_exists('question_bank_entries',
['idnumber' => $question->idnumber, 'questioncategoryid' => $question->category])) {
// We cannot have duplicate idnumbers in a category. Just remove it.
unset($question->idnumber);
}
@ -426,6 +426,20 @@ class qformat_default {
);
$question->id = $DB->insert_record('question', $question);
// Create a bank entry for each question imported.
$questionbankentry = new \stdClass();
$questionbankentry->questioncategoryid = $question->category;
$questionbankentry->idnumber = $question->idnumber ?? null;
$questionbankentry->ownerid = $question->createdby;
$questionbankentry->id = $DB->insert_record('question_bank_entries', $questionbankentry);
// Create a version for each question imported.
$questionversion = new \stdClass();
$questionversion->questionbankentryid = $questionbankentry->id;
$questionversion->questionid = $question->id;
$questionversion->version = 1;
$questionversion->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$questionversion->id = $DB->insert_record('question_versions', $questionversion);
$event = \core\event\question_created::create_from_question_instance($question, $this->importcontext);
$event->trigger();
@ -499,9 +513,6 @@ class qformat_default {
return true;
}
// Give the question a unique version stamp determined by question_hash()
$DB->set_field('question', 'version', question_hash($question),
array('id' => $question->id));
}
return true;
}
@ -886,7 +897,8 @@ class qformat_default {
// get the questions (from database) in this category
// only get q's with no parents (no cloze subquestions specifically)
if ($this->category) {
$questions = get_questions_category($this->category, true);
// Export only the latest version of a question.
$questions = get_questions_category($this->category, true, true, true, true);
} else {
$questions = $this->questions;
}
@ -907,9 +919,16 @@ class qformat_default {
foreach ($questions as $question) {
// used by file api
$contextid = $DB->get_field('question_categories', 'contextid',
array('id' => $question->category));
$questionbankentry = question_bank::load_question($question->id);
$qcategory = $questionbankentry->category;
$contextid = $DB->get_field('question_categories', 'contextid', ['id' => $qcategory]);
$question->contextid = $contextid;
$question->idnumber = $questionbankentry->idnumber;
if ($question->status === \core_question\local\bank\question_version_status::QUESTION_STATUS_READY) {
$question->status = 0;
} else {
$question->status = 1;
}
// do not export hidden questions
if (!empty($question->hidden)) {
@ -934,7 +953,7 @@ class qformat_default {
// If parent wasn't written.
if (!in_array($trackcategoryparent, $writtencategories)) {
// If parent is empty.
if (!count($DB->get_records('question', array('category' => $trackcategoryparent)))) {
if (!count($DB->get_records('question_bank_entries', ['questioncategoryid' => $trackcategoryparent]))) {
$categoryname = $this->get_category_path($trackcategoryparent, $this->contexttofile);
$categoryinfo = $DB->get_record('question_categories', array('id' => $trackcategoryparent),
'name, info, infoformat, idnumber', MUST_EXIST);

View file

@ -67,6 +67,10 @@ class qformat_multianswer extends qformat_default {
$question->generalfeedbackformat = FORMAT_MOODLE;
$question->length = 1;
$question->penalty = 0.3333333;
$question->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$question->version = 1;
$question->versionid = 0;
$question->questionbankentryid = 0;
if (!empty($question)) {
$question->name = $this->create_default_question_name($question->questiontext, get_string('questionname', 'question'));

View file

@ -1182,6 +1182,7 @@ class qformat_xml extends qformat_default {
$invalidquestion = false;
$fs = get_file_storage();
$contextid = $question->contextid;
$question->status = 0;
// Get files used by the questiontext.
$question->questiontextfiles = $fs->get_area_files(
$contextid, 'question', 'questiontext', $question->id);
@ -1205,7 +1206,10 @@ class qformat_xml extends qformat_default {
// Check question type.
$questiontype = $this->get_qtype($question->qtype);
$idnumber = htmlspecialchars($question->idnumber);
$idnumber = '';
if (isset($question->idnumber)) {
$idnumber = htmlspecialchars($question->idnumber);
}
// Categories are a special case.
if ($question->qtype == 'category') {
@ -1242,7 +1246,7 @@ class qformat_xml extends qformat_default {
$expout .= " <defaultgrade>{$question->defaultmark}</defaultgrade>\n";
}
$expout .= " <penalty>{$question->penalty}</penalty>\n";
$expout .= " <hidden>{$question->hidden}</hidden>\n";
$expout .= " <hidden>{$question->status}</hidden>\n";
$expout .= " <idnumber>{$idnumber}</idnumber>\n";
// The rest of the output depends on question type.

View file

@ -132,7 +132,13 @@ class qformat_xml_import_export_test extends advanced_testcase {
*/
public function assert_question_in_category($qname, $catname) {
global $DB;
$question = $DB->get_record('question', ['name' => $qname], '*', MUST_EXIST);
$sql = "SELECT q.*, qbe.questioncategoryid AS category
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE q.name = :name";
$question = $DB->get_record_sql($sql, ['name' => $qname], MUST_EXIST);
$category = $DB->get_record('question_categories', ['name' => $catname], '*', MUST_EXIST);
$this->assertEquals($category->id, $question->category);
}

View file

@ -57,8 +57,7 @@ class qformat_xml_test extends question_testcase {
$q->penalty = 0.3333333;
$q->length = 1;
$q->stamp = make_unique_id_code();
$q->version = make_unique_id_code();
$q->hidden = 0;
$q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$q->timecreated = time();
$q->timemodified = time();
$q->createdby = $USER->id;
@ -342,7 +341,7 @@ END;
$qdata->defaultmark = 0;
$qdata->length = 0;
$qdata->penalty = 0;
$qdata->hidden = 0;
$qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$qdata->idnumber = null;
$exporter = new qformat_xml();
@ -570,7 +569,7 @@ END;
$qdata->defaultmark = 1;
$qdata->length = 1;
$qdata->penalty = 0;
$qdata->hidden = 0;
$qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$qdata->idnumber = null;
$qdata->options = new stdClass();
$qdata->options->id = 456;
@ -742,7 +741,7 @@ END;
$qdata->defaultmark = 1;
$qdata->length = 1;
$qdata->penalty = 0.3333333;
$qdata->hidden = 0;
$qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$qdata->idnumber = null;
$qdata->options = new stdClass();
@ -975,7 +974,7 @@ END;
$qdata->defaultmark = 2;
$qdata->length = 1;
$qdata->penalty = 0.3333333;
$qdata->hidden = 0;
$qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$qdata->idnumber = null;
$qdata->options = new stdClass();
@ -1152,7 +1151,7 @@ END;
$qdata->defaultmark = 1;
$qdata->length = 1;
$qdata->penalty = 0.1;
$qdata->hidden = 0;
$qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$qdata->idnumber = null;
$qdata->options = new stdClass();
@ -1284,7 +1283,7 @@ END;
$qdata->defaultmark = 1;
$qdata->length = 1;
$qdata->penalty = 0.3333333;
$qdata->hidden = 0;
$qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$qdata->idnumber = null;
$qdata->options = new stdClass();
@ -1460,7 +1459,7 @@ END;
$qdata->defaultmark = 1;
$qdata->length = 1;
$qdata->penalty = 1;
$qdata->hidden = 0;
$qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$qdata->idnumber = null;
$qdata->options = new stdClass();
@ -1520,7 +1519,7 @@ END;
$qdata->defaultmark = 1;
$qdata->length = 1;
$qdata->penalty = 1;
$qdata->hidden = 0;
$qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$qdata->idnumber = 'TestIDNum2';
$qdata->options = new stdClass();

View file

@ -57,9 +57,15 @@ function core_question_output_fragment_tags_form($args) {
$filtercourses = null;
}
$category = $DB->get_record('question_categories', ['id' => $question->category]);
$sql = "SELECT qc.*
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE q.id = :id";
$category = $DB->get_record_sql($sql, ['id' => $question->id]);
$questioncontext = \context::instance_by_id($category->contextid);
$contexts = new \question_edit_contexts($editingcontext);
$contexts = new \core_question\local\bank\question_edit_contexts($editingcontext);
// Load the question tags and filter the course tags by the current course.
if (core_tag_tag::is_enabled('core_question', 'question')) {

View file

@ -144,7 +144,14 @@ class core_question_backup_testcase extends advanced_testcase {
// The questions should remain in the question category they were which is
// a question category belonging to a course category context.
$questions = $DB->get_records('question', ['category' => $qcat->id], 'idnumber');
$sql = 'SELECT q.*,
qbe.idnumber
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE qbe.questioncategoryid = ?
ORDER BY qbe.idnumber';
$questions = $DB->get_records_sql($sql, [$qcat->id]);
$this->assertCount(2, $questions);
// Retrieve tags for each question and check if they are assigned at the right context.
@ -190,9 +197,11 @@ class core_question_backup_testcase extends advanced_testcase {
// The questions should have been moved to a question category that belongs to a course context.
$questions = $DB->get_records_sql("SELECT q.*
FROM {question} q
JOIN {question_categories} qc ON q.category = qc.id
WHERE qc.contextid = ?", [$coursecontext3->id]);
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid = ?", [$coursecontext3->id]);
$this->assertCount(2, $questions);
// Now, retrieve tags for each question and check if they are assigned at the right context.

View file

@ -49,7 +49,7 @@ class core_question_bank_view_testcase extends advanced_testcase {
$context = context_course::instance($course->id);
// Create a question in the default category.
$contexts = new question_edit_contexts($context);
$contexts = new core_question\local\bank\question_edit_contexts($context);
$cat = question_make_default_categories($contexts->all());
$questiondata = $questiongenerator->create_question('numerical', null,
['name' => 'Example question', 'category' => $cat->id]);
@ -93,7 +93,7 @@ class core_question_bank_view_testcase extends advanced_testcase {
$context = context_course::instance($course->id);
// Create a question in the default category.
$contexts = new question_edit_contexts($context);
$contexts = new core_question\local\bank\question_edit_contexts($context);
$cat = question_make_default_categories($contexts->all());
$questiondata = $questiongenerator->create_question('numerical', null,
['name' => 'Example question', 'category' => $cat->id]);

View file

@ -32,7 +32,6 @@ Feature: A teacher can duplicate questions in the question bank
And I press "id_submitbutton"
Then I should see "Duplicated question name"
And I should see "Test question to be copied"
And "Duplicated question name" row "Last modified by" column of "categoryquestions" table should contain "Teacher One"
And "Test question to be copied ID number qid" row "Created by" column of "categoryquestions" table should contain "Admin User"
Scenario: Duplicated questions automatically get a new name suggested

View file

@ -0,0 +1,55 @@
@core @core_question
Feature: Questions in the question bank have versions
In order to see modified questions
As a teacher
I want to view them as different versions
Background:
Given the following "courses" exist:
| fullname | shortname | category | groupmode |
| Course 1 | C1 | 0 | 1 |
And the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "activities" exist:
| activity | name | course | idnumber |
| quiz | Quiz 1 | C1 | quiz1 |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext | answer 1 |
| Test questions | truefalse | First question | Answer the first question | True |
And quiz "Quiz 1" contains the following questions:
| question | page |
| First question | 1 |
And I log in as "teacher1"
And I am on "Course 1" course homepage
@javascript
Scenario: Question version is displayed
When I navigate to "Question bank" in current page administration
And I should see "First question"
And I click on "Edit" "link" in the "First question" "table_row"
And I follow "Edit question"
Then I should see "Version 1"
@javascript
Scenario: Question version change when question is altered
When I navigate to "Question bank" in current page administration
And I should see "First question"
And I click on "Edit" "link" in the "First question" "table_row"
And I follow "Edit question"
And I should see "Version 1"
And I set the field "id_name" to "Renamed question v2"
And I set the field "id_questiontext" to "edited question"
And I press "id_submitbutton"
And I should not see "First question"
And I should see "Renamed question v2"
And I click on "Edit" "link" in the "Renamed question v2" "table_row"
And I follow "Edit question"
Then I should see "Version 2"
And I should not see "Version 1"

View file

@ -32,8 +32,7 @@ Feature: A teacher can edit questions in the question bank
And I press "id_submitbutton"
Then I should see "Edited question name"
And I should not see "Test question to be edited"
And "Edited question name" row "Created by" column of "categoryquestions" table should contain "Admin User"
And "Edited question name" row "Last modified by" column of "categoryquestions" table should contain "Teacher 1"
And "Edited question name" row "Created by" column of "categoryquestions" table should contain "Teacher 1"
Scenario: Editing a question can be cancelled
When I choose "Edit question" action for "Test question to be edited" in the question bank
@ -41,7 +40,6 @@ Feature: A teacher can edit questions in the question bank
And I press "Cancel"
Then I should see "Test question to be edited"
And "Test question to be edited" row "Created by" column of "categoryquestions" table should contain "Admin User"
And "Test question to be edited" row "Last modified by" column of "categoryquestions" table should contain "Admin User"
Scenario: A question can have its idnumber removed
Given the following "questions" exist:

View file

@ -39,16 +39,3 @@ Feature: A teacher can move questions between categories in the question bank
And the field "Select a category" matches value "&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Subcategory (1)"
And the "Select a category" select box should contain "Used category"
And the "Select a category" select box should not contain "Used category (1)"
@javascript
Scenario: Move a question between categories via the question settings page
When I navigate to "Question bank" in current page administration
And I set the field "Select a category" to "Used category"
And I choose "Edit question" action for "Test question to be moved" in the question bank
And I click on "Use this category" "checkbox"
And I set the field "Save in category" to "Subcategory"
And I press "id_submitbutton"
Then I should see "Test question to be moved"
And the field "Select a category" matches value "&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Subcategory (1)"
And the "Select a category" select box should contain "Used category"
And the "Select a category" select box should not contain "Used category (1)"

View file

@ -46,7 +46,7 @@ Feature: A teacher can put questions with idnumbers in categories in the questio
And I press "Save changes"
Then I should not see "This ID number is already in use"
Scenario: Question idnumber conflicts found when saving to a different category.
Scenario: Question idnumber conflicts found when saving to the same category.
When the following "question categories" exist:
| contextlevel | reference | questioncategory | name |
| Course | C1 | Top | top |
@ -55,13 +55,10 @@ Feature: A teacher can put questions with idnumbers in categories in the questio
And the following "questions" exist:
| questioncategory | qtype | name | questiontext | idnumber |
| Category 1 | essay | Question to edit | Write about whatever you want | q1 |
| Category 2 | essay | Other question | Write about whatever you want | q2 |
| Category 1 | essay | Other question | Write about whatever you want | q2 |
And I navigate to "Question bank" in current page administration
And I choose "Edit question" action for "Question to edit" in the question bank
And I set the following fields to these values:
| Use this category | 0 |
| ID number | q2 |
| Save in category | Category 2 |
And I set the field "ID number" to "q2"
And I press "Save changes"
Then I should see "This ID number is already in use"

View file

@ -28,7 +28,6 @@ use qbank_managecategories\question_category_object;
use qtype_description;
use qtype_description_edit_form;
use qtype_description_test_helper;
use question_edit_contexts;
use test_question_maker;
defined('MOODLE_INTERNAL') || die();
@ -57,7 +56,7 @@ class events_test extends \advanced_testcase {
$course = $this->getDataGenerator()->create_course();
$quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
$contexts = new question_edit_contexts(\context_module::instance($quiz->cmid));
$contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid));
$defaultcategoryobj = question_make_default_categories([$contexts->lowest()]);
$defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid;
@ -108,7 +107,7 @@ class events_test extends \advanced_testcase {
$course = $this->getDataGenerator()->create_course();
$quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
$contexts = new question_edit_contexts(\context_module::instance($quiz->cmid));
$contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid));
$defaultcategoryobj = question_make_default_categories([$contexts->lowest()]);
$defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid;
@ -229,12 +228,13 @@ class events_test extends \advanced_testcase {
// Trigger and capture the event.
$sink = $this->redirectEvents();
$qtype->save_question($questiondata, $fromform);
$question = $qtype->save_question($questiondata, $fromform);
$events = $sink->get_events();
$event = reset($events);
// Check that the event data is valid.
$this->assertInstanceOf('\core\event\question_updated', $event);
// Every save is a new question after Moodle 4.0.
$this->assertInstanceOf('\core\event\question_created', $event);
$this->assertEquals($question->id, $event->objectid);
$this->assertEquals($cat->id, $event->other['categoryid']);
$this->assertDebuggingNotCalled();

View file

@ -14,16 +14,24 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
defined('MOODLE_INTERNAL') || die();
/**
* Quiz module test data generator class
* Quiz module test data generator.
*
* @package moodlecore
* @subpackage question
* @package core_question
* @copyright 2013 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use core_question\local\bank\question_version_status;
/**
* Class core_question_generator for generating question data.
*
* @package core_question
* @copyright 2013 The Open University
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class core_question_generator extends component_generator_base {
/**
@ -31,19 +39,23 @@ class core_question_generator extends component_generator_base {
*/
protected $categorycount = 0;
/**
* Make the category count to zero.
*/
public function reset() {
$this->categorycount = 0;
}
/**
* Create a new question category.
*
* @param array|stdClass $record
* @return stdClass question_categories record.
*/
public function create_question_category($record = null) {
global $DB;
$this->categorycount++;
$this->categorycount ++;
$defaults = [
'name' => 'Test question category ' . $this->categorycount,
@ -79,18 +91,11 @@ class core_question_generator extends component_generator_base {
* @return stdClass the question data.
*/
public function create_question($qtype, $which = null, $overrides = null) {
global $CFG;
require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
$fromform = test_question_maker::get_question_form_data($qtype, $which);
$fromform = (object) $this->datagenerator->combine_defaults_and_record(
(array) $fromform, $overrides);
$question = new stdClass();
$question->category = $fromform->category;
$question->qtype = $qtype;
$question->qtype = $qtype;
$question->createdby = 0;
$question->idnumber = null;
$question->status = question_version_status::QUESTION_STATUS_READY;
return $this->update_question($question, $which, $overrides);
}
@ -122,10 +127,9 @@ class core_question_generator extends component_generator_base {
$qtype = $question->qtype;
$fromform = test_question_maker::get_question_form_data($qtype, $which);
$fromform = (object) $this->datagenerator->combine_defaults_and_record(
(array) $question, $fromform);
$fromform = (object) $this->datagenerator->combine_defaults_and_record(
(array) $fromform, $overrides);
$fromform = (object) $this->datagenerator->combine_defaults_and_record((array) $question, $fromform);
$fromform = (object) $this->datagenerator->combine_defaults_and_record((array) $fromform, $overrides);
$fromform->status = $question->status;
$question = question_bank::get_qtype($qtype)->save_question($question, $fromform);

View file

@ -71,7 +71,7 @@ class generator_test extends \advanced_testcase {
question_move_questions_to_category([$quest1->id], $qcat1->id);
$this->assertSame('myquest', \question_bank::load_question_data($quest1->id)->idnumber);
// Can only change idnumber of quest2 once quest1 has been moved to another category.
$quest2 = $generator->update_question($questions[1], null, ['idnumber' => 'myquest']);
$quest2 = $generator->update_question($questions[1], null, ['idnumber' => 'myquest_4']);
question_move_questions_to_category([$quest2->id], $qcat1->id);
$this->assertSame('myquest_4', \question_bank::load_question_data($quest2->id)->idnumber);
// Check can add an idnumber of 0.

View file

@ -310,12 +310,14 @@ class provider_test extends \core_privacy\tests\provider_testcase {
$q2 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
$this->setUser($otheruser);
$questiongenerator->update_question($q2);
// When we update a question, a new question/version is created.
$q2updated = $questiongenerator->update_question($q2);
$q3 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
$q4 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
$this->setUser($user);
$questiongenerator->update_question($q3);
// When we update a question, a new question/version is created.
$q3updated = $questiongenerator->update_question($q3);
$q5 = $questiongenerator->create_question('shortanswer', null, ['category' => $othercat->id]);
$approvedcontextlist = new \core_privacy\tests\request\approved_contextlist(
@ -337,12 +339,12 @@ class provider_test extends \core_privacy\tests\provider_testcase {
$this->assertEquals(0, $qrecord->createdby);
$this->assertEquals(0, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q2->id]);
$this->assertEquals(0, $qrecord->createdby);
$qrecord = $DB->get_record('question', ['id' => $q2updated->id]);
$this->assertEquals($otheruser->id, $qrecord->createdby);
$this->assertEquals($otheruser->id, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q3->id]);
$this->assertEquals($otheruser->id, $qrecord->createdby);
$qrecord = $DB->get_record('question', ['id' => $q3updated->id]);
$this->assertEquals(0, $qrecord->createdby);
$this->assertEquals(0, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q4->id]);
@ -461,9 +463,7 @@ class provider_test extends \core_privacy\tests\provider_testcase {
// User1 has created questions and user3 has edited them.
$this->assertCount(2, $userlist);
$this->assertEqualsCanonicalizing(
[$user1->id, $user3->id],
$userlist->get_userids());
$this->assertEqualsCanonicalizing([$user1->id, $user3->id], $userlist->get_userids());
}
/**
@ -510,22 +510,24 @@ class provider_test extends \core_privacy\tests\provider_testcase {
provider::delete_data_for_users($approveduserlist);
// Now, there should be no question related to user1 or user2 in course1.
$this->assertEquals(
0,
$this->assertEquals(0,
$DB->count_records_sql("SELECT COUNT(q.id)
FROM {question} q
JOIN {question_categories} qc ON q.category = qc.id
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid = ?
AND (q.createdby = ? OR q.modifiedby = ? OR q.createdby = ? OR q.modifiedby = ?)",
AND (q.createdby = ? OR q.modifiedby = ? OR q.createdby = ? OR q.modifiedby = ?)",
[$course1context->id, $user1->id, $user1->id, $user2->id, $user2->id])
);
// User3 data in course1 should not change.
$this->assertEquals(
2,
$this->assertEquals(2,
$DB->count_records_sql("SELECT COUNT(q.id)
FROM {question} q
JOIN {question_categories} qc ON q.category = qc.id
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid = ? AND (q.createdby = ? OR q.modifiedby = ?)",
[$course1context->id, $user3->id, $user3->id])
);

View file

@ -45,7 +45,7 @@ class question_bank_column_testcase extends advanced_testcase {
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course();
$questionbank = new core_question\local\bank\view(
new question_edit_contexts(context_course::instance($course->id)),
new core_question\local\bank\question_edit_contexts(context_course::instance($course->id)),
new moodle_url('/'),
$course
);
@ -79,7 +79,7 @@ class question_bank_column_testcase extends advanced_testcase {
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course();
$questionbank = new core_question\local\bank\view(
new question_edit_contexts(context_course::instance($course->id)),
new core_question\local\bank\question_edit_contexts(context_course::instance($course->id)),
new moodle_url('/'),
$course
);

View file

@ -68,7 +68,8 @@ class random_question_loader_testcase extends advanced_testcase {
$cat = $generator->create_question_category();
$question1 = $generator->create_question('shortanswer', null, ['category' => $cat->id]);
$DB->set_field('question', 'hidden', 1, ['id' => $question1->id]);
$DB->set_field('question_versions', 'status',
\core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN, ['questionid' => $question1->id]);
$loader = new \core_question\local\bank\random_question_loader(new qubaid_list([]));
$this->assertNull($loader->get_next_question_id($cat->id, 0));

View file

@ -0,0 +1,257 @@
<?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 core_question;
use question_bank;
/**
* Question version unit tests.
*
* @package core_question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Guillermo Gomez Arias <guillermogomez@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @coversDefaultClass \question_bank
*/
class version_test extends \advanced_testcase {
/**
* @var \context_module module context.
*/
protected $context;
/**
* @var \stdClass course object.
*/
protected $course;
/**
* @var \component_generator_base question generator.
*/
protected $qgenerator;
/**
* @var \stdClass quiz object.
*/
protected $quiz;
/**
* Called before every test.
*/
protected function setUp(): void {
parent::setUp();
self::setAdminUser();
$this->resetAfterTest();
$datagenerator = $this->getDataGenerator();
$this->course = $datagenerator->create_course();
$this->quiz = $datagenerator->create_module('quiz', ['course' => $this->course->id]);
$this->qgenerator = $datagenerator->get_plugin_generator('core_question');
$this->context = \context_module::instance($this->quiz->cmid);
}
/**
* Test if creating a question a new version and bank entry records are created.
*
* @covers ::load_question
*/
public function test_make_question_create_version_and_bank_entry() {
global $DB;
$qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
$question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]);
// Get the question object after creating a question.
$questiondefinition = question_bank::load_question($question->id);
// The version and bank entry in the object should be the same.
$sql = "SELECT qv.id AS versionid, qv.questionbankentryid
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE q.id = ?";
$questionversion = $DB->get_record_sql($sql, [$questiondefinition->id]);
$this->assertEquals($questionversion->versionid, $questiondefinition->versionid);
$this->assertEquals($questionversion->questionbankentryid, $questiondefinition->questionbankentryid);
// If a question is updated, a new version should be created.
$this->qgenerator->update_question($question, null, ['name' => 'This is a new version']);
$newquestiondefinition = question_bank::load_question($question->id);
// The version should be 2.
$this->assertEquals('2', $newquestiondefinition->version);
// Both versions should be in same bank entry.
$this->assertEquals($questiondefinition->questionbankentryid, $newquestiondefinition->questionbankentryid);
}
/**
* Test if deleting a question the related version and bank entry records are deleted.
*
* @covers ::load_question
* @covers ::question_delete_question
*/
public function test_delete_question_delete_versions() {
global $DB;
$qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
$question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]);
$questionfirstversionid = $question->id;
// Create a new version and try to remove it.
$this->qgenerator->update_question($question, null, ['name' => 'This is a new version']);
// The new version and bank entry record should exist.
$sql = "SELECT q.id, qv.id AS versionid, qv.questionbankentryid
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE q.id = ?";
$questionobject = $DB->get_records_sql($sql, [$question->id]);
$this->assertCount(1, $questionobject);
// Try to delete new version.
question_delete_question($question->id);
// The version record should not exist.
$sql = "SELECT qv.*
FROM {question_versions} qv
WHERE qv.id = ?";
$questionversion = $DB->get_record_sql($sql, [$questionobject[$question->id]->versionid]);
$this->assertFalse($questionversion);
// The bank entry record should exist because there is an older version.
$sql = "SELECT qbe.*
FROM {question_bank_entries} qbe
WHERE qbe.id = ?";
$questionbankentry = $DB->get_records_sql($sql, [$questionobject[$question->id]->questionbankentryid]);
$this->assertCount(1, $questionbankentry);
// Now remove the first version.
question_delete_question($questionfirstversionid);
$sql = "SELECT qbe.*
FROM {question_bank_entries} qbe
WHERE qbe.id = ?";
$questionbankentry = $DB->get_record_sql($sql, [$questionobject[$question->id]->questionbankentryid]);
// The bank entry record should not exist.
$this->assertFalse($questionbankentry);
}
/**
* Test if deleting a question will not break a quiz.
*
* @covers ::load_question
* @covers ::quiz_add_quiz_question
* @covers ::question_delete_question
*/
public function test_delete_question_in_use() {
$qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
$question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]);
$questionfirstversionid = $question->id;
// Create a new version and try to remove it after adding it to a quiz.
$this->qgenerator->update_question($question, null, ['name' => 'This is a new version']);
// Add it to the quiz.
quiz_add_quiz_question($question->id, $this->quiz);
// Try to delete new version.
question_delete_question($question->id);
// Try to delete old version.
question_delete_question($questionfirstversionid);
// The questions should exist even after trying to remove it.
$questionversion1 = question_bank::load_question($question->id);
$questionversion2 = question_bank::load_question($questionfirstversionid);
$this->assertEquals($questionversion1->id, $question->id);
$this->assertEquals($questionversion2->id, $questionfirstversionid);
}
/**
* Test if moving a category will not break a quiz.
*
* @covers ::load_question
* @covers ::quiz_add_quiz_question
*/
public function test_move_category_with_questions() {
global $DB;
$qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
$qcategorychild = $this->qgenerator->create_question_category(['contextid' => $this->context->id,
'parent' => $qcategory->id]);
$systemcontext = \context_system::instance();
$qcategorysys = $this->qgenerator->create_question_category(['contextid' => $systemcontext->id]);
$question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategorychild->id]);
$questiondefinition = question_bank::load_question($question->id);
// Add it to the quiz.
quiz_add_quiz_question($question->id, $this->quiz);
// Move the category to system context.
$contexts = new \core_question\local\bank\question_edit_contexts($systemcontext);
$qcobject = new \qbank_managecategories\question_category_object(null,
new \moodle_url('/question/bank/managecategories/category.php', ['courseid' => SITEID]),
$contexts->having_one_edit_tab_cap('categories'), 0, null, 0,
$contexts->having_cap('moodle/question:add'));
$qcobject->move_questions_and_delete_category($qcategorychild->id, $qcategorysys->id);
// The bank entry record should point to the new category in order to not break quizzes.
$sql = "SELECT qbe.questioncategoryid
FROM {question_bank_entries} qbe
WHERE qbe.id = ?";
$questionbankentry = $DB->get_record_sql($sql, [$questiondefinition->questionbankentryid]);
$this->assertEquals($qcategorysys->id, $questionbankentry->questioncategoryid);
}
/**
* Test that all versions will have the same bank entry idnumber value.
*
* @covers ::load_question
*/
public function test_id_number_in_bank_entry() {
global $DB;
$qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
$question = $this->qgenerator->create_question('shortanswer', null,
[
'category' => $qcategory->id,
'idnumber' => 'id1'
]);
$questionid1 = $question->id;
// Create a new version and try to remove it after adding it to a quiz.
$this->qgenerator->update_question($question, null, ['idnumber' => 'id2']);
$questionid2 = $question->id;
// Change the id number and get the question object.
$this->qgenerator->update_question($question, null, ['idnumber' => 'id3']);
$questionid3 = $question->id;
// The new version and bank entry record should exist.
$questiondefinition = question_bank::load_question($question->id);
$sql = "SELECT q.id AS questionid, qv.id AS versionid, qbe.id AS questionbankentryid, qbe.idnumber
FROM {question_bank_entries} qbe
JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id
JOIN {question} q ON q.id = qv.questionid
WHERE qbe.id = ?";
$questionbankentry = $DB->get_records_sql($sql, [$questiondefinition->questionbankentryid]);
// We should have 3 versions and 1 question bank entry with the same idnumber.
$this->assertCount(3, $questionbankentry);
$this->assertEquals($questionbankentry[$questionid1]->idnumber, 'id3');
$this->assertEquals($questionbankentry[$questionid2]->idnumber, 'id3');
$this->assertEquals($questionbankentry[$questionid3]->idnumber, 'id3');
}
}

View file

@ -56,16 +56,13 @@ class question_dataset_dependent_definitions_form extends question_wizard_form {
* @param MoodleQuickForm $mform the form being built.
*/
public function __construct($submiturl, $question) {
global $DB;
// Validate the question category.
if (!isset($question->categoryobject)) {
throw new moodle_exception('categorydoesnotexist', 'question');
}
$question->category = $question->categoryobject->id;
$this->question = $question;
$this->qtypeobj = question_bank::get_qtype($this->question->qtype);
// Validate the question category.
if (!$category = $DB->get_record('question_categories',
array('id' => $question->category))) {
print_error('categorydoesnotexist', 'question', $returnurl);
}
$this->category = $category;
$this->categorycontext = context::instance_by_id($category->contextid);
parent::__construct($submiturl);
}

View file

@ -75,17 +75,18 @@ class question_dataset_dependent_items_form extends question_wizard_form {
* @param MoodleQuickForm $mform the form being built.
*/
public function __construct($submiturl, $question, $regenerate) {
global $SESSION, $CFG, $DB;
global $SESSION;
// Validate the question category.
if (!isset($question->categoryobject)) {
throw new moodle_exception('categorydoesnotexist', 'question');
}
$question->category = $question->categoryobject->id;
$this->category = $question->categoryobject;
$this->categorycontext = context::instance_by_id($this->category->contextid);
$this->regenerate = $regenerate;
$this->question = $question;
$this->qtypeobj = question_bank::get_qtype($this->question->qtype);
// Validate the question category.
if (!$category = $DB->get_record('question_categories',
array('id' => $question->category))) {
print_error('categorydoesnotexist', 'question', $returnurl);
}
$this->category = $category;
$this->categorycontext = context::instance_by_id($category->contextid);
// Get the dataset defintions for this question.
if (empty($question->id)) {
$this->datasetdefs = $this->qtypeobj->get_dataset_definitions(

View file

@ -97,6 +97,7 @@ class qtype_calculated_test_helper extends question_test_helper {
$qdata->name = 'Simple sum';
$qdata->questiontext = 'What is {a} + {b}?';
$qdata->generalfeedback = 'Generalfeedback: {={a} + {b}} is the right answer.';
$qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$qdata->options = new stdClass();
$qdata->options->unitgradingtype = 0;
@ -186,6 +187,8 @@ class qtype_calculated_test_helper extends question_test_helper {
$fromform->feedback[2]['format'] = FORMAT_HTML;
$fromform->feedback[2]['text'] = 'Completely wrong.';
$fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
return $fromform;
}
}

View file

@ -88,8 +88,8 @@ class qtype_calculated_test extends advanced_testcase {
$this->assertEquals(['id', 'category', 'parent', 'name', 'questiontext', 'questiontextformat',
'generalfeedback', 'generalfeedbackformat', 'defaultmark', 'penalty', 'qtype',
'length', 'stamp', 'version', 'hidden', 'timecreated', 'timemodified',
'createdby', 'modifiedby', 'idnumber', 'contextid', 'options', 'hints', 'categoryobject'],
'length', 'stamp', 'timecreated', 'timemodified', 'createdby', 'modifiedby', 'idnumber', 'contextid',
'status', 'versionid', 'version', 'questionbankentryid', 'categoryobject', 'options', 'hints'],
array_keys(get_object_vars($questiondata)));
$this->assertEquals($category->id, $questiondata->category);
$this->assertEquals(0, $questiondata->parent);
@ -102,7 +102,7 @@ class qtype_calculated_test extends advanced_testcase {
$this->assertEquals(0, $questiondata->penalty);
$this->assertEquals('calculated', $questiondata->qtype);
$this->assertEquals(1, $questiondata->length);
$this->assertEquals(0, $questiondata->hidden);
$this->assertEquals(\core_question\local\bank\question_version_status::QUESTION_STATUS_READY, $questiondata->status);
$this->assertEquals($question->createdby, $questiondata->createdby);
$this->assertEquals($question->createdby, $questiondata->modifiedby);
$this->assertEquals('', $questiondata->idnumber);

Some files were not shown because too many files have changed in this diff Show more