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 mod_qbank\task;use context_system;use core\context;use core\task\adhoc_task;use core\task\manager;use core_course_category;use core_question\local\bank\question_bank_helper;use stdClass;/*** This script transfers question categories at CONTEXT_SITE, CONTEXT_COURSE, & CONTEXT_COURSECAT to a new qbank instance* context.** Firstly, it finds any question categories where questions are not being used and deletes them, including questions.** Then for any remaining, if it is at course level context, it creates a mod_qbank instance taking the course name* and moves the category there including subcategories, files and tags.** If the original question category context was at system context, then it creates a mod_qbank instance on the site course i.e.* front page and moves the category & sub categories there, along with its files and tags.** If the original question category context was a course category context, then it creates a course in that category,* taking the category name. Then it creates a mod_qbank instance in that course and moves the category & sub categories* there, along with files and tags belonging to those categories.** @package mod_qbank* @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 transfer_question_categories extends adhoc_task {/*** @var array a cache [ context id => question category ] of the top category in each context.* Used by get_top_category_id_for_context() to avoid repeated DB queries.* 0 is cached if this context id has no corresponding top category.*/private array $topcategorycache = [];#[\Override]public function execute(): void {global $DB, $CFG;require_once($CFG->dirroot . '/course/modlib.php');require_once($CFG->libdir . '/questionlib.php');$this->fix_wrong_parents();$recordset = $DB->get_recordset('question_categories', ['parent' => 0]);$movedcategorycontexts = [];foreach ($recordset as $oldtopcategory) {if (!$oldcontext = context::instance_by_id($oldtopcategory->contextid, IGNORE_MISSING)) {// That context does not exist anymore, we will treat these as if they were at site context level.$oldcontext = context_system::instance();}$trans = $DB->start_delegated_transaction();// Remove any unused questions if they are marked as deleted.// Also, if a category contained questions which were all unusable then delete it as well.$subcategories = $DB->get_records_select('question_categories','parent <> 0 AND contextid = :contextid',['contextid' => $oldtopcategory->contextid]);// This gives us categories in parent -> child order so array_reverse it,// because we should process stale categories from the bottom up.$subcategories = array_reverse(sort_categories_by_tree($subcategories, $oldtopcategory->id));foreach ($subcategories as $subcategory) {\qbank_managecategories\helper::question_remove_stale_questions_from_category($subcategory->id);if ($this->question_category_is_empty($subcategory->id)) {question_category_delete_safe($subcategory);}}// If the top category no longer has any subcategories, because they only contained stale questions,// delete the top category and stop here without creating a new qbank.if (!$DB->record_exists('question_categories', ['parent' => $oldtopcategory->id])) {$DB->delete_records('question_categories', ['id' => $oldtopcategory->id]);$trans->allow_commit();continue;}// We don't want to transfer any categories at valid contexts i.e. quiz modules.if ($oldcontext->contextlevel === CONTEXT_MODULE) {$trans->allow_commit();continue;}// Category is in use so let's process it. Firstly, a course and mod instance is needed.switch ($oldcontext->contextlevel) {case CONTEXT_SYSTEM:$course = get_site();$bankname = question_bank_helper::get_bank_name_string('systembank', 'question');break;case CONTEXT_COURSECAT:$coursecategory = core_course_category::get($oldcontext->instanceid);$courseshortname = "$coursecategory->name-$coursecategory->id";$course = $this->create_course($coursecategory, $courseshortname);$bankname = question_bank_helper::get_bank_name_string('sharedbank', 'mod_qbank', $coursecategory->name);break;case CONTEXT_COURSE:$course = get_course($oldcontext->instanceid);$bankname = question_bank_helper::get_bank_name_string('sharedbank', 'mod_qbank', $course->shortname);break;default:// This shouldn't be possible, so we can't really transfer it.// We should commit any pre-transfer category cleanup though.$trans->allow_commit();continue 2;}if (!$newmod = question_bank_helper::get_default_open_instance_system_type($course)) {$newmod = question_bank_helper::create_default_open_instance($course, $bankname, question_bank_helper::TYPE_SYSTEM);}// We have our new mod instance, now move all the subcategories of the old 'top' category to this new context.$movedcategories = $this->move_question_category($oldtopcategory, $newmod->context);$movedcategorycontexts += array_fill_keys($movedcategories, $oldtopcategory->contextid);// Job done, lets delete the old 'top' category.$DB->delete_records('question_categories', ['id' => $oldtopcategory->id]);$trans->allow_commit();}$recordset->close();// Create a set of new tasks to update the questions in each category to the new contexts.// The category itself is already in the new context. We record the old context so we know where to move// files and tags from.foreach ($movedcategorycontexts as $categoryid => $oldcontextid) {$task = new transfer_questions();$task->set_custom_data(['categoryid' => $categoryid, 'contextid' => $oldcontextid]);manager::queue_adhoc_task($task);}}/*** Wrapper for \create_course.** @param core_course_category $coursecategory* @param string $shortname* @return stdClass*/protected function create_course(core_course_category $coursecategory, string $shortname): stdClass {$data = (object) ['enablecompletion' => 0,'fullname' => get_string('coursecategory', 'mod_qbank', $coursecategory->name),'shortname' => $shortname,'category' => $coursecategory->id,];return create_course($data);}/*** Create a new 'Top' category in our new context and move the old categories descendents beneath it.** @param stdClass $oldtopcategory The old 'Top' category that we are moving.* @param context\module $newcontext The context we are moving our category to.* @return int[] The IDs of all categories moved to the new context.*/protected function move_question_category(stdClass $oldtopcategory, context\module $newcontext): array {global $DB;$newtopcategory = question_get_top_category($newcontext->id, true);move_question_set_references($oldtopcategory->id, $newtopcategory->id, $oldtopcategory->contextid, $newcontext->id, true);// This function moves subcategories, so we have to start at the top.$movedcategories = $this->move_subcategories_to_context($oldtopcategory->id, $newcontext);// Move the parent from the old top category to the new one.$DB->set_field('question_categories', 'parent', $newtopcategory->id, ['parent' => $oldtopcategory->id]);return $movedcategories;}/*** Recursively update the contextid for all subcategories of the given category.** @param int $categoryid The ID of the category to update subcategories for. When calling directly,* this should be a top category.* @param context\module $newcontext The new context for the subcategories.* @return int[] The IDs of all categories moved to the new context.*/protected function move_subcategories_to_context(int $categoryid, context\module $newcontext): array {global $DB;$movedcategories = [];$subcatids = $DB->get_records('question_categories', ['parent' => $categoryid]);foreach ($subcatids as $subcatid => $data) {// Because of the fallback above, where categories pointing to a// missing contextid are all moved to the new shared system-level// question bank, some categories are moved from previously// separate contextids to the same context. This can violate// unique indexes, so we fix this by ensuring uniqueness.// For the stamp, we just generate a new stamp if required.if ($DB->record_exists('question_categories', ['stamp' => $data->stamp, 'contextid' => $newcontext->id])) {$data->stamp = make_unique_id_code();}// The idnumber we just reset duplicates to null, as is done in other places.if ($data->idnumber !== null &&$DB->record_exists('question_categories', ['idnumber' => $data->idnumber, 'contextid' => $newcontext->id])) {$data->idnumber = null;}// Update the contextid and save the category.$data->contextid = $newcontext->id;$DB->update_record('question_categories', $data);$movedcategories[] = $subcatid;$movedcategories = array_merge($this->move_subcategories_to_context($subcatid, $newcontext),$movedcategories,);}return $movedcategories;}/*** Find the Top category for a context, if there is one.** @param int $contextid the id of a context (which might not exist).* @return int a Top category id, or 0 if none is found.*/protected function get_top_category_id_for_context(int $contextid): int {global $DB;// Use the cache if we have already loaded this.if (array_key_exists($contextid, $this->topcategorycache)) {return $this->topcategorycache[$contextid];}$topcategoryid = (int) $DB->get_field('question_categories', 'id',['contextid' => $contextid, 'parent' => 0]);$this->topcategorycache[$contextid] = $topcategoryid;return $topcategoryid;}/*** Fix the context of child categories whose contextid does not match that of their parents.** Fix here means:** - if the child category's context exists, and has a 'Top' category, we move the child* category to be just under that Top category. That is where they would have appeared* before, e.g. in the return from question_categorylist().** - if the child category points to a context that does not exist at all, then we* instead change its context to be the same as it's parent's context. This may* break things like images in the question text of questions there, but there is* no real alternative.** This is necessary because, due to old bugs, for example in backup and restoree code,* we know there can be question categories in the databases of old Moodle sites with* the wrong context id.*/public function fix_wrong_parents(): void {global $DB;$categoriestofix = $this->get_categories_in_a_different_context_to_their_parent();foreach ($categoriestofix as $childcategoryid => $childcontextid) {$topcategoryid = $this->get_top_category_id_for_context($childcontextid);if ($topcategoryid) {// Suitable Top category in the child's current context, so move to be a parent of that.$DB->set_field('question_categories', 'parent', $topcategoryid, ['id' => $childcategoryid]);} else {// Top not found. Change the child to have the same context as its parent.// This is not efficient in DB queries, but we expect this to be a rare case, and this is simple and right.$childcategory = $DB->get_record('question_categories', ['id' => $childcategoryid]);$parentcontextid = $DB->get_field('question_categories', 'contextid', ['id' => $childcategory->parent]);$this->move_category_and_its_children($childcategoryid, $parentcontextid);}}}/*** Get question categories that are in a different context to their parent.** @return int[] child category id => context id of the child category.*/public function get_categories_in_a_different_context_to_their_parent(): array {global $DB;return $DB->get_records_sql_menu('SELECT c.id, c.contextidFROM {question_categories} cJOIN {question_categories} p ON p.id = c.parentWHERE p.contextid <> c.contextidORDER BY c.id');}/*** Set the contextid of category $categoryid and all its children to $newcontextid.** @param int $categoryid a question_category id.* @param int $newcontextid the place to move to.*/public function move_category_and_its_children(int $categoryid, int $newcontextid): void {global $DB;$DB->set_field('question_categories', 'contextid', $newcontextid, ['id' => $categoryid]);$children = $DB->get_records('question_categories', ['parent' => $categoryid], '', 'id, contextid');foreach ($children as $child) {if ($child->contextid != $newcontextid) {$this->move_category_and_its_children($child->id, $newcontextid);}}}/*** Recursively check if a question category or its children contain any questions.** @param int $categoryid The parent category to check from.* @return bool True if neither the category nor its children contain any questions.*/protected function question_category_is_empty(int $categoryid): bool {global $DB;if ($DB->record_exists('question_bank_entries', ['questioncategoryid' => $categoryid])) {return false;}// If this category is empty, recursively check child categories.$childcategoryids = $DB->get_fieldset('question_categories', 'id', ['parent' => $categoryid]);foreach ($childcategoryids as $childcategoryid) {if (!$this->question_category_is_empty($childcategoryid)) {// If we found questions in a child, we don't want to check any other children.return false;}}return true;}}