AutorÃa | Ultima modificación | Ver Log |
<?php// This file is part of Moodle - http://moodle.org///// Moodle is free software: you can redistribute it and/or modify// it under the terms of the GNU General Public License as published by// the Free Software Foundation, either version 3 of the License, or// (at your option) any later version.//// Moodle is distributed in the hope that it will be useful,// but WITHOUT ANY WARRANTY; without even the implied warranty of// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the// GNU General Public License for more details.//// You should have received a copy of the GNU General Public License// along with Moodle. If not, see <http://www.gnu.org/licenses/>.namespace core_question\local\bank;use cm_info;use context;use context_course;use core\task\manager;use moodle_url;use stdClass;defined('MOODLE_INTERNAL') || die();require_once($CFG->dirroot . '/lib/questionlib.php');require_once($CFG->dirroot . '/course/modlib.php');/*** Helper class for qbank sharing.** @package core_question* @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net}* @author Simon Adams <simon.adams@catalyst-eu.net>* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/class question_bank_helper {/** @var string the type of qbank module that users create */public const TYPE_STANDARD = 'standard';/*** The type of shared bank module that the system creates.* These are created in course restores when no target context can be found,* and also for when a question category cannot be deleted safely due to questions being in use.** @var string*/public const TYPE_SYSTEM = 'system';/** @var string The type of shared bank module that the system creates for previews. Not used for any other purpose. */public const TYPE_PREVIEW = 'preview';/** @var array Shared bank types */public const SHARED_TYPES = [self::TYPE_STANDARD, self::TYPE_SYSTEM, self::TYPE_PREVIEW];/*** User preferences record key to store recently viewed question banks.*/protected const RECENTLY_VIEWED = 'recently_viewed_open_banks';/*** Category delimiter used by the SQL to group concatenate question category data i.e.* category_id<->category_name<->context_id.*/private const CATEGORY_DELIMITER = '<->';/*** Category separator used by the SQL for group concatenation of those category triplets* from above.*/private const CATEGORY_SEPARATOR = '<,>';/*** Maximum length for the question bank name database field.*/public const BANK_NAME_MAX_LENGTH = 255;/*** Modules that share questions via FEATURE_PUBLISHES_QUESTIONS.** @return array*/public static function get_activity_types_with_shareable_questions(): array {static $sharedmods;if (!empty($sharedmods)) {return $sharedmods;}$manager = \core_plugin_manager::instance();$plugins = $manager->get_enabled_plugins('mod');$sharedmods = array_filter(array_keys($plugins),static fn ($plugin) => plugin_supports('mod', $plugin, FEATURE_PUBLISHES_QUESTIONS) &&question_module_uses_questions($plugin));return array_values($sharedmods);}/*** Get module types that do not share questions. They will have FEATURE_USES_QUESTIONS set to false or won't have it defined.** @return array*/public static function get_activity_types_with_private_questions(): array {static $privatemods;if (!empty($privatemods)) {return $privatemods;}$manager = \core_plugin_manager::instance();$plugins = $manager->get_enabled_plugins('mod');$privatemods = array_filter(array_keys($plugins),static fn ($plugin) => !plugin_supports('mod', $plugin, FEATURE_PUBLISHES_QUESTIONS) &&question_module_uses_questions($plugin));return array_values($privatemods);}/*** Get records for activity modules that do publish questions, and optionally get their question categories too.** @param array $incourseids array of course ids where you want instances included. Leave empty if you want from all courses.* @param array $notincourseids array of course ids where you do not want instances included.* @param array $havingcap current user must have at least one of these capabilities on each bank context.* @param bool $getcategories optionally return the categories belonging to these banks.* @param int $currentbankid optionally include the bank id you want included as the first result from the method return.* it will only be included if the other parameters allow it.* @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with* parameters that will get banks across multiple contexts.* @param string $search Optional term to search question bank instances by name* @param int $limit The number of results to return (default 0 = no limit)* @return stdClass[]*/public static function get_activity_instances_with_shareable_questions(array $incourseids = [],array $notincourseids = [],array $havingcap = [],bool $getcategories = false,int $currentbankid = 0,?context $filtercontext = null,string $search = '',int $limit = 0,): array {return self::get_bank_instances(true,$incourseids,$notincourseids,$getcategories,$currentbankid,$havingcap,$filtercontext,$search,$limit,);}/*** Get records for activity modules that don't publish questions, and optionally get their question categories too.** @param array $incourseids array of course ids where you want instances included. Leave empty if you want from all courses.* @param array $notincourseids array of course ids where you do not want instances included.* @param array $havingcap current user must have at least one of these capabilities on each bank context.* @param bool $getcategories optionally return the categories belonging to these banks.* @param int $currentbankid optionally include the bank id you want included as the first result from the method return.* it will only be included if the other parameters allow it.* @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with* parameters that will get banks across multiple contexts.* @return stdClass[]*/public static function get_activity_instances_with_private_questions(array $incourseids = [],array $notincourseids = [],array $havingcap = [],bool $getcategories = false,int $currentbankid = 0,?context $filtercontext = null,): array {return self::get_bank_instances(false,$incourseids,$notincourseids,$getcategories,$currentbankid,$havingcap,$filtercontext,);}/*** Private method to build the SQL and get records from the DB. Called from public API methods* {@see self::get_activity_instances_with_shareable_questions()}* {@see self::get_activity_instances_with_private_questions()}** @param bool $isshared true if you want instances that publish questions false if you want instances that don't* @param array $incourseids array of course ids where you want instances included. Leave empty if you want from all courses.* @param array $notincourseids array of course ids where you do not want instances included.* @param bool $getcategories optionally return the categories belonging to these banks.* @param int $currentbankid optionally include the bank id you want included as the first result from the method return.* it will only be included if the other parameters allow it.* @param array $havingcap current user must have at least one of these capabilities on each bank context.* @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with* parameters that will get banks across multiple contexts.* @param string $search Optional term to search question bank instances by name* @param int $limit The number of results to return (default 0 = no limit)* @return stdClass[]*/private static function get_bank_instances(bool $isshared,array $incourseids = [],array $notincourseids = [],bool $getcategories = false,int $currentbankid = 0,array $havingcap = [],?context $filtercontext = null,string $search = '',int $limit = 0,): array {global $DB;$pluginssql = [];$params = [];// Build the SELECT portion of the SQL and include question category joins as required.if ($getcategories) {$concat = $DB->sql_concat('qc.id',"'" . self::CATEGORY_DELIMITER . "'",'qc.name',"'" . self::CATEGORY_DELIMITER . "'",'qc.contextid');$groupconcat = $DB->sql_group_concat($concat, self::CATEGORY_SEPARATOR);$select = "SELECT cm.id, cm.course, {$groupconcat} AS cats";$catsql = ' JOIN {context} c ON c.instanceid = cm.id AND c.contextlevel = ' . CONTEXT_MODULE .' JOIN {question_categories} qc ON qc.contextid = c.id AND qc.parent <> 0';} else {$select = 'SELECT cm.id, cm.course';$catsql = '';}if ($isshared) {$plugins = self::get_activity_types_with_shareable_questions();} else {$plugins = self::get_activity_types_with_private_questions();}if (empty($plugins)) {return [];}// Build the joins for all modules of the type requested i.e. those that do or do not share questions.foreach ($plugins as $key => $plugin) {$moduleid = $DB->get_field('modules', 'id', ['name' => $plugin]);$sql = "JOIN {{$plugin}} p{$key} ON p{$key}.id = cm.instanceAND cm.module = {$moduleid} AND cm.deletioninprogress = 0";if ($plugin === self::get_default_question_bank_activity_name()) {$sql .= " AND p{$key}.type <> '" . self::TYPE_PREVIEW . "'";}if (!empty($search)) {$sql .= " AND " . $DB->sql_like("p{$key}.name", ":search{$key}", false);$params["search{$key}"] = "%{$search}%";}$pluginssql[] = $sql;}$pluginssql = implode(' ', $pluginssql);// Build the SQL to filter out any requested course ids.if (!empty($notincourseids)) {[$notincoursesql, $notincourseparams] = $DB->get_in_or_equal($notincourseids, SQL_PARAMS_NAMED, 'param', false);$notincoursesql = "AND cm.course {$notincoursesql}";$params = array_merge($params, $notincourseparams);} else {$notincoursesql = '';}// Build the SQL to include ONLY records belonging to the requested courses.if (!empty($incourseids)) {[$incoursesql, $incourseparams] = $DB->get_in_or_equal($incourseids, SQL_PARAMS_NAMED);$incoursesql = " AND cm.course {$incoursesql}";$params = array_merge($params, $incourseparams);} else {$incoursesql = '';}// Optionally order the results by the requested bank id.if (!empty($currentbankid)) {$orderbysql = " ORDER BY CASE WHEN cm.id = :currentbankid THEN 0 ELSE 1 END ASC, cm.id DESC ";$params['currentbankid'] = $currentbankid;} else {$orderbysql = '';}$sql = "{$select}FROM {course_modules} cmJOIN {modules} m ON m.id = cm.module{$pluginssql}{$catsql}WHERE 1=1 {$notincoursesql} {$incoursesql}GROUP BY cm.id, cm.course{$orderbysql}";$rs = $DB->get_recordset_sql($sql, $params, limitnum: $limit);$banks = [];foreach ($rs as $cm) {// If capabilities have been supplied as a method argument then ensure the viewing user has at least one of those// capabilities on the module itself.if (!empty($havingcap)) {$context = \context_module::instance($cm->id);if (!(new question_edit_contexts($context))->have_one_cap($havingcap)) {continue;}}// Populate the raw record.$banks[] = self::get_formatted_bank($cm, $currentbankid, filtercontext: $filtercontext);}$rs->close();return $banks;}/*** Get a list of recently viewed question banks that implement FEATURE_PUBLISHES_QUESTIONS.* If any of the stored contexts don't exist anymore then update the user preference record accordingly.** @param int $userid of the user to get recently viewed banks for.* @param int $notincourseid if supplied don't return any in this course id* @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with* parameters that will get banks across multiple contexts.* @return cm_info[]*/public static function get_recently_used_open_banks(int $userid,int $notincourseid = 0,?context $filtercontext = null,array $havingcap = [],): array {$prefs = get_user_preferences(self::RECENTLY_VIEWED, null, $userid);$contextids = !empty($prefs) ? explode(',', $prefs) : [];if (empty($contextids)) {return $contextids;}$invalidcontexts = [];$banks = [];foreach ($contextids as $contextid) {if (!$context = context::instance_by_id($contextid, IGNORE_MISSING)) {$invalidcontexts[] = $context;continue;}if ($context->contextlevel !== CONTEXT_MODULE) {throw new \moodle_exception('Invalid question bank contextlevel: ' . $context->contextlevel);}[, $cm] = get_module_from_cmid($context->instanceid);if (!empty($notincourseid) && $notincourseid == $cm->course) {continue;}if (!empty($havingcap) && !(new question_edit_contexts($context))->have_one_cap($havingcap)) {continue;}$record = self::get_formatted_bank($cm, filtercontext: $filtercontext);$banks[] = $record;}if (!empty($invalidcontexts)) {$tostore = array_diff($contextids, $invalidcontexts);$tostore = implode(',', $tostore);set_user_preference(self::RECENTLY_VIEWED, $tostore, $userid);}return $banks;}/*** Mark a user as having viewed a question bank in the user_preferences table with key {@see self::RECENTLY_VIEWED}** @param context $bankcontext add this bank context to the viewing user's list of recently viewed.* @return void*/public static function add_bank_context_to_recently_viewed(context $bankcontext): void {[, $cm] = get_module_from_cmid($bankcontext->instanceid);if (!plugin_supports('mod', $cm->modname, FEATURE_PUBLISHES_QUESTIONS)) {return;}$userprefs = get_user_preferences(self::RECENTLY_VIEWED);$recentlyviewed = !empty($userprefs) ? explode(',', $userprefs) : [];$recentlyviewed = array_combine($recentlyviewed, $recentlyviewed);$tostore = [];$tostore[] = $bankcontext->id;if (!empty($recentlyviewed[$bankcontext->id])) {unset($recentlyviewed[$bankcontext->id]);}$tostore = array_merge($tostore, array_values($recentlyviewed));$tostore = array_slice($tostore, 0, 5);set_user_preference(self::RECENTLY_VIEWED, implode(',', $tostore));}/*** Populate the raw record with data for use in rendering.** @param stdClass $cm raw course_modules record to populate data from.* @param int $currentbankid set an 'enabled' flag on the instance that matched this id.* Used in qbank_bulkmove/bulk_move.mustache* @param ?context $filtercontext Optional context in which to apply filters.** @return stdClass*/private static function get_formatted_bank(stdClass $cm, int $currentbankid = 0, ?context $filtercontext = null): stdClass {$cminfo = cm_info::create($cm);$concatedcats = !empty($cm->cats) ? explode(self::CATEGORY_SEPARATOR, $cm->cats) : [];$categories = array_map(static function($concatedcategory) use ($cminfo, $currentbankid) {$values = explode(self::CATEGORY_DELIMITER, $concatedcategory);$cat = new stdClass();$cat->id = $values[0];$cat->name = $values[1];$cat->contextid = $values[2];$cat->enabled = $cminfo->id == $currentbankid ? 'enabled' : 'disabled';return $cat;}, $concatedcats);$bank = new stdClass();$filteroptions = ['escape' => false];if (!is_null($filtercontext)) {$filteroptions['context'] = $filtercontext;}$bank->name = $cminfo->get_formatted_name($filteroptions);$bank->modid = $cminfo->id;$bank->contextid = $cminfo->context->id;if (!isset($filteroptions['context'])) {$filteroptions['context'] = context_course::instance($cminfo->get_course()->id);}$bank->coursenamebankname = format_string($cminfo->get_course()->shortname, true, $filteroptions) . " - {$bank->name}";$bank->cminfo = $cminfo;$bank->questioncategories = $categories;return $bank;}/*** Get the system type qbank instance for this course, optionally create it if it does not yet exist.* {@see self::TYPE_SYSTEM}** @param stdClass $course the course to get the default system type bank for.* @param bool $createifnotexists create a default bank if it does not exist.* @return cm_info|null*/public static function get_default_open_instance_system_type(stdClass $course, bool $createifnotexists = false): ?cm_info {$modinfo = get_fast_modinfo($course);$qbanks = $modinfo->get_instances_of(self::get_default_question_bank_activity_name());$systembank = null;if ($systembankids = self::get_qbank_ids_of_type_in_course($course, self::TYPE_SYSTEM)) {// We should only ever have 1 of these.$systembankid = reset($systembankids);// Filter the course modinfo qbanks by the systembankid.$systembanks = array_filter($qbanks, static fn($bank) => $bank->id === $systembankid);$systembank = !empty($systembanks) ? reset($systembanks) : null;}if (!$systembank && $createifnotexists) {$systembank = self::create_default_open_instance($course,self::get_bank_name_string('systembank', 'question'),self::TYPE_SYSTEM,);}return $systembank;}/*** Get the bank that is used for preview purposes only, optionally create it if it does not yet exist.* {@see \qbank_columnsortorder\column_manager::get_questionbank()}** @param bool $createifnotexists create a default bank if it does not exist.* @return cm_info|null*/public static function get_preview_open_instance_type(bool $createifnotexists = false): ?cm_info {$site = get_site();$modinfo = get_fast_modinfo($site);$qbanks = $modinfo->get_instances_of(self::get_default_question_bank_activity_name());$previewbank = null;if ($previewbankids = self::get_qbank_ids_of_type_in_course($site, self::TYPE_PREVIEW)) {// We should only ever have 1 of these.$previewbankid = reset($previewbankids);// Filter the course modinfo qbanks by the previewbankid.$previewbanks = array_filter($qbanks, static fn($bank) => $bank->id === $previewbankid);$previewbank = !empty($previewbanks) ? reset($previewbanks) : null;}if (!$previewbank && $createifnotexists) {$previewbank = self::create_default_open_instance($site,self::get_bank_name_string('previewbank', 'question'),self::TYPE_PREVIEW);}return $previewbank;}/*** Get course module ids from qbank instances on a course that are of the sub-type provided.** @param stdClass $course the course to search* @param string $subtype the subtype of the qbank module {@see self::SHARED_TYPES}* @return int[]*/private static function get_qbank_ids_of_type_in_course(stdClass $course, string $subtype): array {global $DB;if (!in_array($subtype, self::SHARED_TYPES)) {throw new \moodle_exception('Invalid question bank type: ' . $subtype);}$modinfo = get_fast_modinfo($course);$defaultyactivityname = self::get_default_question_bank_activity_name();$qbanks = $modinfo->get_instances_of($defaultyactivityname);$whereclause = "AND m.name = '" . $defaultyactivityname . "'";if (!empty($qbanks)) {$sql = "SELECT cm.idFROM {course_modules} cmJOIN {modules} m ON m.id = cm.moduleJOIN {{$defaultyactivityname}} q ON q.id = cm.instanceWHERE cm.course = :courseAND q.type = :type " .$whereclause;return $DB->get_fieldset_sql($sql, ['type' => $subtype, 'course' => $course->id]);}return [];}/*** Create a bank on the course from default options.** @param stdClass $course the course that the new module is being created in* @param string $bankname name of the new module* @param string $type {@see self::TYPES}* @return cm_info*/public static function create_default_open_instance(stdClass $course,string $bankname,string $type = self::TYPE_STANDARD): cm_info {global $DB;if (!in_array($type, self::SHARED_TYPES)) {throw new \RuntimeException('invalid type');}// Preview bank must be created at site course.if ($type === self::TYPE_PREVIEW) {if ($qbank = self::get_preview_open_instance_type()) {return $qbank;}$course = get_site();}// We can only have one of these types per course.if ($type === self::TYPE_SYSTEM && $qbank = self::get_default_open_instance_system_type($course)) {return $qbank;}$module = $DB->get_record('modules', ['name' => self::get_default_question_bank_activity_name()], '*', MUST_EXIST);$context = context_course::instance($course->id);// STANDARD type needs capability checks.if ($type === self::TYPE_STANDARD) {require_capability('moodle/course:manageactivities', $context);if (!course_allowed_module($course, $module->name)) {throw new \moodle_exception('moduledisable');}}if (\core_text::strlen($bankname) > self::BANK_NAME_MAX_LENGTH) {throw new \coding_exception('The provided bankname is too long for the database field.','Use question_bank_helper::get_bank_name_string to get a suitably truncated name.',);}$data = new stdClass();$data->section = 0;$data->visible = 0;$data->course = $course->id;$data->module = $module->id;$data->modulename = $module->name;$data->groupmode = $course->groupmode;$data->groupingid = $course->defaultgroupingid;$data->id = '';$data->instance = '';$data->coursemodule = '';$data->downloadcontent = DOWNLOAD_COURSE_CONTENT_ENABLED;$data->visibleoncoursepage = 0;$data->name = $bankname;$data->type = in_array($type, self::SHARED_TYPES) ? $type : self::TYPE_STANDARD;$data->showdescription = $type === self::TYPE_STANDARD ? 0 : 1;// Don't create the default category if this is being created by the system as part of a migration or restore,// existing categories will be migrated to the new context.$data->skipdefaultcategory = $type === self::TYPE_SYSTEM;$mod = add_moduleinfo($data, $course);// Have to set this manually as the system because this bank type is not intended to be created directly by a user.if ($type === self::TYPE_SYSTEM) {$DB->set_field($module->name, 'intro', get_string('systembankdescription', 'question'), ['id' => $mod->instance]);$DB->set_field($module->name, 'introformat', FORMAT_HTML, ['id' => $mod->instance]);}return get_fast_modinfo($course)->get_cm($mod->coursemodule);}/*** Get the url that shows the banks list of a course.** @param int $courseid of the course to get the url for.* @param bool $createdefault Pass true if you want the URL to create a default qbank instance when referred.* @return moodle_url*/public static function get_url_for_qbank_list(int $courseid, bool $createdefault = false): moodle_url {$url = new moodle_url('/question/banks.php', ['courseid' => $courseid]);if ($createdefault) {$url->param('createdefault', true);}return $url;}/*** This task should only ever be called once, on install/upgrade. But we may need to warn the user on some pages* that some banks may not have been transferred yet if it failed or hasn't yet completed.** @return bool*/public static function has_bank_migration_task_completed_successfully(): bool {$defaultbank = self::get_default_question_bank_activity_name();$task = manager::get_adhoc_tasks("\\mod_{$defaultbank}\\task\\transfer_question_categories");$subtasks = manager::get_adhoc_tasks("\\mod_{$defaultbank}\\task\\transfer_questions");return empty($task) && empty($subtasks);}/*** Get the activity plugin name that will be the type used for default bank creation and management.** @return string*/public static function get_default_question_bank_activity_name(): string {global $CFG;return $CFG->corequestion_defaultqbankmod ?? 'qbank';}/*** Get the requested language string, with parameters truncated to ensure the result fits in the database.** Since we may be generating a question bank name based on an existing course or category name, we need to ensure* that the resulting string isn't longer than the maximum module name.** @param string $identifier The string identifier* @param string $component The string component* @param mixed|null $params The string parameters (a single string, array or object as accepted by get_string)* @return string The string truncated to a length that will fit in the database.*/public static function get_bank_name_string(string $identifier, string $component, mixed $params = null): string {if (is_object($params)) {$shortparams = (array) $params;} else {$shortparams = $params;}$bankname = get_string($identifier, $component, $shortparams);if (!is_null($shortparams)) {$trimlength = self::BANK_NAME_MAX_LENGTH - 4;while (\core_text::strlen($bankname) > self::BANK_NAME_MAX_LENGTH && $trimlength > 0) {// Gradually shorten the string parameters until the resulting string is short enough.if (is_array($shortparams)) {$shortparams = array_map(fn($param) => shorten_text(trim($param), $trimlength), $shortparams);} else {$shortparams = shorten_text(trim($shortparams), $trimlength);}$bankname = get_string($identifier, $component, $shortparams);$trimlength -= 10;}}// As a failsafe, limit the length of the final string in case the lang string is too long.return shorten_text($bankname, self::BANK_NAME_MAX_LENGTH);}}