. 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 * @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} thiscategory JOIN {question_categories} parentcategory ON thiscategory.parent = parentcategory.id JOIN {question_categories} siblingcategory ON siblingcategory.parent = thiscategory.parent WHERE 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.id FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.questioncategoryid = :categoryid AND (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; } }