MDL-85069 question: Services for question bank and category search

This updates the core_question_search_shared_banks external function
with additional parameters to make it applicable outside of the "Switch
bank" dialogue in the quiz, with the corresponding changes to the
question_banks_datasource Javascript module, and unit test coverage.

It also adds a new \core_question\output\question_category_selector
class for rendering a select list of question categories, migrating
the logic used in qbank_managecategories to a core namespace. There
is a fragment callback for rendering this via AJAX.
This commit is contained in:
Mark Johnson
2025-07-02 10:58:33 +01:00
parent 082a5f7a96
commit f4e8942755
13 changed files with 789 additions and 135 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
@@ -1276,7 +1276,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?
*/
+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)
+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);
}
/**
+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>
+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
);
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
$version = 2025062900.00; // YYYYMMDD = weekly release date of this DEV branch.
$version = 2025062900.01; // YYYYMMDD = weekly release date of this DEV branch.
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.
$release = '5.1dev (Build: 20250629)'; // Human-friendly version name