Merge branch 'MDL-85069_500_STABLE' of https://github.com/marxjohnson/moodle into MOODLE_500_STABLE

This commit is contained in:
Mihail Geshoski
2025-07-08 22:28:36 +08:00
24 changed files with 1026 additions and 338 deletions
@@ -0,0 +1,19 @@
issueNumber: MDL-85069
notes:
core_question:
- message: >
`core_question_search_shared_banks` will now search all question banks,
not just those outside the current course.
This makes the service usable in cases outside of the current "Switch
banks" UI, which require searching all banks on the site.
It also makes the autocomplete in the "Switch banks" UI more consistent,
as it was previously excluding some of the banks listed in the UI
(Question banks in this course), but not others (Recently viewed
question banks).
This change has also adds a 'requiredcapabilties' parameter to the
function, which accepts an list of abbreviated capabilities for
checking access against question banks before they are returned.
type: changed
+1 -1
View File
@@ -267,7 +267,7 @@ $string['novirtualquestiontype'] = 'No virtual question type for question type {
$string['numqas'] = 'No. question attempts';
$string['numquestions'] = 'No. questions';
$string['numquestionsandhidden'] = '{$a->numquestions} (+{$a->numhidden} hidden +{$a->numdraft} draft)';
$string['otherquestionbank'] = 'Other question banks';
$string['otherquestionbank'] = 'All question banks';
$string['otherquestionbankstoomany'] = 'More than {$a} results. You need to refine your search.';
$string['page-question-x'] = 'Any question page';
$string['page-question-edit'] = 'Question editing page';
+1 -1
View File
@@ -1278,7 +1278,7 @@ function question_default_export_filename($course, $category): string {
*
* @param int|stdClass|question_definition $questionorid object or id.
* If an object is passed, it should include ->contextid and ->createdby.
* @param string $cap 'add', 'edit', 'view', 'use', 'move' or 'tag'.
* @param string $cap 'add', 'edit', 'view', 'use', 'move', or 'tag'.
* @param int $notused no longer used.
* @return bool this user has the capability $cap for this question $question?
*/
+2
View File
@@ -26,6 +26,7 @@
$string['addanotherbank'] = 'Add question bank';
$string['coursecategory'] = 'Shared teaching resources for category: {$a}';
$string['courserestore'] = 'Course restore';
$string['createqbank'] = 'Create question bank {$a}';
$string['modulename'] = 'Question bank';
$string['modulename_help'] = 'This activity allows a teacher to create, preview, and edit questions in a database of question categories.
@@ -37,6 +38,7 @@ $string['noqbankinstances'] = 'There are no question banks in this course.';
$string['pluginadministration'] = 'Question bank administration';
$string['pluginname'] = 'Question bank';
$string['privacy:metadata'] = 'The Question bank plugin does not store any personal data, core_question automatically tracks all sorts of data for questions.';
$string['progress_createqbank'] = 'Creating question banks';
$string['qbank:addinstance'] = 'Add a new question bank';
$string['qbank:view'] = 'View question bank';
$string['qbankname'] = 'Question bank name';
+37
View File
@@ -108,3 +108,40 @@ function qbank_delete_instance(int $id): bool {
return true;
}
/**
* Callback for tool_generator so we can add qbanks to generated courses.
*
* @param tool_generator_course_backend $backend
* @param testing_data_generator $generator
* @param int $courseid
* @param int $number
* @return void
* @throws coding_exception
*/
function qbank_course_backend_generator_create_activity(
tool_generator_course_backend $backend,
testing_data_generator $generator,
int $courseid,
int $number,
) {
// Set up generator.
$generator = $generator->get_plugin_generator('mod_qbank');
// Create assignments.
$backend->log('createqbank', $number, true, 'mod_qbank');
for ($i = 1; $i <= $number; $i++) {
$qbank = $generator->create_instance(
[
'course' => $courseid,
'name' => "Question bank course {$courseid} bank {$i}",
],
[
'section' => 0,
],
);
question_get_default_category(\core\context\module::instance($qbank->cmid)->id, true);
$backend->dot($i, $number);
}
$backend->end_log();
}
+6
View File
@@ -32,6 +32,12 @@ class mod_qbank_generator extends testing_module_generator {
if (empty($record->type)) {
$record->type = core_question\local\bank\question_bank_helper::TYPE_STANDARD;
}
if (isset($options['section']) && (int) $options['section'] !== 0) {
throw new \core\exception\coding_exception(
"Attempted to create a mod_qbank instance in section {$options['section']}. " .
"Question banks can only be created in section 0.",
);
}
return parent::create_instance($record, $options);
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ Feature: Moving a question to another category should not affect random question
And I click on "With selected" "button"
And I click on question bulk action "move"
And I open the autocomplete suggestions list in the ".search-categories" "css_element"
And I click on "Subcategory" item in the autocomplete list
And I click on "Subcategory" "list_item" in the "Move the selected questions to..." "dialogue"
And I click on "Move questions" "button"
And I should see "Are you sure you want to move these questions?"
And I click on "Confirm" "button"
+1 -1
View File
@@ -6,6 +6,6 @@ define("core_question/question_banks_datasource",["exports","core/ajax","core/no
* @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net}
* @author Mark Johnson <mark.johnson@catalyst-eu.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_notification=(obj=_notification)&&obj.__esModule?obj:{default:obj};var _default={transport:function(selector,query,callback){const contextId=document.querySelector(selector).dataset.contextid;if(!contextId)throw new Error("The attribute data-contextid is required on "+selector);(0,_ajax.call)([{methodname:"core_question_search_shared_banks",args:{contextid:contextId,search:query}}])[0].then(callback).catch(_notification.default.exception)},processResults:(selector,results)=>results.sharedbanks};return _exports.default=_default,_exports.default}));
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_notification=(obj=_notification)&&obj.__esModule?obj:{default:obj};var _default={transport:function(selector,query,callback){const element=document.querySelector(selector),contextId=element.dataset.contextid;let requiredcapabilities=["use"];if(element.dataset.requiredcapabilities&&(requiredcapabilities=JSON.parse(element.dataset.requiredcapabilities)),!contextId)throw new Error("The attribute data-contextid is required on "+selector);(0,_ajax.call)([{methodname:"core_question_search_shared_banks",args:{contextid:contextId,search:query,requiredcapabilities:requiredcapabilities}}])[0].then(callback).catch(_notification.default.exception)},processResults:(selector,results)=>results.sharedbanks};return _exports.default=_default,_exports.default}));
//# sourceMappingURL=question_banks_datasource.min.js.map
@@ -1 +1 @@
{"version":3,"file":"question_banks_datasource.min.js","sources":["../src/question_banks_datasource.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Autocomplete data source for shared question banks.\n *\n * @module core_question/question_banks_datasource\n * @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net}\n * @author Mark Johnson <[email protected]>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {call as fetchMany} from 'core/ajax';\nimport Notification from 'core/notification';\n\nexport default {\n\n transport: function(selector, query, callback) {\n const element = document.querySelector(selector);\n const contextId = element.dataset.contextid;\n\n if (!contextId) {\n throw new Error('The attribute data-contextid is required on ' + selector);\n }\n\n fetchMany([{\n methodname: 'core_question_search_shared_banks',\n args: {\n contextid: contextId,\n search: query,\n },\n }])[0]\n .then(callback)\n .catch(Notification.exception);\n },\n\n processResults: (selector, results) => {\n return results.sharedbanks;\n },\n};\n"],"names":["transport","selector","query","callback","contextId","document","querySelector","dataset","contextid","Error","methodname","args","search","then","catch","Notification","exception","processResults","results","sharedbanks"],"mappings":";;;;;;;;sKA2Be,CAEXA,UAAW,SAASC,SAAUC,MAAOC,gBAE3BC,UADUC,SAASC,cAAcL,UACbM,QAAQC,cAE7BJ,gBACK,IAAIK,MAAM,+CAAiDR,yBAG3D,CAAC,CACPS,WAAY,oCACZC,KAAM,CACFH,UAAWJ,UACXQ,OAAQV,UAEZ,GACHW,KAAKV,UACLW,MAAMC,sBAAaC,YAGxBC,eAAgB,CAAChB,SAAUiB,UAChBA,QAAQC"}
{"version":3,"file":"question_banks_datasource.min.js","sources":["../src/question_banks_datasource.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Autocomplete data source for shared question banks.\n *\n * @module core_question/question_banks_datasource\n * @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net}\n * @author Mark Johnson <[email protected]>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {call as fetchMany} from 'core/ajax';\nimport Notification from 'core/notification';\n\nexport default {\n\n transport: function(selector, query, callback) {\n const element = document.querySelector(selector);\n const contextId = element.dataset.contextid;\n let requiredcapabilities = ['use'];\n if (element.dataset.requiredcapabilities) {\n requiredcapabilities = JSON.parse(element.dataset.requiredcapabilities);\n }\n\n if (!contextId) {\n throw new Error('The attribute data-contextid is required on ' + selector);\n }\n\n fetchMany([{\n methodname: 'core_question_search_shared_banks',\n args: {\n contextid: contextId,\n search: query,\n requiredcapabilities: requiredcapabilities,\n },\n }])[0]\n .then(callback)\n .catch(Notification.exception);\n },\n\n processResults: (selector, results) => {\n return results.sharedbanks;\n },\n};\n"],"names":["transport","selector","query","callback","element","document","querySelector","contextId","dataset","contextid","requiredcapabilities","JSON","parse","Error","methodname","args","search","then","catch","Notification","exception","processResults","results","sharedbanks"],"mappings":";;;;;;;;sKA2Be,CAEXA,UAAW,SAASC,SAAUC,MAAOC,gBAC3BC,QAAUC,SAASC,cAAcL,UACjCM,UAAYH,QAAQI,QAAQC,cAC9BC,qBAAuB,CAAC,UACxBN,QAAQI,QAAQE,uBAChBA,qBAAuBC,KAAKC,MAAMR,QAAQI,QAAQE,wBAGjDH,gBACK,IAAIM,MAAM,+CAAiDZ,yBAG3D,CAAC,CACPa,WAAY,oCACZC,KAAM,CACFN,UAAWF,UACXS,OAAQd,MACRQ,qBAAsBA,yBAE1B,GACHO,KAAKd,UACLe,MAAMC,sBAAaC,YAGxBC,eAAgB,CAACpB,SAAUqB,UAChBA,QAAQC"}
@@ -30,6 +30,10 @@ export default {
transport: function(selector, query, callback) {
const element = document.querySelector(selector);
const contextId = element.dataset.contextid;
let requiredcapabilities = ['use'];
if (element.dataset.requiredcapabilities) {
requiredcapabilities = JSON.parse(element.dataset.requiredcapabilities);
}
if (!contextId) {
throw new Error('The attribute data-contextid is required on ' + selector);
@@ -40,6 +44,7 @@ export default {
args: {
contextid: contextId,
search: query,
requiredcapabilities: requiredcapabilities,
},
}])[0]
.then(callback)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -29,6 +29,7 @@ import AutoComplete from 'core/form-autocomplete';
import {moveQuestions} from 'core_question/repository';
import Templates from 'core/templates';
import Notification from 'core/notification';
import Pending from 'core/pending';
export default class ModalQuestionBankBulkmove extends Modal {
@@ -38,13 +39,15 @@ export default class ModalQuestionBankBulkmove extends Modal {
SAVE_BUTTON: '[data-action="bulkmovesave"]',
SELECTED_QUESTIONS: 'table#categoryquestions input[id^="checkq"]',
SEARCH_BANK: '#searchbanks',
SEARCH_CATEGORY: '#searchcategories',
CATEGORY_OPTIONS: '#searchcategories option',
SEARCH_CATEGORY: '.selectcategory',
QUESTION_CATEGORY_SELECTOR: '.question_category_selector',
CATEGORY_OPTIONS: '.selectcategory option',
BANK_OPTIONS: '#searchbanks option',
CATEGORY_ENHANCED_INPUT: '.search-categories input',
ORIGINAL_SELECTS: 'select.bulk-move',
CATEGORY_WARNING: '#searchcatwarning',
CATEGORY_SUGGESTION: '.search-categories span.form-autocomplete-downarrow',
CATEGORY_SELECTION: '.search-categories span[role="option"][data-active-selection="true"]',
CONFIRM_BUTTON: '.bulk-move-footer button[data-action="save"]',
CANCEL_BUTTON: '.bulk-move-footer button[data-action="cancel"]'
};
@@ -112,6 +115,7 @@ export default class ModalQuestionBankBulkmove extends Modal {
* @param {integer} currentCategoryId
*/
async display(currentBankContextId, currentCategoryId) {
const displayPending = new Pending('qbank_bulkmove/bulk_move_modal');
this.bodyPromise = await Fragment.loadFragment(
'qbank_bulkmove',
'bulk_move',
@@ -122,27 +126,23 @@ export default class ModalQuestionBankBulkmove extends Modal {
);
await this.setBody(this.bodyPromise);
await this.enhanceSelects(document.querySelectorAll(ModalQuestionBankBulkmove.SELECTORS.ORIGINAL_SELECTS));
await this.enhanceSelects();
this.registerEnhancedEventListeners();
this.mapCategoryContextIds();
this.updateSaveButtonState();
displayPending.resolve();
}
/**
* Register event listeners on the enhanced selects. Must be done after they have been enhanced.
*/
registerEnhancedEventListeners() {
document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY).addEventListener("change", (e) => {
const targetCategoryId = e.currentTarget.value;
this.targetCategoryId = targetCategoryId;
this.rebuildOptions(this.targetBankContextId, targetCategoryId);
document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY).addEventListener("change", () => {
this.updateSaveButtonState();
});
document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_BANK).addEventListener("change", (e) => {
const selectedBankContextId = e.currentTarget.value;
this.targetBankContextId = selectedBankContextId;
this.rebuildOptions(selectedBankContextId, this.targetCategoryId);
document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_BANK).addEventListener("change", async(e) => {
await this.updateCategorySelector(e.currentTarget.value);
this.updateSaveButtonState();
});
this.getModal().on("click", ModalQuestionBankBulkmove.SELECTORS.SAVE_BUTTON, (e) => {
@@ -151,25 +151,6 @@ export default class ModalQuestionBankBulkmove extends Modal {
});
}
/**
* Set a map, so we can determine which bank belongs to which category.
*/
mapCategoryContextIds() {
const customSelectCategoryOptions = document.querySelectorAll(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_OPTIONS);
if (customSelectCategoryOptions.length === 0) {
return;
}
const categoryContextIds = [];
customSelectCategoryOptions.forEach((option) => {
categoryContextIds[option.value] = option.dataset.bankContextid;
});
this.categoryContextIds = categoryContextIds;
}
/**
* Update the body with a confirmation prompt and set confirm cancel buttons in the footer.
* @return {Promise<void>}
@@ -201,37 +182,33 @@ export default class ModalQuestionBankBulkmove extends Modal {
}
/**
* Dynamically update all enhanced selects options based on what is selected.
* Update the category selector based on the selected question bank.
*
* @param {integer} selectedBankContextId
* @param {integer} selectedCategoryId
* @param {Number} selectedBankCmId
* @return {Promise} Resolved when the update is complete.
*/
rebuildOptions(selectedBankContextId, selectedCategoryId) {
const categoryContextIds = this.categoryContextIds;
const customSelectCategoryOptions = document.querySelectorAll(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_OPTIONS);
// Disable the category selector if no bank selected.
if (!selectedBankContextId) {
updateCategorySelector(selectedBankCmId) {
if (!selectedBankCmId) {
this.updateCategorySelectorState(false);
return Promise.resolve();
} else {
// Mark to be disabled all the categories not belonging to the selected bank.
// This will then be handled by the enhanced selects event handlers.
customSelectCategoryOptions.forEach((option) => {
if (option.dataset.bankContextid != selectedBankContextId) {
option.dataset.enabled = 'disabled';
} else {
option.dataset.enabled = 'enabled';
return Fragment.loadFragment(
'core_question',
'category_selector',
this.contextId,
{
'bankcmid': selectedBankCmId,
}
});
this.updateCategorySelectorState(true);
}
// De-select the selected category if it does not belong to the selected bank.
if (selectedCategoryId && selectedBankContextId && categoryContextIds[selectedCategoryId] != selectedBankContextId) {
const selectedCategoryElement = document.querySelector(
'.search-categories span[role="option"][data-value="' + selectedCategoryId + '"]'
);
selectedCategoryElement.click();
)
.then((html, js) => {
const categorySelector = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.QUESTION_CATEGORY_SELECTOR);
return Templates.replaceNode(categorySelector, html, js);
})
.then(() => {
document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_WARNING).classList.add('d-none');
return this.enhanceSelects();
})
.catch(Notification.exception);
}
}
@@ -243,6 +220,7 @@ export default class ModalQuestionBankBulkmove extends Modal {
const warning = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_WARNING);
const enhancedInput = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_ENHANCED_INPUT);
const suggestionButton = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_SUGGESTION);
const selection = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.CATEGORY_SELECTION);
if (toEnable) {
warning.classList.add('d-none');
@@ -252,6 +230,7 @@ export default class ModalQuestionBankBulkmove extends Modal {
warning.classList.remove('d-none');
enhancedInput.setAttribute('disabled', 'disabled');
suggestionButton.classList.add('d-none');
selection.click(); // Clear selected category.
}
}
@@ -260,9 +239,10 @@ export default class ModalQuestionBankBulkmove extends Modal {
*/
updateSaveButtonState() {
const saveButton = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SAVE_BUTTON);
const targetCategoryId = this.targetCategoryId;
const categorySelector = document.querySelector(ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY);
[this.targetCategoryId, this.targetBankContextId] = categorySelector.value.split(',');
if (targetCategoryId && targetCategoryId != this.currentCategoryId) {
if (this.targetCategoryId && this.targetCategoryId !== this.currentCategoryId) {
saveButton.removeAttribute('disabled');
} else {
saveButton.setAttribute('disabled', 'disabled');
@@ -303,31 +283,32 @@ export default class ModalQuestionBankBulkmove extends Modal {
/**
* Take the provided select options and enhance them into auto-complete fields.
* @param {NodeList} selects Custom select elements to enhance.
*
* @return {Promise<Promise[]>}
*/
async enhanceSelects(selects) {
async enhanceSelects() {
const placeholder = await getString('searchbyname', 'mod_quiz');
const enhanced = [];
if (selects.length > 0) {
for (let i = 0; i < selects.length; i++) {
enhanced.push(AutoComplete.enhance(
selects.item(i),
false,
'',
placeholder,
false,
true,
'',
true
)
);
}
await AutoComplete.enhance(
ModalQuestionBankBulkmove.SELECTORS.SEARCH_BANK,
false,
'core_question/question_banks_datasource',
placeholder,
false,
true,
'',
true,
);
return Promise.all(enhanced);
}
return Promise.reject('No selects to enhance');
await AutoComplete.enhance(
ModalQuestionBankBulkmove.SELECTORS.SEARCH_CATEGORY,
false,
null,
placeholder,
false,
true,
'',
true,
);
}
}
@@ -27,6 +27,7 @@ namespace qbank_bulkmove\output;
use cm_info;
use core_question\local\bank\question_bank_helper;
use core_question\output\question_category_selector;
use moodle_url;
use renderer_base;
use single_button;
@@ -62,44 +63,26 @@ class bulk_move implements \renderable, \templatable {
public function export_for_template(renderer_base $output) {
[, $cmrec] = get_module_from_cmid($this->currentbankid);
$currentbank = cm_info::create($cmrec);
$currentbankcm = cm_info::create($cmrec);
// Get all shared banks and categories and make the current bank/category pre-selected, i.e. ordered first in the list.
$bankstorender = question_bank_helper::get_activity_instances_with_shareable_questions(
[],
[],
['moodle/question:add'],
true,
$this->currentbankid
);
$allcategories = array_map(function($bank) {
if ($bank->modid == $this->currentbankid) {
// If this is the current bank then sort the categories so that our current categoryid is first in the list.
$this->sort_categories($bank->questioncategories, $this->currentcategoryid);
}
return $bank->questioncategories;
}, $bankstorender);
// The current bank is not a shared bank, but grab the category records anyway so that we can at least allow them
// to be moved to another local category in the bank.
if (!plugin_supports('mod', $currentbank->modname, FEATURE_PUBLISHES_QUESTIONS, false)) {
$currentbank = question_bank_helper::get_activity_instances_with_private_questions(
incourseids: [$currentbank->course],
getcategories: true,
// Get the current bank and its categories. All other banks and categories will be loaded dynamically.
if (plugin_supports('mod', $currentbankcm->modname, FEATURE_PUBLISHES_QUESTIONS, false)) {
$banktorender = question_bank_helper::get_activity_instances_with_shareable_questions(
havingcap: ['moodle/question:add'],
currentbankid: $this->currentbankid,
filtercontext: $currentbankcm->context,
limit: 1,
)[0];
} else {
$banktorender = question_bank_helper::get_activity_instances_with_private_questions(
incourseids: [$currentbankcm->course],
havingcap: ['moodle/question:add'],
currentbankid: $this->currentbankid,
filtercontext: $currentbankcm->context,
)[0];
$currentbankcats = $currentbank->questioncategories;
// Move the current category to the top of the list.
$this->sort_categories($currentbankcats, $this->currentcategoryid);
// Add the current bank categories to the front of the categories list.
array_unshift($allcategories, $currentbankcats);
// Add the current bank to the front of the banks list.
array_unshift($bankstorender, $currentbank);
}
// Flatten all the categories into a 2D array.
$allcategories = array_merge(...array_values($allcategories));
$categoryselector = new question_category_selector([$currentbankcm->context], autocomplete: true);
$savebutton = new single_button(
new moodle_url('#'),
@@ -113,29 +96,10 @@ class bulk_move implements \renderable, \templatable {
);
return [
'allsharedbanks' => $bankstorender,
'allcategories' => $allcategories,
'bank' => $banktorender,
'categories' => $categoryselector->export_for_template($output),
'save' => $savebutton->export_for_template($output),
'contextid' => $currentbankcm->context->id,
];
}
/**
* Wrapped usort to move the currentcategoryid to the top of the list of question categories.
*
* @param array $categories categories to sort
* @param int $currentcategoryid the category to be sorted to the top of the list
* @return void
*/
protected function sort_categories(array &$categories, int $currentcategoryid): void {
usort($categories, static function($categorya, $categoryb) use ($currentcategoryid) {
if ($categorya->id != $currentcategoryid && $categoryb->id == $currentcategoryid) {
return 1;
}
if ($categorya->id == $currentcategoryid && $categoryb->id != $currentcategoryid) {
return -1;
}
return $categoryb->id <=> $categorya->id;
});
}
}
@@ -21,12 +21,7 @@
Example context (json):
{
"allsharedbanks": [
{
"name": "Quiz 1",
"contextid": 1,
"coursenamebankname": "c1 - Quiz 1"
},
"bank": [
{
"name": "Question bank 1",
"modid": "2",
@@ -34,55 +29,27 @@
"coursenamebankname": "c1 - Question bank 1",
"cminfo": {},
"questioncategories": [
{
"id": "22",
"name": "Default for Question bank 1",
"contextid": "22",
"enabled": "disabled"
}
]
},
{
"name": "Question bank 2",
"modid": "3",
"contextid": 33,
"coursenamebankname": "c2 - Question bank 2",
"cminfo": {},
"questioncategories": [
{
"id": "23",
"name": "Default for Question bank 2",
"contextid": "23",
"enabled": "disabled"
}
]
}
],
"allcategories": [
{
"id": "22",
"name": "Default for Question bank 1",
"contextid": "22"
},
{
"id": "23",
"name": "Default for Question bank 2",
"contextid": "23",
"enabled": "disabled"
},
{
"id": "24",
"name": "Default for Question bank 3",
"contextid": "24",
"enabled": "disabled"
},
{
"id": "25",
"name": "Default for Question bank 4",
"contextid": "25",
"enabled": "disabled"
}
],
"categories": {
"categories": [
{
"value": "0",
"disabled": true,
"label": "Bank name 1"
},
{
"value": "123,456",
"selected": true,
"label": "Category 1"
},
{
"value": "124,456",
"label": "Category 2"
}
]
},
"save": {
"id": "single_button669e368d496707",
"formid": null,
@@ -111,25 +78,30 @@
],
"actions": [],
"hasactions": false
}
},
"contextid": 1000
}
}}
<div class="search-banks">
<h5>{{#str}}questionbank, question{{/str}}</h5>
<select class="form-select bulk-move d-none" id="searchbanks">
{{#allsharedbanks}}
<option value="{{contextid}}">{{{coursenamebankname}}}</option>
{{/allsharedbanks}}
<label for="searchbanks" class="visually-hidden">{{#str}}questionbank, question{{/str}}</label>
<select
class="form-select bulk-move d-none"
id="searchbanks"
data-contextid="{{contextid}}"
data-requiredcapabilities='["add"]'
>
{{#bank}}
<option value="{{modid}}">{{{coursenamebankname}}}</option>
{{/bank}}
</select>
</div>
<div class="search-categories mt-3">
<h5>{{#str}}questioncategories, question{{/str}}</h5>
<select class="form-select bulk-move d-none" id="searchcategories">
{{#allcategories}}
<option value="{{id}}" data-bank-contextid="{{contextid}}" data-enabled="{{enabled}}">{{{name}}}</option>
{{/allcategories}}
</select>
{{#categories}}
{{> core_question/question_category_selector }}
{{/categories}}
<div id="searchcatwarning" class="d-none">{{#str}}warning, qbank_bulkmove{{/str}}</div>
</div>
@@ -78,8 +78,8 @@ Feature: Use the qbank plugin manager page for bulkmove
Then I should not see "C3 - Question bank 3" in the ".search-banks" "css_element"
And I click on "C1 - Question bank 1" item in the autocomplete list
Then I should not see "Test questions 1" in the ".search-categories .form-autocomplete-selection" "css_element"
And the field "selectcategory" matches value "Test questions 2 (1)"
And I open the autocomplete suggestions list in the ".search-categories" "css_element"
And "Test questions 2" "autocomplete_suggestions" should exist
And "Test questions 3" "autocomplete_suggestions" should not exist
And "Test questions 4" "autocomplete_suggestions" should not exist
And "Test questions 5" "autocomplete_suggestions" should exist
@@ -92,7 +92,7 @@ Feature: Use the qbank plugin manager page for bulkmove
And I click on "With selected" "button"
And I click on "move" "button"
And I open the autocomplete suggestions list in the ".search-categories" "css_element"
And I click on "Test questions 6" item in the autocomplete list
And I click on "Test questions 6 (1)" item in the autocomplete list
And I click on "Move questions" "button"
Then I should see "Are you sure you want to move these questions?"
And I click on "Confirm" "button"
@@ -122,6 +122,9 @@ Feature: Use the qbank plugin manager page for bulkmove
And I should see "Question 1"
And I should see "Question 2"
And I should not see "Question 3"
# No questions are highlighted when bulk-moved.
And the "class" attribute of "Question 1" "table_row" should not contain "highlight"
And the "class" attribute of "Question 2" "table_row" should not contain "highlight"
@javascript
Scenario: Unable to bulk move questions from history page
@@ -131,3 +134,63 @@ Feature: Use the qbank plugin manager page for bulkmove
And I click on "With selected" "button"
Then I should see question bulk action "deleteselected"
And I should not see question bulk action "move"
@javascript
Scenario: Questions can be moved to a different bank, if the user has the correct capability
Given the following "role" exists:
| name | Question adder |
| shortname | adder |
And the following "role capability" exists:
| role | adder |
| moodle/question:add | allow |
And the following "course" exists:
| fullname | Course 4 |
| shortname | C4 |
| category | 0 |
And the following "course enrolment" exists:
| course | C4 |
| user | teacher1 |
| role | adder |
And the following "activity" exists:
| activity | qbank |
| course | C4 |
| name | Question bank 4 |
| idnumber | qbank4 |
And the following "question category" exists:
| contextlevel | Activity module |
| reference | qbank4 |
| name | Test questions 7 |
Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "teacher1"
And I press "Create a new question ..."
And I set the field "item_qtype_truefalse" to "1"
# Manually create a new question so additional parameters are included in the URL, and we can test they are handled correctly
# during the move operation.
And I click on "Add" "button" in the "Choose a question type to add" "dialogue"
And I set the following fields to these values:
| Question name | Seventh question |
| Question text | test |
And I press "id_submitbutton"
And I click on "Seventh question" "checkbox"
And I click on "With selected" "button"
And I click on "move" "button"
And the field "searchbanks" matches value "C1 - Test quiz"
And the field "selectcategory" matches value "Test questions 1 (2)"
And I open the autocomplete suggestions list in the ".search-banks" "css_element"
And I should see "C1 - Question bank 1" in the ".search-banks .form-autocomplete-suggestions" "css_element"
And I should see "C2 - Question bank 2" in the ".search-banks .form-autocomplete-suggestions" "css_element"
And I should see "C4 - Question bank 4" in the ".search-banks .form-autocomplete-suggestions" "css_element"
And I should not see "C3 - Question bank 3" in the ".search-banks .form-autocomplete-suggestions" "css_element"
And I click on "C1 - Question bank 1" item in the autocomplete list
And the field "selectcategory" matches value "Test questions 2 (1)"
And I click on "Move questions" "button"
And I should see "Are you sure you want to move these questions?"
When I click on "Confirm" "button"
Then I should see "Questions successfully moved"
# The move dialogue should default to the new bank and category.
And I click on "Seventh question" "checkbox"
And I click on "With selected" "button"
And I click on "move" "button"
And the field "searchbanks" matches value "C1 - Question bank 1"
And the field "selectcategory" matches value "Test questions 2 (2)"
# The moved question should be highlighted
And the "class" attribute of "Seventh question" "table_row" should contain "highlight"
+12 -124
View File
@@ -23,6 +23,7 @@ require_once($CFG->libdir . "/questionlib.php");
use context;
use core_question\category_manager;
use core_question\local\bank\question_version_status;
use core_question\output\question_category_selector;
use moodle_exception;
use html_writer;
@@ -304,46 +305,7 @@ class helper {
bool $top = false,
int $showallversions = 0,
): array {
global $DB;
$contextids = explode(',', $contexts);
foreach ($contextids as $contextid) {
$context = context::instance_by_id($contextid);
if ($context->contextlevel === CONTEXT_MODULE) {
$validcontexts[] = $contextid;
}
}
if (empty($validcontexts)) {
return [];
}
[$insql, $inparams] = $DB->get_in_or_equal($validcontexts);
$topwhere = $top ? '' : 'AND c.parent <> 0';
$statuscondition = "AND (qv.status = '" . question_version_status::QUESTION_STATUS_READY . "' " .
" OR qv.status = '" . question_version_status::QUESTION_STATUS_DRAFT . "' )";
$substatuscondition = "AND v.status <> '" . question_version_status::QUESTION_STATUS_HIDDEN . "' ";
$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 $substatuscondition)
)
)
) AS questioncount
FROM {question_categories} c
WHERE c.contextid {$insql} {$topwhere}
ORDER BY {$sortorder}";
return $DB->get_records_sql($sql, $inparams);
return (new question_category_selector())->get_categories_for_contexts($contexts, $sortorder, $top, $showallversions);
}
/**
@@ -366,74 +328,14 @@ class helper {
int $nochildrenof = -1,
bool $escapecontextnames = true,
): array {
global $CFG;
$pcontexts = [];
foreach ($contexts as $context) {
if ($context->contextlevel !== CONTEXT_MODULE) {
continue;
}
$pcontexts[] = $context->id;
}
$contextslist = join(', ', $pcontexts);
$categories = self::get_categories_for_contexts($contextslist, 'parent, sortorder, name ASC', $top);
if ($top) {
$categories = self::question_fix_top_names($categories);
}
$categories = self::question_add_context_in_key($categories);
$categories = self::add_indented_names($categories, $nochildrenof);
// Sort cats out into different contexts.
$categoriesarray = [];
foreach ($pcontexts as $contextid) {
$context = \context::instance_by_id($contextid);
$contextstring = $context->get_context_name(true, true, $escapecontextnames);
foreach ($categories as $category) {
if ($category->contextid == $contextid) {
$cid = $category->id;
if ("{$currentcat},{$contextid}" != $cid || $currentcat == 0) {
$a = new \stdClass();
$a->name = format_string(
$category->indentedname,
true,
['context' => $context]
);
if ($category->idnumber !== null && $category->idnumber !== '') {
$a->idnumber = s($category->idnumber);
}
if (!empty($category->questioncount)) {
$a->questioncount = $category->questioncount;
}
if (isset($a->idnumber) && isset($a->questioncount)) {
$formattedname = get_string('categorynamewithidnumberandcount', 'question', $a);
} else if (isset($a->idnumber)) {
$formattedname = get_string('categorynamewithidnumber', 'question', $a);
} else if (isset($a->questioncount)) {
$formattedname = get_string('categorynamewithcount', 'question', $a);
} else {
$formattedname = $a->name;
}
$categoriesarray[$contextstring][$cid] = $formattedname;
}
}
}
}
if ($popupform) {
$popupcats = [];
foreach ($categoriesarray as $contextstring => $optgroup) {
$group = [];
foreach ($optgroup as $key => $value) {
$key = str_replace($CFG->wwwroot, '', $key);
$group[$key] = $value;
}
$popupcats[] = [$contextstring => $group];
}
return $popupcats;
} else {
return $categoriesarray;
}
return (new question_category_selector())->question_category_options(
$contexts,
$top,
$currentcat,
$popupform,
$nochildrenof,
$escapecontextnames,
);
}
/**
@@ -443,13 +345,7 @@ class helper {
* @return array
*/
public static function question_add_context_in_key(array $categories): array {
$newcatarray = [];
foreach ($categories as $id => $category) {
$category->parent = "$category->parent,$category->contextid";
$category->id = "$category->id,$category->contextid";
$newcatarray["$id,$category->contextid"] = $category;
}
return $newcatarray;
return (new question_category_selector())->question_add_context_in_key($categories);
}
/**
@@ -461,15 +357,7 @@ class helper {
* @throws \coding_exception
*/
public static function question_fix_top_names(array $categories, bool $escape = true): array {
foreach ($categories as $id => $category) {
if ($category->parent == 0) {
$context = \context::instance_by_id($category->contextid);
$categories[$id]->name = get_string('topfor', 'question', $context->get_context_name(false, false, $escape));
}
}
return $categories;
return (new question_category_selector())->question_fix_top_names($categories, $escape);
}
/**
+10 -1
View File
@@ -47,7 +47,7 @@ class move_questions extends external_api {
'newcontextid' => new external_value(PARAM_INT, 'Contextid of the target question bank'),
'newcategoryid' => new external_value(PARAM_INT, 'ID of the target question category'),
'questionids' => new external_value(PARAM_SEQUENCE, 'Comma separated list of question ids to move'),
'returnurl' => new external_value(PARAM_URL,
'returnurl' => new external_value(PARAM_LOCALURL,
desc: 'A URL to add/update the filter param with the new category',
default: ''
),
@@ -112,6 +112,15 @@ class move_questions extends external_api {
if ($returnurlstring) {
$returnurl = new moodle_url($returnurlstring);
$returnurl->param('cmid', $newcontext->instanceid);
$returnurl->param('cat', "{$newcategoryid},{$newcontextid}");
$returnurl->remove_params('category');
// We can only highlight 1 question, so only highlight if we're moving a single question.
$qids = explode(',', $questionids);
if (count($qids) === 1) {
$returnurl->param('lastchanged', reset($qids));
} else {
$returnurl->remove_params('lastchanged');
};
$filter = $returnurl->param('filter');
if ($filter) {
$returnfilters = filter_condition_manager::update_filter_param_to_category(
+58 -6
View File
@@ -16,6 +16,7 @@
namespace core_question\external;
use core\exception\coding_exception;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_single_structure;
@@ -49,34 +50,85 @@ class search_shared_banks extends external_api {
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters(
[
'contextid' => new external_value(PARAM_INT, 'The current context ID.'),
'contextid' => new external_value(PARAM_INT, 'The current context ID for applying text filters to bank names.'),
'search' => new external_value(PARAM_TEXT, 'Search terms by which to filter the shared banks.', default: ''),
'requiredcapabilities' => new external_multiple_structure(
new external_value(PARAM_TEXT, 'Capability'),
'Array of abbreviated "moodle/question:" capabilities that the user must have at least one of in the context ' .
'of each matching question bank. Valid options are "add", "managecategory", "flag", "config", "edit", ' .
'"view", "use", "move" or "tag". For "edit", "view", "use", "move" and "tag", the -all and -mine ' .
"suffixed versions of the capabilty will both be checked.",
VALUE_DEFAULT,
['use'],
),
]
);
}
/**
* Expand a list of abbreviated capaibilities into an array of full capability strings.
*
* Each abbreviation must match a capability with the 'moodle/question:' prefix. Capabilities that have an "all" and "mine"
* variant will have both variants included in the returned array.
*
* These abbreviations are copied from {@see question_has_capability_on()}
*
* @param array $abbreviations Abbreviated capabilities. Must match capabilities with the 'moodle/question:' prefix.
* @return array The expanded capabilities
*/
protected static function expand_capabilities(array $abbreviations): array {
$capabilitieswithallandmine = ['edit', 'view', 'use', 'move', 'tag'];
$prefix = 'moodle/question:';
$suffixes = ['all', 'mine'];
$capabilities = [];
foreach ($abbreviations as $abbreviation) {
if (in_array($abbreviation, $capabilitieswithallandmine)) {
foreach ($suffixes as $suffix) {
$capability = $prefix . $abbreviation . $suffix;
if (is_null(get_capability_info($capability))) {
throw new coding_exception("Capability {$capability} does not exist.");
}
$capabilities[] = $capability;
}
} else {
$capability = $prefix . $abbreviation;
if (is_null(get_capability_info($capability))) {
throw new coding_exception("Capability {$capability} does not exist.");
}
$capabilities[] = $capability;
}
}
return $capabilities;
}
/**
* Return ID and formatted name of question banks accessible by the user, in courses other than the one $contextid is in.
*
* @param int $contextid Context ID of the current activity
* @param string $search String to filter results by question bank name
* @param array $requiredcapabilities List of abbreviated capabilities to check, {@see self::expand_capabilities()}
* @return array
*/
public static function execute(int $contextid, string $search = ''): array {
public static function execute(
int $contextid,
string $search = '',
array $requiredcapabilities = ['use'],
): array {
[
'contextid' => $contextid,
'search' => $search,
'requiredcapabilities' => $requiredcapabilities,
] = self::validate_parameters(self::execute_parameters(), [
'contextid' => $contextid,
'search' => $search,
'requiredcapabilities' => $requiredcapabilities,
]);
$modulecontext = context::instance_by_id($contextid);
$courseid = $modulecontext->get_parent_context()->instanceid;
self::validate_context($modulecontext);
$sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(
notincourseids: [$courseid],
havingcap: ['moodle/question:useall', 'moodle/question:usemine'],
havingcap: self::expand_capabilities($requiredcapabilities),
filtercontext: $modulecontext,
search: $search,
limit: self::MAX_RESULTS + 1, // Return up to 1 extra result, so we know there are more.
@@ -112,7 +164,7 @@ class search_shared_banks extends external_api {
return new external_single_structure([
'sharedbanks' => new external_multiple_structure(
new external_single_structure([
'value' => new external_value(PARAM_INT, 'Module ID of the shared bank.'),
'value' => new external_value(PARAM_INT, 'Course Module ID of the shared bank.'),
'label' => new external_value(PARAM_TEXT, 'Formatted bank name'),
]),
'List of shared banks',
@@ -0,0 +1,399 @@
<?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\output;
use core\context;
use core\output\renderable;
use core\output\renderer_base;
use core\output\templatable;
use core_question\local\bank\question_version_status;
/**
* A select menu of question categories.
*
*
* @package core_question
* @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net}
* @author Mark Johnson <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_category_selector implements renderable, templatable {
/**
* Constructor.
*
* @param array $contexts
* @param bool $top
* @param string $currentcat
* @param string $selected
* @param int $nochildrenof
* @param bool $autocomplete
*/
public function __construct(
/** @var array The module contexts for the question banks to show category options for. */
protected array $contexts = [],
/** @var bool If true, include top categories for each context in the options. */
protected bool $top = false,
/** @var string The current category, to exclude from the list. */
protected $currentcat = 0,
/** @var string The value of the initially selected option. */
protected string $selected = "",
/** @var int If this matches the ID of a category in the list, don't include its children. */
protected int $nochildrenof = -1,
/** @var bool If true, return options as a flattened array suitable for a list of autocomplete suggestions. */
protected bool $autocomplete = false,
) {
}
/**
* 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 string $contexts comma separated list of contextids
* @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 function get_categories_for_contexts(
string $contexts,
string $sortorder = 'parent, sortorder, name ASC',
bool $top = false,
int $showallversions = 0,
): array {
global $DB;
$contextids = explode(',', $contexts);
foreach ($contextids as $contextid) {
$context = context::instance_by_id($contextid);
if ($context->contextlevel === CONTEXT_MODULE) {
$validcontexts[] = $contextid;
}
}
if (empty($validcontexts)) {
return [];
}
[$insql, $inparams] = $DB->get_in_or_equal($validcontexts);
$topwhere = $top ? '' : 'AND c.parent <> 0';
$statuscondition = "AND (qv.status = '" . question_version_status::QUESTION_STATUS_READY . "' " .
" OR qv.status = '" . question_version_status::QUESTION_STATUS_DRAFT . "' )";
$substatuscondition = "AND v.status <> '" . question_version_status::QUESTION_STATUS_HIDDEN . "' ";
$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 $substatuscondition)
)
)
) AS questioncount
FROM {question_categories} c
WHERE c.contextid {$insql} {$topwhere}
ORDER BY {$sortorder}";
return $DB->get_records_sql($sql, $inparams);
}
/**
* Output an array of question categories.
*
* @param array $contexts The list of contexts.
* @param bool $top Whether to return the top categories or not.
* @param int $currentcat The current category, to exclude from the list.
* @param bool $popupform Return each question bank's group in an additional nested array.
* @param int $nochildrenof Don't include children of this category
* @param bool $escapecontextnames Whether the returned name of the thing is to be HTML escaped or not.
* @return array
* @throws \coding_exception|\dml_exception
*/
public function question_category_options(
array $contexts,
bool $top = false,
int $currentcat = 0,
bool $popupform = false,
int $nochildrenof = -1,
bool $escapecontextnames = true,
): array {
global $CFG;
$pcontexts = [];
foreach ($contexts as $context) {
if ($context->contextlevel !== CONTEXT_MODULE) {
continue;
}
$pcontexts[] = $context->id;
}
$contextslist = join(', ', $pcontexts);
$categories = $this->get_categories_for_contexts($contextslist, 'parent, sortorder, name ASC', $top);
if ($top) {
$categories = $this->question_fix_top_names($categories);
}
$categories = $this->question_add_context_in_key($categories);
$categories = $this->add_indented_names($categories, $nochildrenof);
// Sort cats out into different contexts.
$categoriesarray = [];
foreach ($pcontexts as $contextid) {
$context = context::instance_by_id($contextid);
$contextstring = $context->get_context_name(true, true, $escapecontextnames);
foreach ($categories as $category) {
if ($category->contextid == $contextid) {
$cid = $category->id;
if ("{$currentcat},{$contextid}" != $cid || $currentcat == 0) {
$a = new \stdClass();
$a->name = format_string(
$category->indentedname,
true,
['context' => $context]
);
if ($category->idnumber !== null && $category->idnumber !== '') {
$a->idnumber = s($category->idnumber);
}
if (!empty($category->questioncount)) {
$a->questioncount = $category->questioncount;
}
if (isset($a->idnumber) && isset($a->questioncount)) {
$formattedname = get_string('categorynamewithidnumberandcount', 'question', $a);
} else if (isset($a->idnumber)) {
$formattedname = get_string('categorynamewithidnumber', 'question', $a);
} else if (isset($a->questioncount)) {
$formattedname = get_string('categorynamewithcount', 'question', $a);
} else {
$formattedname = $a->name;
}
$categoriesarray[$contextstring][$cid] = $formattedname;
}
}
}
}
if ($popupform) {
$popupcats = [];
foreach ($categoriesarray as $contextstring => $optgroup) {
$group = [];
foreach ($optgroup as $key => $value) {
$key = str_replace($CFG->wwwroot, '', $key);
$group[$key] = $value;
}
$popupcats[] = [$contextstring => $group];
}
return $popupcats;
} else {
return $categoriesarray;
}
}
/**
* Add context in categories key.
*
* @param array $categories The list of categories, keyed by ID.
* @return array The list with the context id added to each key, id, and parent attribute.
*/
public function question_add_context_in_key(array $categories): array {
$newcatarray = [];
foreach ($categories as $id => $category) {
$category->parent = "$category->parent,$category->contextid";
$category->id = "$category->id,$category->contextid";
$newcatarray["$id,$category->contextid"] = $category;
}
return $newcatarray;
}
/**
* Finds top categories in the given categories hierarchy and replace their name with a proper localised string.
*
* @param array $categories An array of question categories.
* @param bool $escape Whether the returned name of the thing is to be HTML escaped or not.
* @return array The same question category list given to the function, with the top category names being translated.
* @throws \coding_exception
*/
public function question_fix_top_names(array $categories, bool $escape = true): array {
foreach ($categories as $id => $category) {
if ($category->parent == 0) {
$context = context::instance_by_id($category->contextid);
$categories[$id]->name = get_string('topfor', 'question', $context->get_context_name(false, false, $escape));
}
}
return $categories;
}
/**
* Format categories into an indented list reflecting the tree structure.
*
* @param array $categories An array of category objects, keyed by ID.
* @param int $nochildrenof If the category with this ID is in the list, don't include its children.
* @return array The formatted list of categories.
*/
public function add_indented_names(array $categories, int $nochildrenof = -1): array {
// Add an array to each category to hold the child category ids. This array
// will be removed again by flatten_category_tree(). It should not be used
// outside these two functions.
foreach (array_keys($categories) as $id) {
$categories[$id]->childids = [];
}
// Build the tree structure, and record which categories are top-level.
// We have to be careful, because the categories array may include published
// categories from other courses, but not their parents.
$toplevelcategoryids = [];
foreach (array_keys($categories) as $id) {
if (
!empty($categories[$id]->parent) &&
array_key_exists($categories[$id]->parent, $categories)
) {
$categories[$categories[$id]->parent]->childids[] = $id;
} else {
$toplevelcategoryids[] = $id;
}
}
// Flatten the tree to and add the indents.
$newcategories = [];
foreach ($toplevelcategoryids as $id) {
$newcategories = $newcategories + $this->flatten_category_tree(
$categories,
$id,
0,
$nochildrenof,
);
}
return $newcategories;
}
/**
* Only for the use of add_indented_names().
*
* Recursively adds an indentedname field to each category, starting with the category
* with id $id, and dealing with that category and all its children, and
* return a new array, with those categories in the right order.
*
* @param array $categories an array of categories which has had childids
* fields added by flatten_category_tree(). Passed by reference for
* performance only. It is not modfied.
* @param int $id the category to start the indenting process from.
* @param int $depth the indent depth. Used in recursive calls.
* @param int $nochildrenof If the category with this ID is in the list, don't recur to its children.
* @return array a new array of categories, in the right order for the tree.
*/
public function flatten_category_tree(array &$categories, $id, int $depth = 0, int $nochildrenof = -1): array {
// Indent the name of this category.
$newcategories = [];
$newcategories[$id] = $categories[$id];
$newcategories[$id]->indentedname = str_repeat('&nbsp;&nbsp;&nbsp;', $depth) .
$categories[$id]->name;
// Recursively indent the children.
foreach ($categories[$id]->childids as $childid) {
if ($childid != $nochildrenof) {
$newcategories = $newcategories + self::flatten_category_tree(
$categories,
$childid,
$depth + 1,
$nochildrenof,
);
}
}
// Remove the childids array that were temporarily added.
unset($newcategories[$id]->childids);
return $newcategories;
}
/**
* Context for the question_category_selector.mustache template.
*
* @param renderer_base $output
* @return array[] [
* 'banks' => a 2-D array of question banks and categories, for a plain select list with optgroups.
* 'categories' => A flat list of categories, with bank names and disabled entries, for enhancing with an Autocomplete.
* ]
*/
public function export_for_template(renderer_base $output): array {
$categoriesarray = $this->question_category_options(
$this->contexts,
$this->top,
$this->currentcat,
false,
$this->nochildrenof,
false,
);
$bankoptgroups = [];
$categoryoptions = [];
if ($this->autocomplete) {
foreach ($categoriesarray as $bankname => $categories) {
$categoryoptions[] = [
'label' => $bankname,
'value' => 0,
'disabled' => true,
];
foreach ($categories as $idcontext => $category) {
$categoryoptions[] = [
'label' => $category,
'value' => $idcontext,
'selected' => $this->selected == $idcontext,
];
}
}
if (empty($selected) && isset($categoryoptions[1])) {
// Default to selecting the first category option.
$categoryoptions[1]['selected'] = 1;
}
} else {
foreach ($categoriesarray as $bankname => $categories) {
$bankoptgroups[] = [
'bankname' => $bankname,
'categories' => array_map(
fn($idcontext, $category) => [
'idcontext' => $idcontext,
'category' => $category,
'selected' => $this->selected == $idcontext,
],
array_keys($categories),
$categories,
),
];
}
if (empty($selected) && isset($bankoptgroups[0]['categories'][0])) {
// Default to selecting the first category option.
$bankoptgroups[0]['categories'][0]['selected'] = 1;
}
}
return [
'banks' => $bankoptgroups,
'categories' => $categoryoptions,
];
}
}
+13
View File
@@ -86,3 +86,16 @@ function core_question_output_fragment_question_data(array $args): string {
$questionbank->display_question_list();
return ob_get_clean();
}
/**
* Render and return a category selector for the categories in a given question bank.
*
* @param array $args ['bankcmid' => Course module ID of the question bank]
* @return string The rendered selector.
*/
function core_question_output_fragment_category_selector(array $args): string {
global $OUTPUT;
$context = \core\context\module::instance($args['bankcmid']);
$selector = new \core_question\output\question_category_selector([$context], autocomplete: true);
return $OUTPUT->render($selector);
}
@@ -0,0 +1,61 @@
{{!
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 core_question/question_category_selector
Template for core_question\output\question_category_selector
Example context (json):
{
"categories": [
{
"value": "0",
"disabled": true,
"label": "Bank name 1"
},
{
"value": "123,456",
"selected": true,
"label": "Category 1"
},
{
"value": "124,456",
"label": "Category 2"
}
]
}
}}
<div class="question_category_selector">
<label for="selectcategory{{uniqid}}" class="visually-hidden">{{#str}} questioncategory, core_question {{/str}}</label>
<select id="selectcategory{{uniqid}}" class="selectcategory" name="selectcategory">
{{#banks}}
<optgroup label="{{bankname}}">
{{#categories}}
<option value="{{idcontext}}"{{#selected}} selected{{/selected}}>{{{category}}}</option>
{{/categories}}
</optgroup>
{{/banks}}
{{#categories}}
<option {{!
}}value="{{value}}"{{!
}}{{#disabled}} disabled class="suggestions-heading"{{/disabled}}{{!
}}{{#selected}} selected{{/selected}}>
{{{label}}}
</option>
{{/categories}}
</select>
</div>
@@ -83,7 +83,7 @@ Feature: A teacher can put questions with idnumbers in categories in the questio
And I click on "With selected" "button"
And I click on question bulk action "move"
And I open the autocomplete suggestions list in the ".search-categories" "css_element"
And I click on "Used category" item in the autocomplete list
And I click on "Used category" "list_item" in the "Move the selected questions to..." "dialogue"
And I click on "Move questions" "button"
And I should see "Are you sure you want to move these questions?"
And I click on "Confirm" "button"
+217
View File
@@ -0,0 +1,217 @@
<?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\external;
use core\context\module;
/**
* Unit tests for core_question\external\search_shared_banks
*
* @package core_question
* @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net}
* @author Mark Johnson <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core_question\external\search_shared_banks
*/
final class search_shared_banks_test extends \advanced_testcase {
/**
* Create a set of question banks across 3 courses.
*
* One bank belongs to a quiz, which is not searchable as it is not shared.
* One bank does not include the word "test" in its name, so will not match that search.
* One bank is on a course where the user has different permissions.
* One bank is on the same course as the quiz, so will not be returned unless banks on the same course are included.
*
* @return array
*/
protected function create_banks(): array {
$generator = $this->getDataGenerator();
$quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
$qbankgenerator = $this->getDataGenerator()->get_plugin_generator('mod_qbank');
$course1 = $generator->create_course();
$course2 = $generator->create_course();
$course3 = $generator->create_course();
$teacher = $generator->create_user();
$generator->enrol_user($teacher->id, $course1->id, 'teacher');
$generator->enrol_user($teacher->id, $course2->id, 'teacher');
$generator->enrol_user($teacher->id, $course3->id, 'editingteacher');
$quiz = $quizgenerator->create_instance(['name' => 'Test quiz', 'course' => $course1->id]);
$qbank1 = $qbankgenerator->create_instance(['name' => 'Test qbank 1', 'course' => $course2->id]);
$qbank2 = $qbankgenerator->create_instance(['name' => 'Test qbank 2', 'course' => $course2->id]);
$qbank3 = $qbankgenerator->create_instance(['name' => 'Different name', 'course' => $course2->id]);
$qbank4 = $qbankgenerator->create_instance(['name' => 'Test qbank with different permissions', 'course' => $course3->id]);
return [
$course1,
$course2,
$course3,
$teacher,
module::instance($quiz->cmid),
$qbank1,
$qbank2,
$qbank3,
$qbank4,
];
}
/**
* Call the function with no search string. All banks the user has permission to should be returned.
*/
public function test_empty_search(): void {
$this->resetAfterTest();
[
,
$course2,
$course3,
$teacher,
$quizcontext,
$qbank1,
$qbank2,
$qbank3,
$qbank4,
] = $this->create_banks();
$this->setUser($teacher);
$result = search_shared_banks::execute($quizcontext->id);
$this->assertEquals(
[
[
'label' => "{$course2->shortname} - {$qbank1->name}",
'value' => $qbank1->cmid,
],
[
'label' => "{$course2->shortname} - {$qbank2->name}",
'value' => $qbank2->cmid,
],
[
'label' => "{$course2->shortname} - {$qbank3->name}",
'value' => $qbank3->cmid,
],
[
'label' => "{$course3->shortname} - {$qbank4->name}",
'value' => $qbank4->cmid,
],
],
$result['sharedbanks'],
);
}
/**
* Call the function with a search string matching a subset of available banks. Only those matching should be returned.
*/
public function test_search(): void {
$this->resetAfterTest();
[
,
$course2,
$course3,
$teacher,
$quizcontext,
$qbank1,
$qbank2,
,
$qbank4,
] = $this->create_banks();
$this->setUser($teacher);
$result = search_shared_banks::execute($quizcontext->id, 'Test');
$this->assertEquals(
[
[
'label' => "{$course2->shortname} - {$qbank1->name}",
'value' => $qbank1->cmid,
],
[
'label' => "{$course2->shortname} - {$qbank2->name}",
'value' => $qbank2->cmid,
],
[
'label' => "{$course3->shortname} - {$qbank4->name}",
'value' => $qbank4->cmid,
],
],
$result['sharedbanks'],
);
}
/**
* Call the function with a different capability. Only results where the user has that capability should be returned.
*/
public function test_search_different_capability(): void {
$this->resetAfterTest();
[
,
,
$course3,
$teacher,
$quizcontext,
,
,
,
$qbank4,
] = $this->create_banks();
$this->setUser($teacher);
$result = search_shared_banks::execute($quizcontext->id, 'Test', ['edit']);
$this->assertEquals(
[
[
'label' => "{$course3->shortname} - {$qbank4->name}",
'value' => $qbank4->cmid,
],
],
$result['sharedbanks'],
);
}
/**
* If there are more than the max number of results, a placeholder is returned at the end.
*/
public function test_search_max_results(): void {
$this->resetAfterTest();
[
,
$course2,
,
$teacher,
$quizcontext,
] = $this->create_banks();
$qbankgenerator = $this->getDataGenerator()->get_plugin_generator('mod_qbank');
for ($i = 1; $i <= search_shared_banks::MAX_RESULTS + 2; $i++) {
$qbankgenerator->create_instance(['name' => "Extra qbank {$i}", 'course' => $course2->id]);
}
$this->setUser($teacher);
$result = search_shared_banks::execute($quizcontext->id, 'Extra');
$this->assertCount(search_shared_banks::MAX_RESULTS + 1, $result['sharedbanks']);
$lastresult = end($result['sharedbanks']);
$this->assertEquals(
[
'label' => get_string('otherquestionbankstoomany', 'question', search_shared_banks::MAX_RESULTS),
'value' => 0,
],
$lastresult
);
}
}