MDL-72397 qbank_managecategories: API reorganisation
This splits the API functions required for the reorganised category UI, to properly separate the qbank_managecategories and core_question APIs. This methods that were part of question_category_object that should live in the core_question namespace (generate CRUD operations related to categories) are moved to the new \core_question\category_manager class. The parts that belong in the qbank_managecategories plugin as they are used to display the editing UI are moved to the \qbank_managecategories\question_categories class. Static methods that were defined in \qbank_managecategories\helper and were only used within methods that have been moved to one of the new classes have been deprecated and moved to those new classes as well. This will allow the entire \qbank_managecategories\question_category_object class to be deprecated in the following commit.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
issueNumber: MDL-72397
|
||||
notes:
|
||||
qbank_managecategories:
|
||||
- message: >
|
||||
The methods in the `question_category_object` class that are still
|
||||
required following this change have been split between
|
||||
`\qbank_managecategories\question_categories` (for the parts used within
|
||||
this plugin for display a list of categories) and
|
||||
`\core_question\category_manager` (for the parts used for generate CRUD
|
||||
operations on question categories, including outside of this plugin).
|
||||
This will allow `question_category_object` to be deprecated, and avoids
|
||||
other parts of the system wishing to manipulate question categories from
|
||||
having to violate cross-component communication rules.
|
||||
type: changed
|
||||
- message: >
|
||||
The methods `question_is_only_child_of_top_category_in_context`,
|
||||
`question_is_top_category` and `question_can_delete_cat` from
|
||||
`qbank_managecategories\helper` class have been deprecated and moved to
|
||||
the `\core_question\category_manager` class, minus the misleading
|
||||
`question_` prefix. Following the creation of this class, it does not
|
||||
make sense for them to live inside the `qbank_managecategories` plugin.
|
||||
type: deprecated
|
||||
@@ -17,13 +17,12 @@
|
||||
namespace qbank_managecategories\form;
|
||||
|
||||
use moodleform;
|
||||
use qbank_managecategories\helper;
|
||||
use core_question\category_manager;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
require_once($CFG->libdir.'/formslib.php');
|
||||
|
||||
|
||||
/**
|
||||
* Defines the form for editing question categories.
|
||||
*
|
||||
@@ -34,6 +33,23 @@ require_once($CFG->libdir.'/formslib.php');
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class question_category_edit_form extends moodleform {
|
||||
/** @var ?category_manager $manager */
|
||||
protected ?category_manager $manager = null;
|
||||
|
||||
/**
|
||||
* Return the category manager.
|
||||
*
|
||||
* Since we cannot override the constructor, using this method ensures the manager has always been initialised
|
||||
* before access.
|
||||
*
|
||||
* @return category_manager
|
||||
*/
|
||||
protected function get_manager(): category_manager {
|
||||
if (is_null($this->manager)) {
|
||||
$this->manager = new category_manager();
|
||||
}
|
||||
return $this->manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the form definition.
|
||||
@@ -52,7 +68,7 @@ class question_category_edit_form extends moodleform {
|
||||
$mform->addElement('questioncategory', 'parent', get_string('parentcategory', 'question'),
|
||||
['contexts' => $contexts, 'top' => true, 'currentcat' => $currentcat, 'nochildrenof' => $currentcat]);
|
||||
$mform->setType('parent', PARAM_SEQUENCE);
|
||||
if (helper::question_is_only_child_of_top_category_in_context($currentcat)) {
|
||||
if ($this->get_manager()->is_only_child_of_top_category_in_context($currentcat)) {
|
||||
$mform->hardFreeze('parent');
|
||||
}
|
||||
$mform->addHelpButton('parent', 'parentcategory', 'question');
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
namespace qbank_managecategories;
|
||||
|
||||
use context;
|
||||
use core_question\category_manager;
|
||||
use core_question\local\bank\question_version_status;
|
||||
use moodle_exception;
|
||||
use html_writer;
|
||||
@@ -82,15 +83,19 @@ class helper {
|
||||
* @param int $categoryid a category id.
|
||||
* @return bool
|
||||
* @throws \dml_exception
|
||||
* @deprecated Since Moodle 4.5. Use core_question\category_manager::is_only_child_of_top_category_in_context instead.
|
||||
* @todo Final removal in Moodle 6.0 MDL-80804
|
||||
*/
|
||||
#[\core\attribute\deprecated(
|
||||
'core_question\category_manager::is_only_child_of_top_category_in_context',
|
||||
since: 4.5,
|
||||
reason: 'Moved to core namespace',
|
||||
mdl: 'MDL-72397'
|
||||
)]
|
||||
public static function question_is_only_child_of_top_category_in_context(int $categoryid): bool {
|
||||
global $DB;
|
||||
return 1 == $DB->count_records_sql("
|
||||
SELECT count(*)
|
||||
FROM {question_categories} c
|
||||
JOIN {question_categories} p ON c.parent = p.id
|
||||
JOIN {question_categories} s ON s.parent = c.parent
|
||||
WHERE c.id = ? AND p.parent = 0", [$categoryid]);
|
||||
\core\deprecation::emit_deprecation_if_present([__CLASS__, __FUNCTION__]);
|
||||
$manager = new category_manager();
|
||||
return $manager->is_only_child_of_top_category_in_context($categoryid);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,10 +104,19 @@ class helper {
|
||||
* @param int $categoryid a category id.
|
||||
* @return bool
|
||||
* @throws \dml_exception
|
||||
* @deprecated Since Moodle 4.5. Use core_question\category_manager::is_top_category instead.
|
||||
* @todo Final removal in Moodle 6.0 MDL-80804.
|
||||
*/
|
||||
#[\core\attribute\deprecated(
|
||||
'core_question\category_manager::is_top_category',
|
||||
since: 4.5,
|
||||
reason: 'Moved to core namespace',
|
||||
mdl: 'MDL-72397'
|
||||
)]
|
||||
public static function question_is_top_category(int $categoryid): bool {
|
||||
global $DB;
|
||||
return 0 == $DB->get_field('question_categories', 'parent', ['id' => $categoryid]);
|
||||
\core\deprecation::emit_deprecation_if_present([__CLASS__, __FUNCTION__]);
|
||||
$manager = new category_manager();
|
||||
return $manager->is_top_category($categoryid);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,17 +125,19 @@ class helper {
|
||||
* @param int $todelete a category id.
|
||||
* @throws \required_capability_exception
|
||||
* @throws \dml_exception|moodle_exception
|
||||
* @deprecated Since Moodle 4.5. Use core_question\category_manager::can_delete_category instead.
|
||||
* @todo Final removal in Moodle 6.0 MDL-80804.
|
||||
*/
|
||||
#[\core\attribute\deprecated(
|
||||
'core_question\category_manager::can_delete_category',
|
||||
since: 4.5,
|
||||
reason: 'Moved to core namespace',
|
||||
mdl: 'MDL-72397'
|
||||
)]
|
||||
public static function question_can_delete_cat(int $todelete): void {
|
||||
global $DB;
|
||||
if (self::question_is_top_category($todelete)) {
|
||||
throw new moodle_exception('cannotdeletetopcat', 'question');
|
||||
} else if (self::question_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]);
|
||||
require_capability('moodle/question:managecategory', context::instance_by_id($contextid));
|
||||
}
|
||||
\core\deprecation::emit_deprecation_if_present([__CLASS__, __FUNCTION__]);
|
||||
$manager = new category_manager();
|
||||
$manager->require_can_delete_category($todelete);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?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 qbank_managecategories;
|
||||
|
||||
/**
|
||||
* QUESTION_PAGE_LENGTH - Number of categories to display on page.
|
||||
*/
|
||||
if (!defined('QUESTION_PAGE_LENGTH')) {
|
||||
define('QUESTION_PAGE_LENGTH', 25);
|
||||
}
|
||||
|
||||
use context;
|
||||
use moodle_url;
|
||||
|
||||
/**
|
||||
* Builds a tree for categories for rendering the category management page.
|
||||
*
|
||||
* @package qbank_managecategories
|
||||
* @copyright 2024 Catalyst IT Europe Ltd.
|
||||
* @author Mark Johnson <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class question_categories {
|
||||
/**
|
||||
* @var moodle_url Object representing url for this page
|
||||
*/
|
||||
public moodle_url $pageurl;
|
||||
|
||||
/**
|
||||
* @var ?int cmid.
|
||||
*/
|
||||
public ?int $cmid;
|
||||
|
||||
/**
|
||||
* @var ?int courseid.
|
||||
*/
|
||||
public ?int $courseid;
|
||||
|
||||
/**
|
||||
* @var ?int The context ID of the current page.
|
||||
*/
|
||||
public ?int $contextid;
|
||||
|
||||
/**
|
||||
* @var array An array containing a tree of categories for each context.
|
||||
*/
|
||||
public array $editlists;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param moodle_url $pageurl base URL of the display categories page. Used for redirects.
|
||||
* @param context[] $contexts contexts where the current user can edit categories.
|
||||
* @param ?int $cmid course module id for the current page.
|
||||
* @param ?int $courseid course id for the current page.
|
||||
* @param ?int $thiscontext The context ID of the current page.
|
||||
*/
|
||||
public function __construct(
|
||||
moodle_url $pageurl,
|
||||
array $contexts,
|
||||
?int $cmid = null,
|
||||
?int $courseid = null,
|
||||
?int $thiscontext = null,
|
||||
) {
|
||||
global $DB;
|
||||
|
||||
$this->cmid = $cmid;
|
||||
$this->courseid = $courseid;
|
||||
|
||||
$this->pageurl = $pageurl;
|
||||
$this->contextid = $thiscontext;
|
||||
|
||||
$contextids = array_map(fn($context) => $context->id, $contexts);
|
||||
[$insql, $params] = $DB->get_in_or_equal($contextids);
|
||||
$topcategories = $DB->get_records_select_menu(
|
||||
'question_categories',
|
||||
'parent = 0 AND contextid ' . $insql,
|
||||
$params,
|
||||
fields: 'contextid, id'
|
||||
);
|
||||
foreach ($contexts as $context) {
|
||||
$items = helper::get_categories_for_contexts($context->id);
|
||||
// Create an ordered tree with children correctly nested under parents.
|
||||
foreach ($items as $item) {
|
||||
if (array_key_exists((int) $item->parent, $items)) {
|
||||
$item->parentitem = $items[$item->parent];
|
||||
$items[$item->parent]->children[$item->id] = $item;
|
||||
}
|
||||
}
|
||||
foreach ($items as $item) {
|
||||
if (isset($item->children)) {
|
||||
foreach ($item->children as $children) {
|
||||
unset($items[$children->id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->editlists[$context->id] = (object) [
|
||||
'items' => $items,
|
||||
'context' => $context,
|
||||
'categoryid' => $topcategories[$context->id],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,13 @@
|
||||
|
||||
namespace qbank_managecategories;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
use core\exception\moodle_exception;
|
||||
use core_question\category_manager;
|
||||
|
||||
use moodle_url;
|
||||
use core_question\local\bank\question_edit_contexts;
|
||||
defined('MOODLE_INTERNAL') || die;
|
||||
|
||||
global $CFG;
|
||||
require_once($CFG->dirroot . '/question/bank/managecategories/tests/manage_category_test_base.php');
|
||||
require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php');
|
||||
|
||||
/**
|
||||
@@ -33,8 +34,7 @@ require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.ph
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
* @coversDefaultClass \qbank_managecategories\helper
|
||||
*/
|
||||
class helper_test extends \advanced_testcase {
|
||||
|
||||
final class helper_test extends manage_category_test_base {
|
||||
use \quiz_question_helper_test_trait;
|
||||
|
||||
/**
|
||||
@@ -57,11 +57,6 @@ class helper_test extends \advanced_testcase {
|
||||
*/
|
||||
protected $quiz;
|
||||
|
||||
/**
|
||||
* @var question_category_object used in the tests.
|
||||
*/
|
||||
protected $qcobject;
|
||||
|
||||
/**
|
||||
* Tests initial setup.
|
||||
*/
|
||||
@@ -72,16 +67,12 @@ class helper_test extends \advanced_testcase {
|
||||
|
||||
$datagenerator = $this->getDataGenerator();
|
||||
$this->course = $datagenerator->create_course();
|
||||
$this->quiz = $datagenerator->create_module('quiz',
|
||||
['course' => $this->course->id, 'name' => 'Quiz 1']);
|
||||
$this->quiz = $datagenerator->create_module(
|
||||
'quiz',
|
||||
['course' => $this->course->id, 'name' => 'Quiz 1'],
|
||||
);
|
||||
$this->qgenerator = $datagenerator->get_plugin_generator('core_question');
|
||||
$this->context = \context_module::instance($this->quiz->cmid);
|
||||
|
||||
$contexts = new question_edit_contexts($this->context);
|
||||
$this->qcobject = new question_category_object(null,
|
||||
new moodle_url('/question/bank/managecategories/category.php', ['courseid' => SITEID]),
|
||||
$contexts->having_one_edit_tab_cap('categories'), 0, null, 0,
|
||||
$contexts->having_cap('moodle/question:add'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,60 +83,78 @@ class helper_test extends \advanced_testcase {
|
||||
public function test_question_remove_stale_questions_from_category(): void {
|
||||
global $DB;
|
||||
|
||||
$qcat1 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
|
||||
$q1a = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcat1->id]); // Will be hidden.
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
|
||||
// Quiz and its context.
|
||||
$quiz = $this->create_quiz();
|
||||
|
||||
// Create category 1 and one question.
|
||||
$qcat1 = $this->create_question_category_for_a_quiz($quiz);
|
||||
$q1a = $this->create_question_in_a_category('shortanswer', $qcat1->id);
|
||||
$DB->set_field('question_versions', 'status', 'hidden', ['questionid' => $q1a->id]);
|
||||
|
||||
$qcat2 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
|
||||
$q2a = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcat2->id]); // Will be hidden.
|
||||
$q2b = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcat2->id]); // Will be hidden but used.
|
||||
// Create category 2 and two questions.
|
||||
$qcat2 = $this->create_question_category_for_a_quiz($quiz);
|
||||
$q2a = $this->create_question_in_a_category('shortanswer', $qcat2->id);
|
||||
$q2b = $this->create_question_in_a_category('shortanswer', $qcat2->id);
|
||||
$DB->set_field('question_versions', 'status', 'hidden', ['questionid' => $q2a->id]);
|
||||
$DB->set_field('question_versions', 'status', 'hidden', ['questionid' => $q2b->id]);
|
||||
quiz_add_quiz_question($q2b->id, $this->quiz);
|
||||
|
||||
// Add question to the quiz.
|
||||
quiz_add_quiz_question($q2b->id, $quiz);
|
||||
|
||||
// Adding a new random question does not add a new question, adds a question_set_references record.
|
||||
$this->add_random_questions($this->quiz->id, 0, $qcat2->id, 1);
|
||||
$this->add_random_questions($quiz->id, 0, $qcat2->id, 1);
|
||||
|
||||
// We added one random question to the quiz and we expect the quiz to have only one random question.
|
||||
$q2d = $DB->get_record_sql("SELECT qsr.*
|
||||
FROM {quiz_slots} qs
|
||||
JOIN {question_set_references} qsr ON qsr.itemid = qs.id
|
||||
WHERE qs.quizid = ?
|
||||
AND qsr.component = ?
|
||||
AND qsr.questionarea = ?",
|
||||
[$this->quiz->id, 'mod_quiz', 'slot'], MUST_EXIST);
|
||||
$q2d = $DB->get_record_sql(
|
||||
"SELECT qsr.*
|
||||
FROM {quiz_slots} qs
|
||||
JOIN {question_set_references} qsr ON qsr.itemid = qs.id
|
||||
WHERE qs.quizid = ?
|
||||
AND qsr.component = ?
|
||||
AND qsr.questionarea = ?",
|
||||
[$quiz->id, 'mod_quiz', 'slot'],
|
||||
MUST_EXIST
|
||||
);
|
||||
|
||||
// The following 2 lines have to be after the quiz_add_random_questions() call above.
|
||||
// Otherwise, quiz_add_random_questions() will to be "smart" and use them instead of creating a new "random" question.
|
||||
$q1b = $this->qgenerator->create_question('random', null, ['category' => $qcat1->id]); // Will not be used.
|
||||
$q2c = $this->qgenerator->create_question('random', null, ['category' => $qcat2->id]); // Will not be used.
|
||||
$q1b = $this->create_question_in_a_category('random', $qcat1->id);
|
||||
$q2c = $this->create_question_in_a_category('random', $qcat2->id);
|
||||
|
||||
$this->assertEquals(2, count($this->qcobject->get_real_question_ids_in_category($qcat1->id)));
|
||||
$this->assertEquals(3, count($this->qcobject->get_real_question_ids_in_category($qcat2->id)));
|
||||
$contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid));
|
||||
$manager = new category_manager();
|
||||
$this->assertEquals(2, count($manager->get_real_question_ids_in_category($qcat1->id, $contexts)));
|
||||
$this->assertEquals(3, count($manager->get_real_question_ids_in_category($qcat2->id, $contexts)));
|
||||
|
||||
// Non-existing category, nothing will happen.
|
||||
helper::question_remove_stale_questions_from_category(0);
|
||||
$this->assertEquals(2, count($this->qcobject->get_real_question_ids_in_category($qcat1->id)));
|
||||
$this->assertEquals(3, count($this->qcobject->get_real_question_ids_in_category($qcat2->id)));
|
||||
$this->assertEquals(2, count($manager->get_real_question_ids_in_category($qcat1->id, $contexts)));
|
||||
$this->assertEquals(3, count($manager->get_real_question_ids_in_category($qcat2->id, $contexts)));
|
||||
|
||||
// First category, should be empty afterwards.
|
||||
helper::question_remove_stale_questions_from_category($qcat1->id);
|
||||
$this->assertEquals(0, count($this->qcobject->get_real_question_ids_in_category($qcat1->id)));
|
||||
$this->assertEquals(3, count($this->qcobject->get_real_question_ids_in_category($qcat2->id)));
|
||||
$this->assertEquals(0, count($manager->get_real_question_ids_in_category($qcat1->id, $contexts)));
|
||||
$this->assertEquals(3, count($manager->get_real_question_ids_in_category($qcat2->id, $contexts)));
|
||||
$this->assertFalse($DB->record_exists('question', ['id' => $q1a->id]));
|
||||
$this->assertFalse($DB->record_exists('question', ['id' => $q1b->id]));
|
||||
|
||||
// Second category, used questions should be left untouched.
|
||||
helper::question_remove_stale_questions_from_category($qcat2->id);
|
||||
$this->assertEquals(0, count($this->qcobject->get_real_question_ids_in_category($qcat1->id)));
|
||||
$this->assertEquals(1, count($this->qcobject->get_real_question_ids_in_category($qcat2->id)));
|
||||
$this->assertEquals(0, count($manager->get_real_question_ids_in_category($qcat1->id, $contexts)));
|
||||
$this->assertEquals(1, count($manager->get_real_question_ids_in_category($qcat2->id, $contexts)));
|
||||
$this->assertFalse($DB->record_exists('question', ['id' => $q2a->id]));
|
||||
$this->assertTrue($DB->record_exists('question', ['id' => $q2b->id]));
|
||||
$this->assertFalse($DB->record_exists('question', ['id' => $q2c->id]));
|
||||
$this->assertTrue($DB->record_exists('question_set_references',
|
||||
['id' => $q2d->id, 'component' => 'mod_quiz', 'questionarea' => 'slot']));
|
||||
$this->assertTrue($DB->record_exists(
|
||||
'question_set_references',
|
||||
['id' => $q2d->id, 'component' => 'mod_quiz', 'questionarea' => 'slot'],
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Test delete top category in function question_can_delete_cat.
|
||||
*
|
||||
@@ -158,9 +167,17 @@ class helper_test extends \advanced_testcase {
|
||||
|
||||
// Try to delete a top category.
|
||||
$categorytop = question_get_top_category($qcategory1->id, true)->id;
|
||||
$this->expectException('moodle_exception');
|
||||
$this->expectExceptionMessage(get_string('cannotdeletetopcat', 'question'));
|
||||
helper::question_can_delete_cat($categorytop);
|
||||
try {
|
||||
helper::question_can_delete_cat($categorytop);
|
||||
} catch (moodle_exception $e) {
|
||||
$this->assertEquals(get_string('cannotdeletetopcat', 'question'), $e->getMessage());
|
||||
}
|
||||
$this->assertDebuggingCalled(
|
||||
'Deprecation: qbank_managecategories\helper::question_can_delete_cat has been deprecated since 4.5. ' .
|
||||
'Moved to core namespace. ' .
|
||||
'Use core_question\category_manager::can_delete_category instead. ' .
|
||||
'See MDL-72397 for more information.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,9 +191,17 @@ class helper_test extends \advanced_testcase {
|
||||
$qcategory1 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
|
||||
|
||||
// Try to delete an only child of top category having also at least one child.
|
||||
$this->expectException('moodle_exception');
|
||||
$this->expectExceptionMessage(get_string('cannotdeletecate', 'question'));
|
||||
helper::question_can_delete_cat($qcategory1->id);
|
||||
try {
|
||||
helper::question_can_delete_cat($qcategory1->id);
|
||||
} catch (moodle_exception $e) {
|
||||
$this->assertEquals(get_string('cannotdeletecate', 'question'), $e->getMessage());
|
||||
}
|
||||
$this->assertDebuggingCalled(
|
||||
'Deprecation: qbank_managecategories\helper::question_can_delete_cat has been deprecated since 4.5. ' .
|
||||
'Moved to core namespace. ' .
|
||||
'Use core_question\category_manager::can_delete_category instead. ' .
|
||||
'See MDL-72397 for more information.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,9 +221,19 @@ class helper_test extends \advanced_testcase {
|
||||
$user = $this->getDataGenerator()->create_user();
|
||||
$this->setUser($user);
|
||||
|
||||
$this->expectException(\required_capability_exception::class);
|
||||
$this->expectExceptionMessage(get_string('nopermissions', 'error', get_string('question:managecategory', 'role')));
|
||||
helper::question_can_delete_cat($qcategory2->id);
|
||||
try {
|
||||
helper::question_can_delete_cat($qcategory2->id);
|
||||
} catch (\required_capability_exception $e) {
|
||||
$this->assertEquals(
|
||||
get_string('nopermissions', 'error', get_string('question:managecategory', 'role')),
|
||||
$e->getMessage(),
|
||||
);
|
||||
}
|
||||
$message = 'Deprecation: qbank_managecategories\helper::question_can_delete_cat has been deprecated since 4.5. ' .
|
||||
'Moved to core namespace. ' .
|
||||
'Use core_question\category_manager::can_delete_category instead. ' .
|
||||
'See MDL-72397 for more information.';
|
||||
$this->assertdebuggingcalledcount(2, [$message, $message]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,9 +243,13 @@ class helper_test extends \advanced_testcase {
|
||||
* @covers ::question_category_options
|
||||
*/
|
||||
public function test_question_category_select_menu(): void {
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
|
||||
$this->qgenerator->create_question_category(['contextid' => $this->context->id, 'name' => 'Test this question category']);
|
||||
$contexts = new \core_question\local\bank\question_edit_contexts($this->context);
|
||||
// Create category.
|
||||
$quiz = $this->create_quiz();
|
||||
$this->create_question_category_for_a_quiz($quiz, ['name' => 'Test this question category']);
|
||||
$contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid));
|
||||
|
||||
ob_start();
|
||||
helper::question_category_select_menu($contexts->having_cap('moodle/question:add'));
|
||||
@@ -231,17 +270,21 @@ class helper_test extends \advanced_testcase {
|
||||
* @covers ::add_indented_names
|
||||
*/
|
||||
public function test_question_category_options(): void {
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
|
||||
$qcategory1 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
|
||||
$qcategory2 = $this->qgenerator->create_question_category(['contextid' => $this->context->id, 'parent' => $qcategory1->id]);
|
||||
$qcategory3 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
|
||||
// Create categories.
|
||||
$quiz = $this->create_quiz();
|
||||
$qcategory1 = $this->create_question_category_for_a_quiz($quiz);
|
||||
$this->create_question_category_for_a_quiz($quiz, ['parent' => $qcategory1->id]);
|
||||
$this->create_question_category_for_a_quiz($quiz);
|
||||
|
||||
$contexts = new \core_question\local\bank\question_edit_contexts($this->context);
|
||||
$contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid));
|
||||
|
||||
// Validate that we have the array with the categories tree.
|
||||
$categorycontexts = helper::question_category_options($contexts->having_cap('moodle/question:add'));
|
||||
// The quiz name 'Quiz 1' is set in setUp function.
|
||||
$categorycontext = $categorycontexts['Quiz: Quiz 1'];
|
||||
$categorycontext = $categorycontexts['Quiz: ' . $quiz->name];
|
||||
$this->assertCount(3, $categorycontext);
|
||||
|
||||
// Validate that we have the array with the categories tree and that top category is there.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<?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 qbank_managecategories;
|
||||
|
||||
use core_question\local\bank\question_edit_contexts;
|
||||
use moodle_url;
|
||||
/**
|
||||
* Test base for category tests
|
||||
*
|
||||
* @package qbank_managecategories
|
||||
* @copyright 2022 Catalyst IT Australia Pty Ltd
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
abstract class manage_category_test_base extends \advanced_testcase {
|
||||
/**
|
||||
* Create a question category for a context.
|
||||
*
|
||||
* @param int $contextid the context where question category will be created for
|
||||
* @param array $categorydetails details of the category
|
||||
* @return \stdClass question category record
|
||||
*/
|
||||
private function create_new_question_category_for_a_context(int $contextid, array $categorydetails = []) {
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
$categorydetails['contextid'] = $contextid;
|
||||
return $questiongenerator->create_question_category($categorydetails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a question category for the system.
|
||||
*
|
||||
* @param array $categorydetails details of the category
|
||||
* @return \stdClass question category record
|
||||
*/
|
||||
protected function create_question_category_for_the_system(array $categorydetails = []): \stdClass {
|
||||
$context = \context_system::instance();
|
||||
return $this->create_new_question_category_for_a_context($context->id, $categorydetails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a course category
|
||||
*
|
||||
* @return \core_course_category new course category
|
||||
*/
|
||||
protected function create_course_category(): \core_course_category {
|
||||
// Course category.
|
||||
return $this->getDataGenerator()->create_category();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a question category for a course category.
|
||||
*
|
||||
* @param \core_course_category $coursecategory the course category that new question category will be created for
|
||||
* @param array $categorydetails details of the category
|
||||
* @return \stdClass question category record
|
||||
*/
|
||||
protected function create_question_category_for_a_course_category(
|
||||
\core_course_category $coursecategory,
|
||||
array $categorydetails = [],
|
||||
): \stdClass {
|
||||
$context = \context_coursecat::instance($coursecategory->id);
|
||||
return $this->create_new_question_category_for_a_context($context->id, $categorydetails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a course
|
||||
*
|
||||
* @return \stdClass new course
|
||||
*/
|
||||
protected function create_course(): \stdClass {
|
||||
// Course.
|
||||
return $this->getDataGenerator()->create_course();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a question category for a course.
|
||||
*
|
||||
* @param \stdClass $course the course that new question category will be created for
|
||||
* @param array $categorydetails details of the category
|
||||
* @return \stdClass category record
|
||||
*/
|
||||
protected function create_question_category_for_a_course(\stdClass $course, array $categorydetails = []): \stdClass {
|
||||
$context = \context_course::instance($course->id);
|
||||
return $this->create_new_question_category_for_a_context($context->id, $categorydetails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a quiz
|
||||
*
|
||||
* @return \stdClass the new quiz
|
||||
*/
|
||||
protected function create_quiz(): \stdClass {
|
||||
// Quiz.
|
||||
$course = $this->getDataGenerator()->create_course();
|
||||
return $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a question category for a quiz.
|
||||
*
|
||||
* @param \stdClass $quiz the quiz that new category will be created for
|
||||
* @param array $categorydetails details of the category
|
||||
* @return \stdClass category record
|
||||
*/
|
||||
protected function create_question_category_for_a_quiz(\stdClass $quiz, array $categorydetails = []): \stdClass {
|
||||
$context = \context_module::instance($quiz->cmid);
|
||||
return $this->create_new_question_category_for_a_context($context->id, $categorydetails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new question in a category
|
||||
*
|
||||
* @param string $qtype the question type
|
||||
* @param int $categoryid the category that new question will be created on
|
||||
* @return \stdClass new question
|
||||
*/
|
||||
protected function create_question_in_a_category(string $qtype, int $categoryid): \stdClass {
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
return $questiongenerator->create_question($qtype, null, ['category' => $categoryid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Parent of a question category
|
||||
*
|
||||
* @param int $questioncategoryid a question category
|
||||
* @return int the id of the parent
|
||||
*/
|
||||
protected function get_parent_of_a_question_category(int $questioncategoryid): int {
|
||||
global $DB;
|
||||
$parent = $DB->get_field('question_categories', 'parent', ['id' => $questioncategoryid]);
|
||||
return $parent ?: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?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 qbank_managecategories;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
global $CFG;
|
||||
require_once($CFG->dirroot . '/question/bank/managecategories/tests/manage_category_test_base.php');
|
||||
|
||||
/**
|
||||
* Unit tests for question_categories
|
||||
*
|
||||
* @package qbank_managecategories
|
||||
* @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net}
|
||||
* @author Mark Johnson <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
* @covers \qbank_managecategories\question_categories
|
||||
*/
|
||||
class question_categories_test extends manage_category_test_base {
|
||||
/**
|
||||
* Test creation of an ordered tree of categories in the constructor.
|
||||
*/
|
||||
public function test_create_order_tree(): void {
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
|
||||
// Create question categories for a course.
|
||||
$course = $this->create_course();
|
||||
$qcat1 = $this->create_question_category_for_a_course($course);
|
||||
$qcat2 = $this->create_question_category_for_a_course($course, ['parent' => $qcat1->id]);
|
||||
$qcat3 = $this->create_question_category_for_a_course($course);
|
||||
$qcat4 = $this->create_question_category_for_a_course($course, ['parent' => $qcat2->id]);
|
||||
$coursecontext = \context_course::instance($course->id);
|
||||
|
||||
// Create ordered tree.
|
||||
$questioncategories = new question_categories(
|
||||
new \moodle_url('/'),
|
||||
[$coursecontext],
|
||||
);
|
||||
$items = $questioncategories->editlists[$coursecontext->id]->items;
|
||||
|
||||
// Two top categories (1 and 3) in the course.
|
||||
$this->assertCount(2, $items);
|
||||
$this->assertArrayHasKey($qcat1->id, $items);
|
||||
$this->assertArrayHasKey($qcat3->id, $items);
|
||||
|
||||
// Category 2 is the only child of Category 1.
|
||||
$children = $items[$qcat1->id]->children;
|
||||
$this->assertCount(1, $children);
|
||||
$this->assertArrayHasKey($qcat2->id, $children);
|
||||
|
||||
// Category 4 is the only child of Category 2.
|
||||
$children = $children[$qcat2->id]->children;
|
||||
$this->assertCount(1, $children);
|
||||
$this->assertArrayHasKey($qcat4->id, $children);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
<?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 <[email protected]>
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
<?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 context_course;
|
||||
use context_module;
|
||||
use moodle_url;
|
||||
use core_question\local\bank\question_edit_contexts;
|
||||
|
||||
/**
|
||||
* Unit tests for category_manager
|
||||
*
|
||||
* @package core_question
|
||||
* @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net}
|
||||
* @author Mark Johnson <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
* @covers \core_question\category_manager
|
||||
*/
|
||||
final class category_manager_test extends \advanced_testcase {
|
||||
/**
|
||||
* Test creating a category.
|
||||
*/
|
||||
public function test_add_category_no_idnumber(): void {
|
||||
global $DB;
|
||||
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
$context = context_course::instance(SITEID);
|
||||
$topcat = question_get_top_category($context->id, true);
|
||||
$manager = new category_manager();
|
||||
$id = $manager->add_category(
|
||||
$topcat->id . ',' . $topcat->contextid,
|
||||
'New category',
|
||||
'',
|
||||
FORMAT_HTML,
|
||||
'', // No idnumber passed as '' to match form data.
|
||||
);
|
||||
|
||||
$newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST);
|
||||
$this->assertSame('New category', $newcat->name);
|
||||
$this->assertNull($newcat->idnumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test creating a category with a tricky idnumber.
|
||||
*/
|
||||
public function test_add_category_set_idnumber_0(): void {
|
||||
global $DB;
|
||||
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
$context = context_course::instance(SITEID);
|
||||
$topcat = question_get_top_category($context->id, true);
|
||||
$manager = new category_manager();
|
||||
$id = $manager->add_category(
|
||||
$topcat->id . ',' . $topcat->contextid,
|
||||
'New category',
|
||||
'',
|
||||
FORMAT_HTML,
|
||||
'0',
|
||||
);
|
||||
|
||||
$newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST);
|
||||
$this->assertSame('New category', $newcat->name);
|
||||
$this->assertSame('0', $newcat->idnumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trying to add a category with duplicate idnumber throws an exception.
|
||||
*/
|
||||
public function test_add_category_try_to_set_duplicate_idnumber(): void {
|
||||
global $DB;
|
||||
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
$context = context_course::instance(SITEID);
|
||||
$topcat = question_get_top_category($context->id, true);
|
||||
$manager = new category_manager();
|
||||
$manager->add_category(
|
||||
$topcat->id . ',' . $topcat->contextid,
|
||||
'Existing category',
|
||||
'',
|
||||
FORMAT_HTML,
|
||||
'frog',
|
||||
);
|
||||
$this->expectExceptionMessage(get_string('idnumbertaken', 'error'));
|
||||
$manager->add_category(
|
||||
$topcat->id . ',' . $topcat->contextid,
|
||||
'New category',
|
||||
'',
|
||||
FORMAT_HTML,
|
||||
'frog',
|
||||
);
|
||||
$this->assertFalse($DB->record_exists('question_categories', ['name' => 'New category']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test updating a category.
|
||||
*/
|
||||
public function test_update_category(): void {
|
||||
global $DB;
|
||||
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
$context = context_course::instance(SITEID);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
$topcat = question_get_top_category($context->id, true);
|
||||
$category = $questiongenerator->create_question_category([
|
||||
'contextid' => $context->id,
|
||||
'name' => 'Old name',
|
||||
'info' => 'Description',
|
||||
'idnumber' => 'frog',
|
||||
]);
|
||||
$existingcat = $DB->get_record('question_categories', ['id' => $category->id], '*', MUST_EXIST);
|
||||
$this->assertSame('Old name', $existingcat->name);
|
||||
$this->assertSame('Description', $existingcat->info);
|
||||
$this->assertSame('frog', $existingcat->idnumber);
|
||||
|
||||
$manager = new category_manager(new moodle_url('/'));
|
||||
$manager->update_category(
|
||||
$category->id,
|
||||
$topcat->id . ',' . $topcat->contextid,
|
||||
'New name',
|
||||
'New description',
|
||||
FORMAT_HTML,
|
||||
'0'
|
||||
);
|
||||
|
||||
$updatedcat = $DB->get_record('question_categories', ['id' => $category->id], '*', MUST_EXIST);
|
||||
$this->assertSame('New name', $updatedcat->name);
|
||||
$this->assertSame('New description', $updatedcat->info);
|
||||
$this->assertSame('0', $updatedcat->idnumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test updating a category to remove the idnumber.
|
||||
*/
|
||||
public function test_update_category_removing_idnumber(): void {
|
||||
global $DB;
|
||||
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
$context = context_course::instance(SITEID);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
$topcat = question_get_top_category($context->id, true);
|
||||
$category = $questiongenerator->create_question_category([
|
||||
'contextid' => $context->id,
|
||||
'name' => 'Old name',
|
||||
'info' => 'Description',
|
||||
'idnumber' => 'frog',
|
||||
]);
|
||||
|
||||
$existingcat = $DB->get_record('question_categories', ['id' => $category->id], '*', MUST_EXIST);
|
||||
$this->assertSame('Old name', $existingcat->name);
|
||||
$this->assertSame('Description', $existingcat->info);
|
||||
$this->assertSame('frog', $existingcat->idnumber);
|
||||
|
||||
$manager = new category_manager(new moodle_url('/'));
|
||||
$manager->update_category(
|
||||
$category->id,
|
||||
$topcat->id . ',' . $topcat->contextid,
|
||||
'New name',
|
||||
'New description',
|
||||
FORMAT_HTML,
|
||||
''
|
||||
);
|
||||
|
||||
$updatedcat = $DB->get_record('question_categories', ['id' => $category->id], '*', MUST_EXIST);
|
||||
$this->assertSame('New name', $updatedcat->name);
|
||||
$this->assertSame('New description', $updatedcat->info);
|
||||
$this->assertNull($updatedcat->idnumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test updating a category without changing the idnumber.
|
||||
*/
|
||||
public function test_update_category_dont_change_idnumber(): void {
|
||||
global $DB;
|
||||
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
$context = context_course::instance(SITEID);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
$topcat = question_get_top_category($context->id, true);
|
||||
$category = $questiongenerator->create_question_category([
|
||||
'contextid' => $context->id,
|
||||
'name' => 'Old name',
|
||||
'info' => 'Description',
|
||||
'idnumber' => 'frog',
|
||||
]);
|
||||
$existingcat = $DB->get_record('question_categories', ['id' => $category->id], '*', MUST_EXIST);
|
||||
$this->assertSame('Old name', $existingcat->name);
|
||||
$this->assertSame('Description', $existingcat->info);
|
||||
$this->assertSame('frog', $existingcat->idnumber);
|
||||
|
||||
$manager = new category_manager(new moodle_url('/'));
|
||||
$manager->update_category(
|
||||
$category->id,
|
||||
$topcat->id . ',' . $topcat->contextid,
|
||||
'New name',
|
||||
'New description',
|
||||
FORMAT_HTML,
|
||||
'frog'
|
||||
);
|
||||
|
||||
$updatedcat = $DB->get_record('question_categories', ['id' => $category->id], '*', MUST_EXIST);
|
||||
$this->assertSame('New name', $updatedcat->name);
|
||||
$this->assertSame('New description', $updatedcat->info);
|
||||
$this->assertSame('frog', $updatedcat->idnumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trying to update a category so its idnumber is a duplicate throws an exception and does not update.
|
||||
*/
|
||||
public function test_update_category_try_to_set_duplicate_idnumber(): void {
|
||||
global $DB;
|
||||
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
$context = context_course::instance(SITEID);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
$topcat = question_get_top_category($context->id, true);
|
||||
$questiongenerator->create_question_category([
|
||||
'contextid' => $context->id,
|
||||
'name' => 'Toad category',
|
||||
'idnumber' => 'toad',
|
||||
]);
|
||||
$category = $questiongenerator->create_question_category([
|
||||
'contextid' => $context->id,
|
||||
'name' => 'Frog category',
|
||||
'idnumber' => 'frog',
|
||||
]);
|
||||
$existingcat = $DB->get_record('question_categories', ['id' => $category->id], '*', MUST_EXIST);
|
||||
$this->assertSame('Frog category', $existingcat->name);
|
||||
$this->assertSame('frog', $existingcat->idnumber);
|
||||
|
||||
$manager = new category_manager();
|
||||
$this->expectExceptionMessage(get_string('idnumbertaken', 'error'));
|
||||
$manager->update_category(
|
||||
$category->id,
|
||||
$topcat->id . ',' . $topcat->contextid,
|
||||
'New name',
|
||||
'',
|
||||
FORMAT_HTML,
|
||||
'toad'
|
||||
);
|
||||
|
||||
$updatedcat = $DB->get_record('question_categories', ['id' => $category->id], '*', MUST_EXIST);
|
||||
$this->assertEquals('Frog category', $updatedcat->name);
|
||||
$this->assertEquals('frog', $updatedcat->idnumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the question category created event.
|
||||
*/
|
||||
public function test_question_category_created(): void {
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
$generator = $this->getDataGenerator();
|
||||
$course = $generator->create_course();
|
||||
$quiz = $generator->get_plugin_generator('mod_quiz')->create_instance(['course' => $course->id]);
|
||||
$context = context_module::instance($quiz->cmid);
|
||||
$topcat = question_get_top_category($context->id, true);
|
||||
$manager = new category_manager(new moodle_url('/'));
|
||||
// Trigger and capture the event.
|
||||
$sink = $this->redirectEvents();
|
||||
$manager->add_category(
|
||||
$topcat->id . ',' . $topcat->contextid,
|
||||
'New category',
|
||||
'Description',
|
||||
FORMAT_HTML,
|
||||
'frog',
|
||||
);
|
||||
$events = $sink->get_events();
|
||||
$event = reset($events);
|
||||
|
||||
// Check that the event data is valid.
|
||||
$this->assertInstanceOf('\core\event\question_category_created', $event);
|
||||
$this->assertEquals($context, $event->get_context());
|
||||
$this->assertEventContextNotUsed($event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the question category deleted event.
|
||||
*/
|
||||
public function test_question_category_deleted(): void {
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
$generator = $this->getDataGenerator();
|
||||
$course = $generator->create_course();
|
||||
$quiz = $generator->get_plugin_generator('mod_quiz')->create_instance(['course' => $course->id]);
|
||||
$contexts = new question_edit_contexts(context_module::instance($quiz->cmid));
|
||||
$defaultcat = question_make_default_categories([$contexts->lowest()]);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
// Create the category.
|
||||
$category = $questiongenerator->create_question_category([
|
||||
'contextid' => $contexts->lowest()->id,
|
||||
'name' => 'New category',
|
||||
'info' => 'Description',
|
||||
'idnumber' => 'newcategory',
|
||||
'parent' => $defaultcat->id,
|
||||
]);
|
||||
|
||||
// Trigger and capture the event.
|
||||
$sink = $this->redirectEvents();
|
||||
$manager = new category_manager(new moodle_url('/'));
|
||||
$manager->delete_category($category->id);
|
||||
$events = $sink->get_events();
|
||||
$event = reset($events);
|
||||
|
||||
// Check that the event data is valid.
|
||||
$this->assertInstanceOf('\core\event\question_category_deleted', $event);
|
||||
$this->assertEquals($contexts->lowest(), $event->get_context());
|
||||
$this->assertEquals($category->id, $event->objectid);
|
||||
$this->assertDebuggingNotCalled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the question category updated event.
|
||||
*/
|
||||
public function test_question_category_updated(): void {
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
$generator = $this->getDataGenerator();
|
||||
$course = $generator->create_course();
|
||||
$quiz = $generator->get_plugin_generator('mod_quiz')->create_instance(['course' => $course->id]);
|
||||
$context = context_module::instance($quiz->cmid);
|
||||
$topcat = question_get_top_category($context->id, true);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
// Create the category.
|
||||
$category = $questiongenerator->create_question_category([
|
||||
'contextid' => $context->id,
|
||||
'name' => 'New category',
|
||||
'info' => 'Description',
|
||||
'idnumber' => 'newcategory',
|
||||
]);
|
||||
|
||||
// Trigger and capture the event.
|
||||
$sink = $this->redirectEvents();
|
||||
$manager = new category_manager(new moodle_url('/'));
|
||||
$manager->update_category(
|
||||
$category->id,
|
||||
$topcat->id . ',' . $topcat->contextid,
|
||||
'Updated category',
|
||||
'',
|
||||
true,
|
||||
FORMAT_HTML,
|
||||
);
|
||||
$events = $sink->get_events();
|
||||
$event = reset($events);
|
||||
|
||||
// Check that the event data is valid.
|
||||
$this->assertInstanceOf('\core\event\question_category_updated', $event);
|
||||
$this->assertEquals($context, $event->get_context());
|
||||
$this->assertEquals($category->id, $event->objectid);
|
||||
$this->assertDebuggingNotCalled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that get_real_question_ids_in_category() returns question id
|
||||
* of a shortanswer question in a category.
|
||||
*/
|
||||
public function test_get_real_question_ids_in_category_shortanswer(): void {
|
||||
$this->resetAfterTest();
|
||||
$generator = $this->getDataGenerator();
|
||||
$course = $generator->create_course();
|
||||
$quiz = $generator->create_module('quiz', ['course' => $course->id]);
|
||||
$contexts = new question_edit_contexts(context_module::instance($quiz->cmid));
|
||||
|
||||
$defaultcategory = question_make_default_categories([$contexts->lowest()]);
|
||||
$questiongerator = $generator->get_plugin_generator('core_question');
|
||||
|
||||
// Short answer question is made of one question.
|
||||
$shortanswer = $questiongerator->create_question('shortanswer', null, ['category' => $defaultcategory->id]);
|
||||
$manager = new category_manager();
|
||||
$questionids = $manager->get_real_question_ids_in_category($defaultcategory->id);
|
||||
$this->assertCount(1, $questionids);
|
||||
$this->assertContains($shortanswer->id, $questionids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that get_real_question_ids_in_category() returns question id
|
||||
* of a multianswer question in a category.
|
||||
*/
|
||||
public function test_get_real_question_ids_in_category_multianswer(): void {
|
||||
global $DB;
|
||||
$this->resetAfterTest();
|
||||
$countq = $DB->count_records('question');
|
||||
$countqbe = $DB->count_records('question_bank_entries');
|
||||
$generator = $this->getDataGenerator();
|
||||
$course = $generator->create_course();
|
||||
$quiz = $generator->create_module('quiz', ['course' => $course->id]);
|
||||
$contexts = new question_edit_contexts(context_module::instance($quiz->cmid));
|
||||
|
||||
$defaultcategory = question_make_default_categories([$contexts->lowest()]);
|
||||
$questiongerator = $generator->get_plugin_generator('core_question');
|
||||
|
||||
// Multi answer question is made of one parent and two child questions.
|
||||
$multianswer = $questiongerator->create_question('multianswer', null, ['category' => $defaultcategory->id]);
|
||||
$manager = new category_manager();
|
||||
$questionids = $manager->get_real_question_ids_in_category($defaultcategory->id);
|
||||
$this->assertCount(1, $questionids);
|
||||
$this->assertContains($multianswer->id, $questionids);
|
||||
$this->assertEquals(3, $DB->count_records('question') - $countq);
|
||||
$this->assertEquals(3, $DB->count_records('question_bank_entries') - $countqbe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that get_real_question_ids_in_category() returns question ids
|
||||
* of two versions of a multianswer question in a category.
|
||||
*/
|
||||
public function test_get_real_question_ids_in_category_multianswer_two_versions(): void {
|
||||
global $DB;
|
||||
$this->resetAfterTest();
|
||||
$countq = $DB->count_records('question');
|
||||
$countqv = $DB->count_records('question_versions');
|
||||
$countqbe = $DB->count_records('question_bank_entries');
|
||||
|
||||
$generator = $this->getDataGenerator();
|
||||
$course = $generator->create_course();
|
||||
$quiz = $generator->create_module('quiz', ['course' => $course->id]);
|
||||
$contexts = new question_edit_contexts(context_module::instance($quiz->cmid));
|
||||
|
||||
$defaultcategory = question_make_default_categories([$contexts->lowest()]);
|
||||
$questiongerator = $generator->get_plugin_generator('core_question');
|
||||
|
||||
// Create two versions of a multianswer question which will lead to
|
||||
// 2 parents and 4 child questions in the question bank.
|
||||
$multianswer = $questiongerator->create_question('multianswer', null, ['category' => $defaultcategory->id]);
|
||||
$multianswernew = $questiongerator->update_question($multianswer, null, ['name' => 'This is a new version']);
|
||||
$manager = new category_manager();
|
||||
$questionids = $manager->get_real_question_ids_in_category($defaultcategory->id);
|
||||
$this->assertCount(2, $questionids);
|
||||
$this->assertContains($multianswer->id, $questionids);
|
||||
$this->assertContains($multianswernew->id, $questionids);
|
||||
$this->assertEquals(6, $DB->count_records('question') - $countq);
|
||||
$this->assertEquals(6, $DB->count_records('question_versions') - $countqv);
|
||||
$this->assertEquals(3, $DB->count_records('question_bank_entries') - $countqbe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that get_real_question_ids_in_category() returns question id
|
||||
* of a multianswer question in a category even if their child questions are
|
||||
* linked to a category that doesn't exist.
|
||||
*/
|
||||
public function test_get_real_question_ids_in_category_multianswer_bad_data(): void {
|
||||
global $DB;
|
||||
$this->resetAfterTest();
|
||||
$countqbe = $DB->count_records('question_bank_entries');
|
||||
|
||||
$generator = $this->getDataGenerator();
|
||||
$course = $generator->create_course();
|
||||
$quiz = $generator->create_module('quiz', ['course' => $course->id]);
|
||||
$contexts = new question_edit_contexts(context_module::instance($quiz->cmid));
|
||||
|
||||
$defaultcategory = question_make_default_categories([$contexts->lowest()]);
|
||||
$questiongerator = $generator->get_plugin_generator('core_question');
|
||||
|
||||
// Multi answer question is made of one parent and two child questions.
|
||||
$multianswer = $questiongerator->create_question('multianswer', null, ['category' => $defaultcategory->id]);
|
||||
$qversion = $DB->get_record('question_versions', ['questionid' => $multianswer->id]);
|
||||
|
||||
// Update category id for child questions to a category that doesn't exist.
|
||||
$DB->set_field_select(
|
||||
'question_bank_entries',
|
||||
'questioncategoryid',
|
||||
123456,
|
||||
'id <> :id',
|
||||
['id' => $qversion->questionbankentryid]
|
||||
);
|
||||
|
||||
$manager = new category_manager();
|
||||
$questionids = $manager->get_real_question_ids_in_category($defaultcategory->id);
|
||||
$this->assertCount(1, $questionids);
|
||||
$this->assertContains($multianswer->id, $questionids);
|
||||
$this->assertEquals(3, $DB->count_records('question_bank_entries') - $countqbe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test delete top category in function question_can_delete_cat.
|
||||
*/
|
||||
public function test_question_can_delete_cat_top_category(): void {
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
|
||||
$manager = new category_manager();
|
||||
|
||||
// Create a category.
|
||||
$course = $this->getDataGenerator()->create_course();
|
||||
$quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
|
||||
$context = \context_module::instance($quiz->cmid);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
$qcategory1 = $questiongenerator->create_question_category(['contextid' => $context->id]);
|
||||
|
||||
// Try to delete a top category.
|
||||
$categorytop = question_get_top_category($qcategory1->id, true)->id;
|
||||
$this->expectException('moodle_exception');
|
||||
$this->expectExceptionMessage(get_string('cannotdeletetopcat', 'question'));
|
||||
$manager->require_can_delete_category($categorytop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test delete only child category in function question_can_delete_cat.
|
||||
*/
|
||||
public function test_question_can_delete_cat_child_category(): void {
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
|
||||
$manager = new category_manager();
|
||||
|
||||
// Create a category.
|
||||
$course = $this->getDataGenerator()->create_course();
|
||||
$quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
|
||||
$context = \context_module::instance($quiz->cmid);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
$qcategory1 = $questiongenerator->create_question_category(['contextid' => $context->id]);
|
||||
|
||||
// Try to delete an only child of top category having also at least one child.
|
||||
$this->expectException('moodle_exception');
|
||||
$this->expectExceptionMessage(get_string('cannotdeletecate', 'question'));
|
||||
$manager->require_can_delete_category($qcategory1->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test delete category in function question_can_delete_cat without capabilities.
|
||||
*/
|
||||
public function test_can_delete_category_capability(): void {
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
|
||||
$manager = new category_manager();
|
||||
|
||||
// Create 2 categories.
|
||||
$course = $this->getDataGenerator()->create_course();
|
||||
$quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
|
||||
$context = \context_module::instance($quiz->cmid);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
$qcategory1 = $questiongenerator->create_question_category(['contextid' => $context->id]);
|
||||
$qcategory2 = $questiongenerator->create_question_category(['contextid' => $context->id, 'parent' => $qcategory1->id]);
|
||||
|
||||
// This call should not throw an exception as admin user has the capabilities moodle/question:managecategory.
|
||||
$manager->require_can_delete_category($qcategory2->id);
|
||||
|
||||
// Try to delete a category with and user without the capability.
|
||||
$manager = new category_manager();
|
||||
$user = $this->getDataGenerator()->create_user();
|
||||
$this->setUser($user);
|
||||
|
||||
$this->expectException(\required_capability_exception::class);
|
||||
$this->expectExceptionMessage(get_string('nopermissions', 'error', get_string('question:managecategory', 'role')));
|
||||
$manager->require_can_delete_category($qcategory2->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test get max sortorder
|
||||
*/
|
||||
public function test_get_max_sortorder(): void {
|
||||
$this->setAdminUser();
|
||||
$this->resetAfterTest();
|
||||
|
||||
$manager = new category_manager();
|
||||
|
||||
// Create question categories for a course.
|
||||
$course = $this->getDataGenerator()->create_course();
|
||||
$quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
|
||||
$context = \context_module::instance($quiz->cmid);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
$topcategory = question_get_top_category($context->id, true);
|
||||
$qcategory1 = $questiongenerator->create_question_category(['contextid' => $context->id]);
|
||||
$this->assertEquals(1, $manager->get_max_sortorder($topcategory->id));
|
||||
|
||||
$qcategory2 = $questiongenerator->create_question_category(['contextid' => $context->id, 'parent' => $qcategory1->id]);
|
||||
|
||||
$this->assertEquals(1, $manager->get_max_sortorder($qcategory1->id));
|
||||
|
||||
$questiongenerator->create_question_category(['contextid' => $context->id]);
|
||||
$this->assertEquals(2, $manager->get_max_sortorder($topcategory->id));
|
||||
|
||||
$this->assertEquals(0, $manager->get_max_sortorder($qcategory2->id));
|
||||
$questiongenerator->create_question_category(['contextid' => $context->id, 'parent' => $qcategory2->id]);
|
||||
$this->assertEquals(1, $manager->get_max_sortorder($qcategory2->id));
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@
|
||||
|
||||
namespace core_question\event;
|
||||
|
||||
use qbank_managecategories\question_category_object;
|
||||
use qtype_description;
|
||||
use qtype_description_edit_form;
|
||||
use qtype_description_test_helper;
|
||||
@@ -56,28 +55,21 @@ class events_test extends \advanced_testcase {
|
||||
$this->setAdminUser();
|
||||
$course = $this->getDataGenerator()->create_course();
|
||||
$quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
|
||||
$contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid));
|
||||
|
||||
$defaultcategoryobj = question_make_default_categories([$contexts->lowest()]);
|
||||
$defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid;
|
||||
$defaultcategory = question_make_default_categories([$contexts->lowest()]);
|
||||
|
||||
$qcobject = new question_category_object(
|
||||
1,
|
||||
new \moodle_url('/mod/quiz/edit.php', ['cmid' => $quiz->cmid]),
|
||||
$contexts->having_one_edit_tab_cap('categories'),
|
||||
$defaultcategoryobj->id,
|
||||
$defaultcategory,
|
||||
null,
|
||||
$contexts->having_cap('moodle/question:add'));
|
||||
|
||||
// Create the category.
|
||||
$categoryid = $qcobject->add_category($defaultcategory, 'newcategory', '', true);
|
||||
$category = $questiongenerator->create_question_category([
|
||||
'name' => 'newcategory',
|
||||
'parent' => $defaultcategory->id,
|
||||
]);
|
||||
|
||||
// Log the view of this category.
|
||||
$params = [
|
||||
'context' => \context_module::instance($quiz->cmid),
|
||||
'other' => ['categoryid' => $categoryid, 'format' => 'testformat'],
|
||||
'other' => ['categoryid' => $category->id, 'format' => 'testformat'],
|
||||
];
|
||||
|
||||
$event = \core\event\questions_imported::create($params);
|
||||
@@ -91,7 +83,7 @@ class events_test extends \advanced_testcase {
|
||||
// Check that the event data is valid.
|
||||
$this->assertInstanceOf('\core\event\questions_imported', $event);
|
||||
$this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context());
|
||||
$this->assertEquals($categoryid, $event->other['categoryid']);
|
||||
$this->assertEquals($category->id, $event->other['categoryid']);
|
||||
$this->assertEquals('testformat', $event->other['format']);
|
||||
$this->assertDebuggingNotCalled();
|
||||
|
||||
@@ -107,28 +99,21 @@ class events_test extends \advanced_testcase {
|
||||
$this->setAdminUser();
|
||||
$course = $this->getDataGenerator()->create_course();
|
||||
$quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
|
||||
$contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid));
|
||||
|
||||
$defaultcategoryobj = question_make_default_categories([$contexts->lowest()]);
|
||||
$defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid;
|
||||
$defaultcategory = question_make_default_categories([$contexts->lowest()]);
|
||||
|
||||
$qcobject = new question_category_object(
|
||||
1,
|
||||
new \moodle_url('/mod/quiz/edit.php', ['cmid' => $quiz->cmid]),
|
||||
$contexts->having_one_edit_tab_cap('categories'),
|
||||
$defaultcategoryobj->id,
|
||||
$defaultcategory,
|
||||
null,
|
||||
$contexts->having_cap('moodle/question:add'));
|
||||
|
||||
// Create the category.
|
||||
$categoryid = $qcobject->add_category($defaultcategory, 'newcategory', '', true);
|
||||
$category = $questiongenerator->create_question_category([
|
||||
'name' => 'newcategory',
|
||||
'parent' => $defaultcategory->id,
|
||||
]);
|
||||
|
||||
// Log the view of this category.
|
||||
$params = [
|
||||
'context' => \context_module::instance($quiz->cmid),
|
||||
'other' => ['categoryid' => $categoryid, 'format' => 'testformat'],
|
||||
'other' => ['categoryid' => $category->id, 'format' => 'testformat'],
|
||||
];
|
||||
|
||||
$event = \core\event\questions_exported::create($params);
|
||||
@@ -142,7 +127,7 @@ class events_test extends \advanced_testcase {
|
||||
// Check that the event data is valid.
|
||||
$this->assertInstanceOf('\core\event\questions_exported', $event);
|
||||
$this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context());
|
||||
$this->assertEquals($categoryid, $event->other['categoryid']);
|
||||
$this->assertEquals($category->id, $event->other['categoryid']);
|
||||
$this->assertEquals('testformat', $event->other['format']);
|
||||
$this->assertDebuggingNotCalled();
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?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\event;
|
||||
|
||||
use context_module;
|
||||
|
||||
/**
|
||||
* Unit tests for question_category_viewed
|
||||
*
|
||||
* @package core_question
|
||||
* @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net}
|
||||
* @author Mark Johnson <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
* @covers \core_question\event\question_category_viewed
|
||||
*/
|
||||
final class question_category_viewed_test extends \advanced_testcase {
|
||||
/**
|
||||
* Test creating and triggering an event from a category instance.
|
||||
*
|
||||
* @covers ::create_from_question_category_instance
|
||||
*/
|
||||
public function test_create_from_question_category_instance(): void {
|
||||
$this->resetAfterTest();
|
||||
// Create the category.
|
||||
$generator = $this->getDataGenerator();
|
||||
$course = $generator->create_course();
|
||||
$quiz = $generator->get_plugin_generator('mod_quiz')->create_instance(['course' => $course->id]);
|
||||
$context = context_module::instance($quiz->cmid);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
// Create the category.
|
||||
$category = $questiongenerator->create_question_category([
|
||||
'contextid' => $context->id,
|
||||
'name' => 'New category',
|
||||
'info' => 'Description',
|
||||
'idnumber' => 'newcategory',
|
||||
]);
|
||||
|
||||
// Log the view of this category.
|
||||
$event = \core\event\question_category_viewed::create_from_question_category_instance($category, $context);
|
||||
|
||||
// Trigger and capture the event.
|
||||
$sink = $this->redirectEvents();
|
||||
$event->trigger();
|
||||
$events = $sink->get_events();
|
||||
$event = reset($events);
|
||||
|
||||
// Check that the event data is valid.
|
||||
$this->assertInstanceOf('\core\event\question_category_viewed', $event);
|
||||
$this->assertEquals($context, $event->get_context());
|
||||
$this->assertEquals($category->id, $event->objectid);
|
||||
$this->assertDebuggingNotCalled();
|
||||
}
|
||||
}
|
||||
@@ -62,8 +62,7 @@ class core_question_generator extends component_generator_base {
|
||||
'info' => '',
|
||||
'infoformat' => FORMAT_HTML,
|
||||
'stamp' => make_unique_id_code(),
|
||||
'sortorder' => 999,
|
||||
'idnumber' => null
|
||||
'idnumber' => null,
|
||||
];
|
||||
|
||||
$record = $this->datagenerator->combine_defaults_and_record($defaults, $record);
|
||||
@@ -78,6 +77,10 @@ class core_question_generator extends component_generator_base {
|
||||
if (!isset($record['parent'])) {
|
||||
$record['parent'] = question_get_top_category($record['contextid'], true)->id;
|
||||
}
|
||||
if (!isset($record['sortorder'])) {
|
||||
$manager = new \core_question\category_manager();
|
||||
$record['sortorder'] = $manager->get_max_sortorder($record['parent']) + 1;
|
||||
}
|
||||
$record['id'] = $DB->insert_record('question_categories', $record);
|
||||
return (object) $record;
|
||||
}
|
||||
|
||||
@@ -212,12 +212,8 @@ class version_test extends \advanced_testcase {
|
||||
quiz_add_quiz_question($question->id, $this->quiz);
|
||||
|
||||
// Move the category to system context.
|
||||
$contexts = new \core_question\local\bank\question_edit_contexts($systemcontext);
|
||||
$qcobject = new \qbank_managecategories\question_category_object(null,
|
||||
new \moodle_url('/question/bank/managecategories/category.php', ['courseid' => SITEID]),
|
||||
$contexts->having_one_edit_tab_cap('categories'), 0, null, 0,
|
||||
$contexts->having_cap('moodle/question:add'));
|
||||
$qcobject->move_questions_and_delete_category($qcategorychild->id, $qcategorysys->id);
|
||||
$manager = new category_manager();
|
||||
$manager->move_questions_and_delete_category($qcategorychild->id, $qcategorysys->id);
|
||||
|
||||
// The bank entry record should point to the new category in order to not break quizzes.
|
||||
$sql = "SELECT qbe.questioncategoryid
|
||||
|
||||
Reference in New Issue
Block a user