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;use stdClass;use core\exception\moodle_exception;use core\context;/*** Category manager class, used for CRUD operations on question categories and related utility methods.** @copyright 2024 Catalyst IT Europe Ltd.* @author Mark Johnson <mark.johnson@catalyst-eu.net>* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later* @package core_question*/class category_manager {/*** Cached checks for managecategories permissions in each context.** @var array $managedcontexts;*/protected array $managedcontexts = [];/*** Deletes an existing question category.** @param int $categoryid id of category to delete.*/public function delete_category(int $categoryid): void {global $DB;$this->require_can_delete_category($categoryid);$category = $DB->get_record('question_categories', ['id' => $categoryid]);$transaction = $DB->start_delegated_transaction();// Send the children categories to live with their grandparent.$DB->set_field('question_categories', 'parent', $category->parent, ['parent' => $category->id]);// Finally delete the category itself.$DB->delete_records('question_categories', ['id' => $category->id]);// Log the deletion of this category.$event = \core\event\question_category_deleted::create_from_question_category_instance($category);$event->add_record_snapshot('question_categories', $category);$event->trigger();$transaction->allow_commit();}/*** Move questions and then delete the category.** @param int $oldcat id of the old category.* @param int $newcat id of the new category.*/public function move_questions_and_delete_category(int $oldcat, int $newcat): void {global $DB;$transaction = $DB->start_delegated_transaction();$this->require_can_delete_category($oldcat);$this->move_questions($oldcat, $newcat);$this->delete_category($oldcat);$transaction->allow_commit();}/*** Checks whether the category is a "Top" category (with no parent).** @param int $categoryid a category id.* @return bool* @throws \dml_exception*/public function is_top_category(int $categoryid): bool {global $DB;return 0 == $DB->get_field('question_categories', 'parent', ['id' => $categoryid]);}/*** Checks whether this is the only child of a top category in a context.** @param int $categoryid a category id.* @return bool* @throws \dml_exception*/public function is_only_child_of_top_category_in_context(int $categoryid): bool {global $DB;return 1 == $DB->count_records_sql("SELECT count(siblingcategory.id)FROM {question_categories} thiscategoryJOIN {question_categories} parentcategory ON thiscategory.parent = parentcategory.idJOIN {question_categories} siblingcategory ON siblingcategory.parent = thiscategory.parentWHERE thiscategory.id = ? AND parentcategory.parent = 0", [$categoryid]);}/*** Ensures that this user is allowed to delete this category.** @param int $todelete a category id.* @throws \required_capability_exception* @throws \dml_exception|moodle_exception*/public function require_can_delete_category(int $todelete): void {global $DB;if ($this->is_top_category($todelete)) {throw new moodle_exception('cannotdeletetopcat', 'question');} else if ($this->is_only_child_of_top_category_in_context($todelete)) {throw new moodle_exception('cannotdeletecate', 'question');} else {$contextid = $DB->get_field('question_categories', 'contextid', ['id' => $todelete], MUST_EXIST);$this->require_manage_category(context::instance_by_id($contextid));}}/*** Move questions to another category.** @param int $oldcat id of the old category.* @param int $newcat id of the new category.* @throws \dml_exception*/public function move_questions(int $oldcat, int $newcat): void {$questionids = $this->get_real_question_ids_in_category($oldcat);question_move_questions_to_category($questionids, $newcat);}/*** Check the user can manage categories in the given context.** This caches a successful check in $this->managedcontexts in case we check the same context multiple times.** @param context $context* @return void* @throws \required_capability_exception*/public function require_manage_category(context $context): void {if (!array_key_exists($context->id, $this->managedcontexts)) {require_capability('moodle/question:managecategory', $context);$this->managedcontexts[$context->id] = true;}}/*** Check there is no question category with the given ID number in the given context.** @param ?string $idnumber The ID number to look for.* @param int $contextid The context to check the categories in.* @param ?int $excludecategoryid If set, exclude this category from the check (e.g. if this is the one being edited).* @return bool* @throws \dml_exception*/public function idnumber_is_unique_in_context(?string $idnumber, int $contextid, ?int $excludecategoryid = null): bool {global $DB;if (empty($idnumber)) {return true;}$where = 'idnumber = ? AND contextid = ?';$params = [$idnumber, $contextid];if ($excludecategoryid) {$where .= ' AND id != ?';$params[] = $excludecategoryid;}return !$DB->record_exists_select('question_categories', $where, $params);}/*** Create a new category.** Data is expected to come from question_category_edit_form.** By default redirects on success, unless $return is true.** @param string $newparent 'categoryid,contextid' of the parent category.* @param string $newcategory the name.* @param string $newinfo the description.* @param string $newinfoformat description format. One of the FORMAT_ constants.* @param ?string $idnumber the idnumber. '' is converted to null.* @return int New category id.*/public function add_category(string $newparent,string $newcategory,string $newinfo,string $newinfoformat = FORMAT_HTML,?string $idnumber = null,): int {global $DB;if (empty($newcategory)) {throw new moodle_exception('categorynamecantbeblank', 'question');}[$parentid, $contextid] = explode(',', $newparent);// ...moodle_form makes sure select element output is legal no need for further cleaning.$this->require_manage_category(context::instance_by_id($contextid));if ($parentid) {if (!($DB->get_field('question_categories', 'contextid', ['id' => $parentid]) == $contextid)) {throw new moodle_exception('cannotinsertquestioncatecontext','question','',['cat' => $newcategory, 'ctx' => $contextid],);}}if (!$this->idnumber_is_unique_in_context($idnumber, $contextid)) {throw new moodle_exception('idnumbertaken', 'error');}if ((string)$idnumber === '') {$idnumber = null;}$transaction = $DB->start_delegated_transaction();$cat = new stdClass();$cat->parent = $parentid;$cat->contextid = $contextid;$cat->name = $newcategory;$cat->info = $newinfo;$cat->infoformat = $newinfoformat;$cat->sortorder = $this->get_max_sortorder($parentid) + 1;$cat->stamp = make_unique_id_code();$cat->idnumber = $idnumber;$categoryid = $DB->insert_record("question_categories", $cat);// Log the creation of this category.$category = new stdClass();$category->id = $categoryid;$category->contextid = $contextid;$event = \core\event\question_category_created::create_from_question_category_instance($category);$event->trigger();$transaction->allow_commit();return $categoryid;}/*** Updates an existing category with given params.** Warning! parameter order and meaning confusingly different from add_category in some ways!** @param int $updateid id of the category to update.* @param string $newparent 'categoryid,contextid' of the parent category to set.* @param string $newname category name.* @param string $newinfo category description.* @param string $newinfoformat description format. One of the FORMAT_ constants.* @param ?string $idnumber the idnumber. '' is converted to null.* @param ?int $sortorder The updated sortorder. Not updated if null.*/public function update_category(int $updateid,string $newparent,string $newname,string $newinfo,string $newinfoformat = FORMAT_HTML,?string $idnumber = null,?int $sortorder = null,): void {global $DB;if (empty($newname)) {throw new moodle_exception('categorynamecantbeblank', 'question');}// Get the record we are updating.$oldcat = $DB->get_record('question_categories', ['id' => $updateid]);$lastcategoryinthiscontext = $this->is_only_child_of_top_category_in_context($updateid);if (!empty($newparent) && !$lastcategoryinthiscontext) {[$parentid, $tocontextid] = explode(',', $newparent);} else {$parentid = $oldcat->parent;$tocontextid = $oldcat->contextid;}// Check permissions.$fromcontext = context::instance_by_id($oldcat->contextid);$this->require_manage_category($fromcontext);// If moving to another context, check permissions some more, and confirm contextid,stamp uniqueness.$newstamprequired = false;if ($oldcat->contextid != $tocontextid) {$tocontext = context::instance_by_id($tocontextid);$this->require_manage_category($tocontext);// Confirm stamp uniqueness in the new context. If the stamp already exists, generate a new one.if ($DB->record_exists('question_categories', ['contextid' => $tocontextid, 'stamp' => $oldcat->stamp])) {$newstamprequired = true;}}if (!$this->idnumber_is_unique_in_context($idnumber, $tocontextid, $updateid)) {throw new moodle_exception('idnumbertaken', 'error');}if ((string)$idnumber === '') {$idnumber = null;}$transaction = $DB->start_delegated_transaction();// Update the category record.$cat = new stdClass();$cat->id = $updateid;$cat->name = $newname;$cat->info = $newinfo;$cat->infoformat = $newinfoformat;$cat->parent = $parentid;$cat->contextid = $tocontextid;$cat->idnumber = $idnumber;if ($newstamprequired) {$cat->stamp = make_unique_id_code();}if ($sortorder) {$cat->sortorder = $sortorder;}$DB->update_record('question_categories', $cat);// Update the set_reference records when moving a category to a different context.move_question_set_references($cat->id, $cat->id, $oldcat->contextid, $tocontextid);// Log the update of this category.$event = \core\event\question_category_updated::create_from_question_category_instance($cat);$event->trigger();if ($oldcat->contextid != $tocontextid) {// Moving to a new context. Must move files belonging to questions.question_move_category_to_context($cat->id, $oldcat->contextid, $tocontextid);}$transaction->allow_commit();}/*** Returns ids of the question in the given question category.** This method only returns the real question. It does not include* subquestions of question types like multianswer.** @param int $categoryid id of the category.* @return int[] array of question ids.*/public function get_real_question_ids_in_category(int $categoryid): array {global $DB;$sql = "SELECT q.idFROM {question} qJOIN {question_versions} qv ON qv.questionid = q.idJOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryidWHERE qbe.questioncategoryid = :categoryidAND (q.parent = 0 OR q.parent = q.id)";$questionids = $DB->get_records_sql($sql, ['categoryid' => $categoryid]);return array_keys($questionids);}/*** Get current max sort in a given parent** @param int $parentid The ID of the parent category.* @return int current max sort order*/public function get_max_sortorder(int $parentid): int {global $DB;$sql = "SELECT MAX(sortorder)FROM {question_categories}WHERE parent = :parent";$lastmax = $DB->get_field_sql($sql, ['parent' => $parentid]);return $lastmax ?? 0;}}