541790a5d2
After restoring an activity using shared questions without the question bank they belong to, we may have left over question categories in the target course context. These were being deleted, but any questions they contained weren't. This replaces the bulk delete of question category records with a call to `question_category_delete_safe()` for each category, which will also delete the questions they contain.
434 lines
17 KiB
PHP
434 lines
17 KiB
PHP
<?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 core\attribute\deprecated;
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Upgrade step to find question categories with the wrong parent category.
|
|
*
|
|
* This will find question categories that have a parent in a different context, and set the parent to the top category
|
|
* of the current context. GROUP BY is to ensure we only get one result for each category, just in case we somehow end up
|
|
* with multiple top-level categories in a context.
|
|
*
|
|
* This could occur before the fix for MDL-86300, where a course restore left question categories that were the child of a top
|
|
* category with the original top category as the parent, rather than the new top category.
|
|
*
|
|
* This only needs to one once on upgrade, so is deprecated for removal in 6.0.
|
|
*/
|
|
public static function fix_restored_category_parents(): void {
|
|
global $DB;
|
|
$categoriestofix = $DB->get_records_sql("
|
|
SELECT qc.id as id, MIN(qc3.id) AS parent
|
|
FROM {question_categories} qc
|
|
JOIN {question_categories} qc2 ON qc2.id = qc.parent AND qc.contextid != qc2.contextid
|
|
JOIN {question_categories} qc3 ON qc3.contextid = qc.contextid AND qc3.parent = 0
|
|
GROUP BY qc.id
|
|
");
|
|
foreach ($categoriestofix as $categorytofix) {
|
|
$DB->update_record('question_categories', $categorytofix, true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upgrade step to find questions with no category and delete them.
|
|
*
|
|
* Due to MDL-86154, there may be questions left in the database after a restore, whose category has been deleted. This will
|
|
* find any questions like that and delete them. These questions will always be unused.
|
|
*
|
|
* Now that we have prevented this occurring, this function is used by the upgrade process to clean up these questions.
|
|
*
|
|
* @return int A count of deleted questions.
|
|
*/
|
|
public static function cleanup_questions_without_categories(): int {
|
|
global $DB;
|
|
$questionids = $DB->get_fieldset_sql("
|
|
SELECT q.id
|
|
FROM {question_bank_entries} qbe
|
|
JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id
|
|
JOIN {question} q ON qv.questionid = q.id
|
|
LEFT JOIN {question_categories} qc ON qbe.questioncategoryid = qc.id
|
|
WHERE qc.id IS NULL
|
|
");
|
|
foreach ($questionids as $questionid) {
|
|
question_delete_question($questionid);
|
|
}
|
|
return count($questionids);
|
|
}
|
|
}
|