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} 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;
}
}