Proyectos de Subversion Moodle

Rev

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.contextid
              FROM {question_categories} c
              JOIN {question_categories} p ON p.id = c.parent
             WHERE p.contextid <> c.contextid
          ORDER 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;
    }
}