From fb10b36c04858cf36157a956ac9c1cbbc129f5ec Mon Sep 17 00:00:00 2001 From: Guillermo Gomez Date: Tue, 11 May 2021 16:22:03 +1000 Subject: [PATCH] MDL-71585 qbank_managecategories: Add managecategories to core This implementation will introduce a qbank plugin "managecategories" which will add the question categories feature in the question bank view by replacing the core classes. Having this plugin will give users the flexibility of enabling or disabling the category tab. --- lib/classes/event/question_base.php | 2 +- lib/classes/event/question_category_base.php | 2 +- lib/classes/event/question_moved.php | 2 +- lib/classes/event/questions_exported.php | 3 +- lib/classes/event/questions_imported.php | 3 +- lib/classes/plugin_manager.php | 1 + lib/form/questioncategory.php | 5 +- lib/questionlib.php | 218 ++------ lib/tests/questionlib_test.php | 62 --- lib/upgrade.txt | 11 + mod/quiz/addrandom.php | 3 +- mod/quiz/addrandomform.php | 23 +- .../build/add_question_modal_launcher.min.js | 2 +- .../add_question_modal_launcher.min.js.map | 2 +- mod/quiz/amd/build/add_random_question.min.js | 2 +- .../amd/build/add_random_question.min.js.map | 2 +- .../amd/src/add_question_modal_launcher.js | 7 +- mod/quiz/amd/src/add_random_question.js | 6 +- mod/quiz/classes/output/edit_renderer.php | 3 +- mod/quiz/edit.php | 1 - .../modal_add_random_question.mustache | 4 + .../{ => bank/managecategories}/category.php | 39 +- .../form/question_category_edit_form.php | 127 +++++ .../classes/form/question_move_form.php | 54 ++ .../bank/managecategories/classes/helper.php | 373 +++++++++++++ .../managecategories/classes/navigation.php | 42 ++ .../classes/plugin_feature.php | 35 ++ .../classes/privacy/provider.php | 38 ++ .../classes/question_category_list.php | 135 +++++ .../classes/question_category_list_item.php | 116 ++++ .../classes/question_category_object.php | 504 ++++++++++++++++++ .../lang/en/qbank_managecategories.php | 27 + .../templates/listitem.mustache | 57 ++ .../behat/move_question_categories.feature | 0 .../tests/behat/question_categories.feature | 66 +++ .../question_categories_idnumber.feature | 52 ++ .../view_manage_categories_plugin.feature | 50 ++ .../managecategories/tests/helper_test.php | 228 ++++++++ .../tests/question_category_object_test.php | 343 ++++++++++++ question/bank/managecategories/version.php | 31 ++ question/category_class.php | 18 + question/category_form.php | 5 + .../bank/search/category_condition.php | 4 +- question/classes/local/bank/view.php | 3 +- question/editlib.php | 38 +- question/format.php | 2 +- question/move_form.php | 5 + .../tests/behat/question_categories.feature | 42 +- .../question_categories_idnumber.feature | 35 +- question/tests/category_class_test.php | 178 ------- question/tests/events_test.php | 163 +----- 51 files changed, 2471 insertions(+), 703 deletions(-) rename question/{ => bank/managecategories}/category.php (81%) create mode 100644 question/bank/managecategories/classes/form/question_category_edit_form.php create mode 100644 question/bank/managecategories/classes/form/question_move_form.php create mode 100644 question/bank/managecategories/classes/helper.php create mode 100644 question/bank/managecategories/classes/navigation.php create mode 100644 question/bank/managecategories/classes/plugin_feature.php create mode 100644 question/bank/managecategories/classes/privacy/provider.php create mode 100644 question/bank/managecategories/classes/question_category_list.php create mode 100644 question/bank/managecategories/classes/question_category_list_item.php create mode 100644 question/bank/managecategories/classes/question_category_object.php create mode 100644 question/bank/managecategories/lang/en/qbank_managecategories.php create mode 100644 question/bank/managecategories/templates/listitem.mustache rename question/{ => bank/managecategories}/tests/behat/move_question_categories.feature (100%) create mode 100644 question/bank/managecategories/tests/behat/question_categories.feature create mode 100644 question/bank/managecategories/tests/behat/question_categories_idnumber.feature create mode 100644 question/bank/managecategories/tests/behat/view_manage_categories_plugin.feature create mode 100644 question/bank/managecategories/tests/helper_test.php create mode 100644 question/bank/managecategories/tests/question_category_object_test.php create mode 100644 question/bank/managecategories/version.php delete mode 100644 question/tests/category_class_test.php diff --git a/lib/classes/event/question_base.php b/lib/classes/event/question_base.php index 2f07b8587cb..efa5c8d58c5 100644 --- a/lib/classes/event/question_base.php +++ b/lib/classes/event/question_base.php @@ -60,7 +60,7 @@ abstract class question_base extends base { ['courseid' => $this->courseid, 'cat' => $cat, 'lastchanged' => $this->objectid]); } // Lets try viewing from the frontpage for contexts above course. - return new \moodle_url('/question/category.php', + return new \moodle_url('/question/bank/managecategories/category.php', ['courseid' => SITEID, 'edit' => $this->other['categoryid'], 'lastchanged' => $this->objectid]); } diff --git a/lib/classes/event/question_category_base.php b/lib/classes/event/question_category_base.php index fa1ffc79c3a..e022a351d1b 100644 --- a/lib/classes/event/question_category_base.php +++ b/lib/classes/event/question_category_base.php @@ -58,7 +58,7 @@ abstract class question_category_base extends base { return new \moodle_url('/question/edit.php', ['courseid' => $this->courseid, 'cat' => $cat]); } // Lets try viewing from the frontpage for contexts above course. - return new \moodle_url('/question/category.php', ['courseid' => SITEID, 'edit' => $this->objectid]); + return new \moodle_url('/question/bank/managecategories/category.php', ['courseid' => SITEID, 'edit' => $this->objectid]); } /** diff --git a/lib/classes/event/question_moved.php b/lib/classes/event/question_moved.php index a798f813df2..3f5df468767 100644 --- a/lib/classes/event/question_moved.php +++ b/lib/classes/event/question_moved.php @@ -87,7 +87,7 @@ class question_moved extends question_base { ['courseid' => $this->courseid, 'cat' => $cat, 'lastchanged' => $this->objectid]); } // Lets try viewing from the frontpage for contexts above course. - return new \moodle_url('/question/category.php', + return new \moodle_url('/question/bank/managecategories/category.php', ['courseid' => SITEID, 'edit' => $this->other['newcategoryid'], 'lastchanged' => $this->objectid]); } diff --git a/lib/classes/event/questions_exported.php b/lib/classes/event/questions_exported.php index adc3117a481..4ffe5f8bddf 100644 --- a/lib/classes/event/questions_exported.php +++ b/lib/classes/event/questions_exported.php @@ -83,7 +83,8 @@ class questions_exported extends question_base { } return new \moodle_url('/question/edit.php', ['courseid' => $this->courseid, 'cat' => $cat]); } - return new \moodle_url('/question/category.php', ['courseid' => SITEID, 'edit' => $this->other['categoryid']]); + return new \moodle_url('/question/bank/managecategories/category.php', + ['courseid' => SITEID, 'edit' => $this->other['categoryid']]); } /** diff --git a/lib/classes/event/questions_imported.php b/lib/classes/event/questions_imported.php index d774c40b9d8..26861403135 100644 --- a/lib/classes/event/questions_imported.php +++ b/lib/classes/event/questions_imported.php @@ -83,7 +83,8 @@ class questions_imported extends question_base { } return new \moodle_url('/question/edit.php', ['courseid' => $this->courseid, 'cat' => $cat]); } - return new \moodle_url('/question/category.php', ['courseid' => SITEID, 'edit' => $this->other['categoryid']]); + return new \moodle_url('/question/bank/managecategories/category.php', + ['courseid' => SITEID, 'edit' => $this->other['categoryid']]); } /** diff --git a/lib/classes/plugin_manager.php b/lib/classes/plugin_manager.php index b8da2e822d1..df750084490 100644 --- a/lib/classes/plugin_manager.php +++ b/lib/classes/plugin_manager.php @@ -1943,6 +1943,7 @@ class core_plugin_manager { 'editquestion', 'exportquestions', 'importquestions', + 'managecategories', 'viewcreator', 'viewquestionname', 'viewquestiontext', diff --git a/lib/form/questioncategory.php b/lib/form/questioncategory.php index 32455d58e59..788090bd0f0 100644 --- a/lib/form/questioncategory.php +++ b/lib/form/questioncategory.php @@ -26,6 +26,7 @@ */ global $CFG; +use qbank_managecategories\helper; require_once("$CFG->libdir/form/selectgroups.php"); require_once("$CFG->libdir/questionlib.php"); @@ -59,8 +60,8 @@ class MoodleQuickForm_questioncategory extends MoodleQuickForm_selectgroups { if (is_array($options)) { $this->_options = $options + $this->_options; $this->loadArrayOptGroups( - question_category_options($this->_options['contexts'], $this->_options['top'], $this->_options['currentcat'], - false, $this->_options['nochildrenof'], false)); + helper::question_category_options($this->_options['contexts'], $this->_options['top'], + $this->_options['currentcat'], false, $this->_options['nochildrenof'], false)); } } diff --git a/lib/questionlib.php b/lib/questionlib.php index f9dcf74e3fc..ecaceceeecb 100644 --- a/lib/questionlib.php +++ b/lib/questionlib.php @@ -238,18 +238,16 @@ function match_grade_options($gradeoptionsfull, $grade, $matchgrades = 'error') * - random questions * * @param int $categoryid The category ID. + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 */ function question_remove_stale_questions_from_category($categoryid) { - global $DB; - - $select = 'category = :categoryid AND (qtype = :qtype OR hidden = :hidden)'; - $params = ['categoryid' => $categoryid, 'qtype' => 'random', 'hidden' => 1]; - $questions = $DB->get_recordset_select("question", $select, $params, '', 'id'); - foreach ($questions as $question) { - // The function question_delete_question does not delete questions in use. - question_delete_question($question->id); - } - $questions->close(); + debugging('Function question_remove_stale_questions_from_category() + has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::question_remove_stale_questions_from_category() instead.', + DEBUG_DEVELOPER); + \qbank_managecategories\helper::question_remove_stale_questions_from_category($categoryid); } /** @@ -1146,27 +1144,14 @@ function sort_categories_by_tree(&$categories, $id = 0, $level = 1) { * @param int $id the category to start the indenting process from. * @param int $depth the indent depth. Used in recursive calls. * @return array a new array of categories, in the right order for the tree. + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 */ function flatten_category_tree(&$categories, $id, $depth = 0, $nochildrenof = -1) { - - // Indent the name of this category. - $newcategories = array(); - $newcategories[$id] = $categories[$id]; - $newcategories[$id]->indentedname = str_repeat('   ', $depth) . - $categories[$id]->name; - - // Recursively indent the children. - foreach ($categories[$id]->childids as $childid) { - if ($childid != $nochildrenof) { - $newcategories = $newcategories + flatten_category_tree( - $categories, $childid, $depth + 1, $nochildrenof); - } - } - - // Remove the childids array that were temporarily added. - unset($newcategories[$id]->childids); - - return $newcategories; + debugging('Function flatten_category_tree() has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::flatten_category_tree() instead.', DEBUG_DEVELOPER); + return \qbank_managecategories\helper::flatten_category_tree($categories, $id, $depth, $nochildrenof); } /** @@ -1174,37 +1159,14 @@ function flatten_category_tree(&$categories, $id, $depth = 0, $nochildrenof = -1 * * @param array $categories An array of category objects, for example from the. * @return array The formatted list of categories. + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 */ function add_indented_names($categories, $nochildrenof = -1) { - - // Add an array to each category to hold the child category ids. This array - // will be removed again by flatten_category_tree(). It should not be used - // outside these two functions. - foreach (array_keys($categories) as $id) { - $categories[$id]->childids = array(); - } - - // Build the tree structure, and record which categories are top-level. - // We have to be careful, because the categories array may include published - // categories from other courses, but not their parents. - $toplevelcategoryids = array(); - foreach (array_keys($categories) as $id) { - if (!empty($categories[$id]->parent) && - array_key_exists($categories[$id]->parent, $categories)) { - $categories[$categories[$id]->parent]->childids[] = $id; - } else { - $toplevelcategoryids[] = $id; - } - } - - // Flatten the tree to and add the indents. - $newcategories = array(); - foreach ($toplevelcategoryids as $id) { - $newcategories = $newcategories + flatten_category_tree( - $categories, $id, 0, $nochildrenof); - } - - return $newcategories; + debugging('Function add_indented_names() has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::add_indented_names() instead.', DEBUG_DEVELOPER); + return \qbank_managecategories\helper::add_indented_names($categories, $nochildrenof); } /** @@ -1218,30 +1180,15 @@ function add_indented_names($categories, $nochildrenof = -1) { * @param integer $only_editable if true, exclude categories this user is not allowed to edit. * @param integer $selected optionally, the id of a category to be selected by * default in the dropdown. + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 */ function question_category_select_menu($contexts, $top = false, $currentcat = 0, $selected = "", $nochildrenof = -1) { - $categoriesarray = question_category_options($contexts, $top, $currentcat, - false, $nochildrenof, false); - if ($selected) { - $choose = ''; - } else { - $choose = 'choosedots'; - } - $options = array(); - foreach ($categoriesarray as $group => $opts) { - $options[] = array($group => $opts); - } - echo html_writer::label(get_string('questioncategory', 'core_question'), 'id_movetocategory', false, array('class' => 'accesshide')); - $attrs = array( - 'id' => 'id_movetocategory', - 'class' => 'custom-select', - 'data-action' => 'toggle', - 'data-togglegroup' => 'qbank', - 'data-toggle' => 'action', - 'disabled' => true, - ); - echo html_writer::select($options, 'category', $selected, $choose, $attrs); + debugging('Function question_category_select_menu() has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::question_category_select_menu() instead.', DEBUG_DEVELOPER); + \qbank_managecategories\helper::question_category_select_menu($contexts, $top, $currentcat, $selected, $nochildrenof); } /** @@ -1366,16 +1313,14 @@ function question_make_default_categories($contexts) { * @param string $sortorder used as the ORDER BY clause in the select statement. * @param bool $top Whether to return the top categories or not. * @return array of category objects. + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 */ function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, name ASC', $top = false) { - global $DB; - $topwhere = $top ? '' : 'AND c.parent <> 0'; - return $DB->get_records_sql(" - SELECT c.*, (SELECT count(1) FROM {question} q - WHERE c.id = q.category AND q.hidden='0' AND q.parent='0') AS questioncount - FROM {question_categories} c - WHERE c.contextid IN ($contexts) $topwhere - ORDER BY $sortorder"); + debugging('Function get_categories_for_contexts() has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::get_categories_for_contexts() instead.', DEBUG_DEVELOPER); + return \qbank_managecategories\helper::get_categories_for_contexts($contexts, $sortorder, $top); } /** @@ -1388,81 +1333,27 @@ function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, * @param int $nochildrenof * @param boolean $escapecontextnames Whether the returned name of the thing is to be HTML escaped or not. * @return array + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 */ function question_category_options($contexts, $top = false, $currentcat = 0, $popupform = false, $nochildrenof = -1, $escapecontextnames = true) { - global $CFG; - $pcontexts = array(); - foreach ($contexts as $context) { - $pcontexts[] = $context->id; - } - $contextslist = join(', ', $pcontexts); - - $categories = get_categories_for_contexts($contextslist, 'parent, sortorder, name ASC', $top); - - if ($top) { - $categories = question_fix_top_names($categories); - } - - $categories = question_add_context_in_key($categories); - $categories = add_indented_names($categories, $nochildrenof); - - // sort cats out into different contexts - $categoriesarray = array(); - foreach ($pcontexts as $contextid) { - $context = context::instance_by_id($contextid); - $contextstring = $context->get_context_name(true, true, $escapecontextnames); - foreach ($categories as $category) { - if ($category->contextid == $contextid) { - $cid = $category->id; - if ($currentcat != $cid || $currentcat == 0) { - $a = new stdClass; - $a->name = format_string($category->indentedname, true, - array('context' => $context)); - if ($category->idnumber !== null && $category->idnumber !== '') { - $a->idnumber = s($category->idnumber); - } - if (!empty($category->questioncount)) { - $a->questioncount = $category->questioncount; - } - if (isset($a->idnumber) && isset($a->questioncount)) { - $formattedname = get_string('categorynamewithidnumberandcount', 'question', $a); - } else if (isset($a->idnumber)) { - $formattedname = get_string('categorynamewithidnumber', 'question', $a); - } else if (isset($a->questioncount)) { - $formattedname = get_string('categorynamewithcount', 'question', $a); - } else { - $formattedname = $a->name; - } - $categoriesarray[$contextstring][$cid] = $formattedname; - } - } - } - } - if ($popupform) { - $popupcats = array(); - foreach ($categoriesarray as $contextstring => $optgroup) { - $group = array(); - foreach ($optgroup as $key => $value) { - $key = str_replace($CFG->wwwroot, '', $key); - $group[$key] = $value; - } - $popupcats[] = array($contextstring => $group); - } - return $popupcats; - } else { - return $categoriesarray; - } + debugging('Function question_category_options() has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::question_category_options() instead.', DEBUG_DEVELOPER); + return \qbank_managecategories\helper::question_category_options($contexts, $top, $currentcat, + $popupform, $nochildrenof, $escapecontextnames); } +/** + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 + */ function question_add_context_in_key($categories) { - $newcatarray = array(); - foreach ($categories as $id => $category) { - $category->parent = "$category->parent,$category->contextid"; - $category->id = "$category->id,$category->contextid"; - $newcatarray["$id,$category->contextid"] = $category; - } - return $newcatarray; + debugging('Function question_add_context_in_key() has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::question_add_context_in_key() instead.', DEBUG_DEVELOPER); + return \qbank_managecategories\helper::question_add_context_in_key($categories); } /** @@ -1471,17 +1362,14 @@ function question_add_context_in_key($categories) { * @param array $categories An array of question categories. * @param boolean $escape Whether the returned name of the thing is to be HTML escaped or not. * @return array The same question category list given to the function, with the top category names being translated. + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 */ function question_fix_top_names($categories, $escape = true) { - - foreach ($categories as $id => $category) { - if ($category->parent == 0) { - $context = context::instance_by_id($category->contextid); - $categories[$id]->name = get_string('topfor', 'question', $context->get_context_name(false, false, $escape)); - } - } - - return $categories; + debugging('Function question_fix_top_names() has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::question_fix_top_names() instead.', DEBUG_DEVELOPER); + return \qbank_managecategories\helper::question_fix_top_names($categories, $escape); } /** diff --git a/lib/tests/questionlib_test.php b/lib/tests/questionlib_test.php index 0ffa10cfe77..655286513e3 100644 --- a/lib/tests/questionlib_test.php +++ b/lib/tests/questionlib_test.php @@ -517,68 +517,6 @@ class core_questionlib_testcase extends advanced_testcase { $this->assertEquals(1, $DB->count_records('question_categories', ['contextid' => $qcat->contextid, 'parent' => 0])); } - public function test_question_remove_stale_questions_from_category() { - global $DB; - $this->resetAfterTest(true); - $this->setAdminUser(); - - $dg = $this->getDataGenerator(); - $course = $dg->create_course(); - $quiz = $dg->create_module('quiz', ['course' => $course->id]); - - $qgen = $dg->get_plugin_generator('core_question'); - $context = context_system::instance(); - - $qcat1 = $qgen->create_question_category(['contextid' => $context->id]); - $q1a = $qgen->create_question('shortanswer', null, ['category' => $qcat1->id]); // Will be hidden. - $DB->set_field('question', 'hidden', 1, ['id' => $q1a->id]); - - $qcat2 = $qgen->create_question_category(['contextid' => $context->id]); - $q2a = $qgen->create_question('shortanswer', null, ['category' => $qcat2->id]); // Will be hidden. - $q2b = $qgen->create_question('shortanswer', null, ['category' => $qcat2->id]); // Will be hidden but used. - $DB->set_field('question', 'hidden', 1, ['id' => $q2a->id]); - $DB->set_field('question', 'hidden', 1, ['id' => $q2b->id]); - quiz_add_quiz_question($q2b->id, $quiz); - quiz_add_random_questions($quiz, 0, $qcat2->id, 1, false); - - // 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 q.* - FROM {question} q - JOIN {quiz_slots} s ON s.questionid = q.id - WHERE q.qtype = :qtype - AND s.quizid = :quizid", - array('qtype' => 'random', 'quizid' => $quiz->id), 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 = $qgen->create_question('random', null, ['category' => $qcat1->id]); // Will not be used. - $q2c = $qgen->create_question('random', null, ['category' => $qcat2->id]); // Will not be used. - - $this->assertEquals(2, $DB->count_records('question', ['category' => $qcat1->id])); - $this->assertEquals(4, $DB->count_records('question', ['category' => $qcat2->id])); - - // Non-existing category, nothing will happen. - question_remove_stale_questions_from_category(0); - $this->assertEquals(2, $DB->count_records('question', ['category' => $qcat1->id])); - $this->assertEquals(4, $DB->count_records('question', ['category' => $qcat2->id])); - - // First category, should be empty afterwards. - question_remove_stale_questions_from_category($qcat1->id); - $this->assertEquals(0, $DB->count_records('question', ['category' => $qcat1->id])); - $this->assertEquals(4, $DB->count_records('question', ['category' => $qcat2->id])); - $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. - question_remove_stale_questions_from_category($qcat2->id); - $this->assertEquals(0, $DB->count_records('question', ['category' => $qcat1->id])); - $this->assertEquals(2, $DB->count_records('question', ['category' => $qcat2->id])); - $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', ['id' => $q2d->id])); - } - /** * get_question_options should add the category object to the given question. */ diff --git a/lib/upgrade.txt b/lib/upgrade.txt index 22419ffe8c1..fa49d98a30c 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -69,6 +69,17 @@ information provided here is intended especially for developers. rendered. The default icon for "select" types has also changed to a dropdown caret ("t/expanded"). * The function message_send() in messagelib.php now returns false if there is an error sending the message to the message processor (MDL-70046). +* The following functions are deprecated in questionlib.php and moved to the new location. + These are marked for final deprecation on 4.4: + - question_remove_stale_questions_from_category() => + qbank_managecategories\helper::question_remove_stale_questions_from_category() + - flatten_category_tree() => qbank_managecategories\helper::flatten_category_tree() + - add_indented_names() => qbank_managecategories\helper::add_indented_names() + - question_category_select_menu() => qbank_managecategories\helper::question_category_select_menu() + - get_categories_for_contexts() => qbank_managecategories\helper::get_categories_for_contexts() + - question_category_options() => qbank_managecategories\helper::question_category_options() + - question_add_context_in_key() => qbank_managecategories\helper::question_add_context_in_key() + - question_fix_top_names() => qbank_managecategories\helper::question_fix_top_names() === 3.11.2 === * For security reasons, filelib has been updated so all requests now use emulated redirects. diff --git a/mod/quiz/addrandom.php b/mod/quiz/addrandom.php index 155945626f5..5a20d24d9eb 100644 --- a/mod/quiz/addrandom.php +++ b/mod/quiz/addrandom.php @@ -28,7 +28,8 @@ require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->dirroot . '/mod/quiz/addrandomform.php'); require_once($CFG->dirroot . '/question/editlib.php'); -require_once($CFG->dirroot . '/question/category_class.php'); + +use qbank_managecategories\question_category_object; list($thispageurl, $contexts, $cmid, $cm, $quiz, $pagevars) = question_edit_setup('editq', '/mod/quiz/addrandom.php', true); diff --git a/mod/quiz/addrandomform.php b/mod/quiz/addrandomform.php index b75314222d3..d16121fc017 100644 --- a/mod/quiz/addrandomform.php +++ b/mod/quiz/addrandomform.php @@ -85,19 +85,22 @@ class quiz_add_random_form extends moodleform { $mform->addElement('submit', 'existingcategory', get_string('addrandomquestion', 'quiz')); - // Random from a new category section. - $mform->addElement('header', 'newcategoryheader', - get_string('randomquestionusinganewcategory', 'quiz')); + // If the manage categories plugins is enabled, add the elements to create a new category in the form. + if (\core\plugininfo\qbank::is_plugin_enabled(\qbank_managecategories\helper::PLUGINNAME)) { + // Random from a new category section. + $mform->addElement('header', 'newcategoryheader', + get_string('randomquestionusinganewcategory', 'quiz')); - $mform->addElement('text', 'name', get_string('name'), 'maxlength="254" size="50"'); - $mform->setType('name', PARAM_TEXT); + $mform->addElement('text', 'name', get_string('name'), 'maxlength="254" size="50"'); + $mform->setType('name', PARAM_TEXT); - $mform->addElement('questioncategory', 'parent', get_string('parentcategory', 'question'), - array('contexts' => $usablecontexts, 'top' => true)); - $mform->addHelpButton('parent', 'parentcategory', 'question'); + $mform->addElement('questioncategory', 'parent', get_string('parentcategory', 'question'), + array('contexts' => $usablecontexts, 'top' => true)); + $mform->addHelpButton('parent', 'parentcategory', 'question'); - $mform->addElement('submit', 'newcategory', - get_string('createcategoryandaddrandomquestion', 'quiz')); + $mform->addElement('submit', 'newcategory', + get_string('createcategoryandaddrandomquestion', 'quiz')); + } // Cancel button. $mform->addElement('cancel'); diff --git a/mod/quiz/amd/build/add_question_modal_launcher.min.js b/mod/quiz/amd/build/add_question_modal_launcher.min.js index 3bc0b40ff1e..99ad91c2185 100644 --- a/mod/quiz/amd/build/add_question_modal_launcher.min.js +++ b/mod/quiz/amd/build/add_question_modal_launcher.min.js @@ -1,2 +1,2 @@ -define ("mod_quiz/add_question_modal_launcher",["jquery","core/notification","core/modal_factory"],function(a,b,c){return{init:function init(d,e,f,g){var h=a("body");return c.create({type:d,large:!0,preShowCallback:function preShowCallback(b,c){b=a(b);c.setContextId(f);c.setAddOnPageId(b.attr("data-addonpage"));c.setTitle(b.attr("data-header"));if(g){g(b,c)}}},[h,e]).fail(b.exception)}}}); +define ("mod_quiz/add_question_modal_launcher",["jquery","core/notification","core/modal_factory"],function(a,b,c){return{init:function init(d,e,f,g){var h=4.\n\n/**\n * Initialise the an add question modal on the quiz page.\n *\n * @module mod_quiz/add_question_modal_launcher\n * @copyright 2018 Ryan Wyllie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(\n [\n 'jquery',\n 'core/notification',\n 'core/modal_factory',\n ],\n function(\n $,\n Notification,\n ModalFactory\n ) {\n\n return {\n /**\n * Create a modal using the modal factory and add listeners to launch the\n * modal when clicked.\n *\n * @param {string} modalType Which modal to create\n * @param {string} selector The selectors for the elements that trigger the modal\n * @param {int} contextId The current context id\n * @param {function} preShowCallback A callback to execute before the modal is shown\n * @return {promise} Resolved with the modal\n */\n init: function(modalType, selector, contextId, preShowCallback) {\n var body = $('body');\n\n // Create a question bank modal using the factory.\n // The same modal will be used by all of the add question\n // links that match \"selector\" on the page. The content\n // of the modal will be changed depending on which link is\n // clicked.\n return ModalFactory.create(\n {\n type: modalType,\n large: true,\n // This callback executes before the modal is shown when the\n // trigger element is clicked.\n preShowCallback: function(triggerElement, modal) {\n triggerElement = $(triggerElement);\n modal.setContextId(contextId);\n modal.setAddOnPageId(triggerElement.attr('data-addonpage'));\n modal.setTitle(triggerElement.attr('data-header'));\n\n if (preShowCallback) {\n preShowCallback(triggerElement, modal);\n }\n }\n },\n // Created a deligated listener rather than a single\n // trigger element.\n [body, selector]\n ).fail(Notification.exception);\n }\n };\n});\n"],"file":"add_question_modal_launcher.min.js"} \ No newline at end of file +{"version":3,"sources":["../src/add_question_modal_launcher.js"],"names":["define","$","Notification","ModalFactory","init","modalType","selector","contextId","preShowCallback","showNewCategory","body","create","type","large","templateContext","hidden","triggerElement","modal","setContextId","setAddOnPageId","attr","setTitle","fail","exception"],"mappings":"AAsBAA,OAAM,wCACF,CACI,QADJ,CAEI,mBAFJ,CAGI,oBAHJ,CADE,CAMF,SACIC,CADJ,CAEIC,CAFJ,CAGIC,CAHJ,CAIE,CAEF,MAAO,CAYHC,IAAI,CAAE,cAASC,CAAT,CAAoBC,CAApB,CAA8BC,CAA9B,CAAyCC,CAAzC,CAAkF,IAAxBC,CAAAA,CAAwB,2DAChFC,CAAI,CAAGT,CAAC,CAAC,MAAD,CADwE,CAWpF,MAAOE,CAAAA,CAAY,CAACQ,MAAb,CACH,CACIC,IAAI,CAAEP,CADV,CAEIQ,KAAK,GAFT,CAGIC,eAAe,CAbD,CAClBC,MAAM,CAAEN,CADU,CAUlB,CAMID,eAAe,CAAE,yBAASQ,CAAT,CAAyBC,CAAzB,CAAgC,CAC7CD,CAAc,CAAGf,CAAC,CAACe,CAAD,CAAlB,CACAC,CAAK,CAACC,YAAN,CAAmBX,CAAnB,EACAU,CAAK,CAACE,cAAN,CAAqBH,CAAc,CAACI,IAAf,CAAoB,gBAApB,CAArB,EACAH,CAAK,CAACI,QAAN,CAAeL,CAAc,CAACI,IAAf,CAAoB,aAApB,CAAf,EAEA,GAAIZ,CAAJ,CAAqB,CACjBA,CAAe,CAACQ,CAAD,CAAiBC,CAAjB,CAClB,CACJ,CAfL,CADG,CAoBH,CAACP,CAAD,CAAOJ,CAAP,CApBG,EAqBLgB,IArBK,CAqBApB,CAAY,CAACqB,SArBb,CAsBV,CA7CE,CA+CV,CA3DK,CAAN","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Initialise the an add question modal on the quiz page.\n *\n * @module mod_quiz/add_question_modal_launcher\n * @copyright 2018 Ryan Wyllie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(\n [\n 'jquery',\n 'core/notification',\n 'core/modal_factory',\n ],\n function(\n $,\n Notification,\n ModalFactory\n ) {\n\n return {\n /**\n * Create a modal using the modal factory and add listeners to launch the\n * modal when clicked.\n *\n * @param {string} modalType Which modal to create\n * @param {string} selector The selectors for the elements that trigger the modal\n * @param {int} contextId The current context id\n * @param {function} preShowCallback A callback to execute before the modal is shown\n * @param {boolean} showNewCategory Display the New category tab when selecting random questions.\n * @return {promise} Resolved with the modal\n */\n init: function(modalType, selector, contextId, preShowCallback, showNewCategory = true) {\n var body = $('body');\n let templateContext = {\n hidden: showNewCategory,\n };\n\n // Create a question bank modal using the factory.\n // The same modal will be used by all of the add question\n // links that match \"selector\" on the page. The content\n // of the modal will be changed depending on which link is\n // clicked.\n return ModalFactory.create(\n {\n type: modalType,\n large: true,\n templateContext: templateContext,\n // This callback executes before the modal is shown when the\n // trigger element is clicked.\n preShowCallback: function(triggerElement, modal) {\n triggerElement = $(triggerElement);\n modal.setContextId(contextId);\n modal.setAddOnPageId(triggerElement.attr('data-addonpage'));\n modal.setTitle(triggerElement.attr('data-header'));\n\n if (preShowCallback) {\n preShowCallback(triggerElement, modal);\n }\n }\n },\n // Created a deligated listener rather than a single\n // trigger element.\n [body, selector]\n ).fail(Notification.exception);\n }\n };\n});\n"],"file":"add_question_modal_launcher.min.js"} \ No newline at end of file diff --git a/mod/quiz/amd/build/add_random_question.min.js b/mod/quiz/amd/build/add_random_question.min.js index 5096d7f8ba5..8d5049de9c7 100644 --- a/mod/quiz/amd/build/add_random_question.min.js +++ b/mod/quiz/amd/build/add_random_question.min.js @@ -1,2 +1,2 @@ -define ("mod_quiz/add_random_question",["mod_quiz/add_question_modal_launcher","mod_quiz/modal_add_random_question"],function(a,b){return{init:function init(c,d,e,f){a.init(b.TYPE,".menu [data-action=\"addarandomquestion\"]",c,function(a,b){b.setCategory(d);b.setReturnUrl(e);b.setCMID(f)})}}}); +define ("mod_quiz/add_random_question",["mod_quiz/add_question_modal_launcher","mod_quiz/modal_add_random_question"],function(a,b){return{init:function init(c,d,e,f){var g=4.\n\n/**\n * Initialise the add random question modal on the quiz page.\n *\n * @module mod_quiz/add_random_question\n * @copyright 2018 Ryan Wyllie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(\n [\n 'mod_quiz/add_question_modal_launcher',\n 'mod_quiz/modal_add_random_question'\n ],\n function(\n AddQuestionModalLauncher,\n ModalAddRandomQuestion\n ) {\n\n return {\n /**\n * Create the add random question modal.\n *\n * @param {int} contextId Current context id.\n * @param {string} category Category id and category context id comma separated.\n * @param {string} returnUrl URL to return to after form submission.\n * @param {int} cmid Current course module id.\n */\n init: function(contextId, category, returnUrl, cmid) {\n AddQuestionModalLauncher.init(\n ModalAddRandomQuestion.TYPE,\n '.menu [data-action=\"addarandomquestion\"]',\n contextId,\n // Additional values that should be set before the modal is shown.\n function(triggerElement, modal) {\n modal.setCategory(category);\n modal.setReturnUrl(returnUrl);\n modal.setCMID(cmid);\n }\n );\n }\n };\n});\n"],"file":"add_random_question.min.js"} \ No newline at end of file +{"version":3,"sources":["../src/add_random_question.js"],"names":["define","AddQuestionModalLauncher","ModalAddRandomQuestion","init","contextId","category","returnUrl","cmid","showNewCategory","TYPE","triggerElement","modal","setCategory","setReturnUrl","setCMID"],"mappings":"AAsBAA,OAAM,gCACF,CACI,sCADJ,CAEI,oCAFJ,CADE,CAKF,SACIC,CADJ,CAEIC,CAFJ,CAGE,CAEF,MAAO,CAUHC,IAAI,CAAE,cAASC,CAAT,CAAoBC,CAApB,CAA8BC,CAA9B,CAAyCC,CAAzC,CAAuE,IAAxBC,CAAAA,CAAwB,2DACzEP,CAAwB,CAACE,IAAzB,CACID,CAAsB,CAACO,IAD3B,CAEI,4CAFJ,CAGIL,CAHJ,CAKI,SAASM,CAAT,CAAyBC,CAAzB,CAAgC,CAC5BA,CAAK,CAACC,WAAN,CAAkBP,CAAlB,EACAM,CAAK,CAACE,YAAN,CAAmBP,CAAnB,EACAK,CAAK,CAACG,OAAN,CAAcP,CAAd,CACH,CATL,CAUIC,CAVJ,CAYH,CAvBE,CAyBV,CAnCK,CAAN","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Initialise the add random question modal on the quiz page.\n *\n * @module mod_quiz/add_random_question\n * @copyright 2018 Ryan Wyllie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(\n [\n 'mod_quiz/add_question_modal_launcher',\n 'mod_quiz/modal_add_random_question'\n ],\n function(\n AddQuestionModalLauncher,\n ModalAddRandomQuestion\n ) {\n\n return {\n /**\n * Create the add random question modal.\n *\n * @param {int} contextId Current context id.\n * @param {string} category Category id and category context id comma separated.\n * @param {string} returnUrl URL to return to after form submission.\n * @param {int} cmid Current course module id.\n * @param {boolean} showNewCategory Display the New category tab when selecting random questions.\n */\n init: function(contextId, category, returnUrl, cmid, showNewCategory = true) {\n AddQuestionModalLauncher.init(\n ModalAddRandomQuestion.TYPE,\n '.menu [data-action=\"addarandomquestion\"]',\n contextId,\n // Additional values that should be set before the modal is shown.\n function(triggerElement, modal) {\n modal.setCategory(category);\n modal.setReturnUrl(returnUrl);\n modal.setCMID(cmid);\n },\n showNewCategory\n );\n }\n };\n});\n"],"file":"add_random_question.min.js"} \ No newline at end of file diff --git a/mod/quiz/amd/src/add_question_modal_launcher.js b/mod/quiz/amd/src/add_question_modal_launcher.js index 00df7140e75..96151acef81 100644 --- a/mod/quiz/amd/src/add_question_modal_launcher.js +++ b/mod/quiz/amd/src/add_question_modal_launcher.js @@ -41,10 +41,14 @@ define( * @param {string} selector The selectors for the elements that trigger the modal * @param {int} contextId The current context id * @param {function} preShowCallback A callback to execute before the modal is shown + * @param {boolean} showNewCategory Display the New category tab when selecting random questions. * @return {promise} Resolved with the modal */ - init: function(modalType, selector, contextId, preShowCallback) { + init: function(modalType, selector, contextId, preShowCallback, showNewCategory = true) { var body = $('body'); + let templateContext = { + hidden: showNewCategory, + }; // Create a question bank modal using the factory. // The same modal will be used by all of the add question @@ -55,6 +59,7 @@ define( { type: modalType, large: true, + templateContext: templateContext, // This callback executes before the modal is shown when the // trigger element is clicked. preShowCallback: function(triggerElement, modal) { diff --git a/mod/quiz/amd/src/add_random_question.js b/mod/quiz/amd/src/add_random_question.js index 4a00855e381..f87181cd829 100644 --- a/mod/quiz/amd/src/add_random_question.js +++ b/mod/quiz/amd/src/add_random_question.js @@ -38,8 +38,9 @@ define( * @param {string} category Category id and category context id comma separated. * @param {string} returnUrl URL to return to after form submission. * @param {int} cmid Current course module id. + * @param {boolean} showNewCategory Display the New category tab when selecting random questions. */ - init: function(contextId, category, returnUrl, cmid) { + init: function(contextId, category, returnUrl, cmid, showNewCategory = true) { AddQuestionModalLauncher.init( ModalAddRandomQuestion.TYPE, '.menu [data-action="addarandomquestion"]', @@ -49,7 +50,8 @@ define( modal.setCategory(category); modal.setReturnUrl(returnUrl); modal.setCMID(cmid); - } + }, + showNewCategory ); } }; diff --git a/mod/quiz/classes/output/edit_renderer.php b/mod/quiz/classes/output/edit_renderer.php index 544134ec891..f7d15b75a30 100644 --- a/mod/quiz/classes/output/edit_renderer.php +++ b/mod/quiz/classes/output/edit_renderer.php @@ -119,7 +119,8 @@ class edit_renderer extends \plugin_renderer_base { $thiscontext->id, $pagevars['cat'], $pageurl->out_as_local_url(true), - $pageurl->param('cmid') + $pageurl->param('cmid'), + \core\plugininfo\qbank::is_plugin_enabled(\qbank_managecategories\helper::PLUGINNAME), ]); // Include the question chooser. diff --git a/mod/quiz/edit.php b/mod/quiz/edit.php index 12abf0da1f3..e72c71d58e2 100644 --- a/mod/quiz/edit.php +++ b/mod/quiz/edit.php @@ -45,7 +45,6 @@ require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->dirroot . '/mod/quiz/addrandomform.php'); require_once($CFG->dirroot . '/question/editlib.php'); -require_once($CFG->dirroot . '/question/category_class.php'); // These params are only passed from page request to request while we stay on // this page otherwise they would go in question_edit_setup. diff --git a/mod/quiz/templates/modal_add_random_question.mustache b/mod/quiz/templates/modal_add_random_question.mustache index 6d538c7c78f..5979eb6c5ca 100644 --- a/mod/quiz/templates/modal_add_random_question.mustache +++ b/mod/quiz/templates/modal_add_random_question.mustache @@ -47,6 +47,7 @@ {{#str}} existingcategory, mod_quiz {{/str}} + {{#hidden}} + {{/hidden}}
@@ -65,11 +67,13 @@ role="tabpanel" data-region="existing-category-container">
+ {{#hidden}}
+ {{/hidden}}
{{/body}} {{/ core/modal }} diff --git a/question/category.php b/question/bank/managecategories/category.php similarity index 81% rename from question/category.php rename to question/bank/managecategories/category.php index c740522a63a..c558ee1686b 100644 --- a/question/category.php +++ b/question/bank/managecategories/category.php @@ -17,19 +17,24 @@ /** * This script allows a teacher to create, edit and delete question categories. * - * @package moodlecore - * @subpackage questionbank + * @package qbank_managecategories * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @author 2021, Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ - -require_once("../config.php"); +require_once(__DIR__ . '/../../../config.php'); require_once($CFG->dirroot."/question/editlib.php"); -require_once($CFG->dirroot."/question/category_class.php"); + +use qbank_managecategories\form\question_move_form; +use qbank_managecategories\helper; +use qbank_managecategories\question_category_object; + +require_login(); +core_question\local\bank\helper::require_plugin_enabled(helper::PLUGINNAME); list($thispageurl, $contexts, $cmid, $cm, $module, $pagevars) = - question_edit_setup('categories', '/question/category.php'); + question_edit_setup('categories', '/question/bank/managecategories/category.php'); // Get values from form for actions on this page. $param = new stdClass(); @@ -48,7 +53,7 @@ $param->moveto = optional_param('moveto', 0, PARAM_INT); $param->edit = optional_param('edit', 0, PARAM_INT); $url = new moodle_url($thispageurl); -foreach ((array)$param as $key=>$value) { +foreach ((array)$param as $key => $value) { if (($key !== 'cancel' && $value !== 0) || ($key === 'cancel' && $value !== '')) { $url->param($key, $value); } @@ -78,9 +83,9 @@ if ($param->moveupcontext || $param->movedowncontext) { } $newtopcat = question_get_top_category($param->tocontext); if (!$newtopcat) { - print_error('invalidcontext'); + throw new moodle_exception('invalidcontext'); } - $oldcat = $DB->get_record('question_categories', array('id' => $catid), '*', MUST_EXIST); + $oldcat = $DB->get_record('question_categories', ['id' => $catid], '*', MUST_EXIST); // Log the move to another context. $category = new stdClass(); $category->id = explode(',', $pagevars['cat'], -1)[0]; @@ -92,18 +97,18 @@ if ($param->moveupcontext || $param->movedowncontext) { } if ($param->delete) { - if (!$category = $DB->get_record("question_categories", array("id" => $param->delete))) { - print_error('nocate', 'question', $thispageurl->out(), $param->delete); + if (!$category = $DB->get_record("question_categories", ["id" => $param->delete])) { + throw new moodle_exception('nocate', 'question', $thispageurl->out(), $param->delete); } - question_remove_stale_questions_from_category($param->delete); - $questionstomove = $DB->count_records("question", array("category" => $param->delete)); + helper::question_remove_stale_questions_from_category($param->delete); + $questionstomove = $DB->count_records("question", ["category" => $param->delete]); // Second pass, if we still have questions to move, setup the form. if ($questionstomove) { $categorycontext = context::instance_by_id($category->contextid); $qcobject->moveform = new question_move_form($thispageurl, - array('contexts' => array($categorycontext), 'currentcat' => $param->delete)); + ['contexts' => [$categorycontext], 'currentcat' => $param->delete]); if ($qcobject->moveform->is_cancelled()) { redirect($thispageurl); } else if ($formdata = $qcobject->moveform->get_data()) { @@ -122,7 +127,7 @@ if ($qcobject->catform->is_cancelled()) { } else if ($catformdata = $qcobject->catform->get_data()) { $catformdata->infoformat = $catformdata->info['format']; $catformdata->info = $catformdata->info['text']; - if (!$catformdata->id) {//new category + if (!$catformdata->id) {// New category. $qcobject->add_category($catformdata->parent, $catformdata->name, $catformdata->info, false, $catformdata->infoformat, $catformdata->idnumber); } else { @@ -131,7 +136,7 @@ if ($qcobject->catform->is_cancelled()) { } redirect($thispageurl); } else if ((!empty($param->delete) and (!$questionstomove) and confirm_sesskey())) { - $qcobject->delete_category($param->delete);//delete the category now no questions to move + $qcobject->delete_category($param->delete);// Delete the category now no questions to move. $thispageurl->remove_params('cat', 'category'); redirect($thispageurl); } @@ -151,7 +156,7 @@ echo $renderer->extra_horizontal_navigation(); // Display the UI. if (!empty($param->edit)) { $qcobject->edit_single_category($param->edit); -} else if ($questionstomove){ +} else if ($questionstomove) { $qcobject->display_move_form($questionstomove, $category); } else { // Display the user interface. diff --git a/question/bank/managecategories/classes/form/question_category_edit_form.php b/question/bank/managecategories/classes/form/question_category_edit_form.php new file mode 100644 index 00000000000..8bb43378f54 --- /dev/null +++ b/question/bank/managecategories/classes/form/question_category_edit_form.php @@ -0,0 +1,127 @@ +. + +namespace qbank_managecategories\form; + +use moodleform; +use qbank_managecategories\helper; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir.'/formslib.php'); + + +/** + * Defines the form for editing question categories. + * + * Form for editing questions categories (name, description, etc.) + * + * @package qbank_managecategories + * @copyright 2007 Jamie Pratt me@jamiep.org + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_category_edit_form extends moodleform { + + /** + * Build the form definition. + * + * This adds all the form fields that the manage categories feature needs. + * @throws \coding_exception + */ + protected function definition() { + $mform = $this->_form; + + $contexts = $this->_customdata['contexts']; + $currentcat = $this->_customdata['currentcat']; + + $mform->addElement('header', 'categoryheader', get_string('addcategory', 'question')); + + $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)) { + $mform->hardFreeze('parent'); + } + $mform->addHelpButton('parent', 'parentcategory', 'question'); + + $mform->addElement('text', 'name', get_string('name'), 'maxlength="254" size="50"'); + $mform->setDefault('name', ''); + $mform->addRule('name', get_string('categorynamecantbeblank', 'question'), 'required', null, 'client'); + $mform->setType('name', PARAM_TEXT); + + $mform->addElement('editor', 'info', get_string('categoryinfo', 'question'), + ['rows' => 10], ['noclean' => 1]); + $mform->setDefault('info', ''); + $mform->setType('info', PARAM_RAW); + + $mform->addElement('text', 'idnumber', get_string('idnumber', 'question'), 'maxlength="100" size="10"'); + $mform->addHelpButton('idnumber', 'idnumber', 'question'); + $mform->setType('idnumber', PARAM_RAW); + + $this->add_action_buttons(false, get_string('addcategory', 'question')); + + $mform->addElement('hidden', 'id', 0); + $mform->setType('id', PARAM_INT); + } + + /** + * Set data method. + * + * Add additional information to current data. + * @param \stdClass|array $current Object or array of default current data. + */ + public function set_data($current) { + if (is_object($current)) { + $current = (array) $current; + } + if (!empty($current['info'])) { + $current['info'] = ['text' => $current['info'], 'infoformat' => $current['infoformat']]; + } else { + $current['info'] = ['text' => '', 'infoformat' => FORMAT_HTML]; + } + parent::set_data($current); + } + + /** + * Validation. + * + * @param array $data + * @param array $files + * @return array the errors that were found + * @throws \dml_exception|\coding_exception + */ + public function validation($data, $files) { + global $DB; + + $errors = parent::validation($data, $files); + + // Add field validation check for duplicate idnumber. + list($parentid, $contextid) = explode(',', $data['parent']); + if (((string) $data['idnumber'] !== '') && !empty($contextid)) { + $conditions = 'contextid = ? AND idnumber = ?'; + $params = [$contextid, $data['idnumber']]; + if (!empty($data['id'])) { + $conditions .= ' AND id <> ?'; + $params[] = $data['id']; + } + if ($DB->record_exists_select('question_categories', $conditions, $params)) { + $errors['idnumber'] = get_string('idnumbertaken', 'error'); + } + } + + return $errors; + } +} diff --git a/question/bank/managecategories/classes/form/question_move_form.php b/question/bank/managecategories/classes/form/question_move_form.php new file mode 100644 index 00000000000..9d1ffc29f88 --- /dev/null +++ b/question/bank/managecategories/classes/form/question_move_form.php @@ -0,0 +1,54 @@ +. + +namespace qbank_managecategories\form; + +use moodleform; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir . '/formslib.php'); + + +/** + * Form for moving questions between categories. + * + * @package qbank_managecategories + * @copyright 2008 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_move_form extends moodleform { + + /** + * Build the form definition. + * + * This adds all the form fields that the question move feature needs. + * @throws \coding_exception + */ + protected function definition() { + $mform = $this->_form; + + $currentcat = $this->_customdata['currentcat']; + $contexts = $this->_customdata['contexts']; + + $mform->addElement('questioncategory', 'category', get_string('category', 'question'), compact('contexts', 'currentcat')); + + $this->add_action_buttons(true, get_string('categorymoveto', 'question')); + + $mform->addElement('hidden', 'delete', $currentcat); + $mform->setType('delete', PARAM_INT); + } +} diff --git a/question/bank/managecategories/classes/helper.php b/question/bank/managecategories/classes/helper.php new file mode 100644 index 00000000000..f84abf45ed8 --- /dev/null +++ b/question/bank/managecategories/classes/helper.php @@ -0,0 +1,373 @@ +. + +namespace qbank_managecategories; + +use context; +use moodle_exception; +use html_writer; + +/** + * Class helper contains all the library functions. + * + * Library functions used by qbank_managecategories. + * This code is based on lib/questionlib.php by Martin Dougiamas. + * + * @package qbank_managecategories + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Guillermo Gomez Arias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class helper { + + /** + * Name of this plugin. + */ + const PLUGINNAME = 'qbank_managecategories'; + + /** + * Remove stale questions from a category. + * + * While questions should not be left behind when they are not used any more, + * it does happen, maybe via restore, or old logic, or uncovered scenarios. When + * this happens, the users are unable to delete the question category unless + * they move those stale questions to another one category, but to them the + * category is empty as it does not contain anything. The purpose of this function + * is to detect the questions that may have gone stale and remove them. + * + * You will typically use this prior to checking if the category contains questions. + * + * The stale questions (unused and hidden to the user) handled are: + * - hidden questions + * - random questions + * + * @param int $categoryid The category ID. + * @throws \dml_exception + */ + public static function question_remove_stale_questions_from_category(int $categoryid): void { + global $DB; + + $select = 'category = :categoryid AND (qtype = :qtype OR hidden = :hidden)'; + $params = ['categoryid' => $categoryid, 'qtype' => 'random', 'hidden' => 1]; + $questions = $DB->get_recordset_select("question", $select, $params, '', 'id'); + foreach ($questions as $question) { + // The function question_delete_question does not delete questions in use. + question_delete_question($question->id); + } + $questions->close(); + } + + /** + * 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 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]); + } + + /** + * Checks whether the category is a "Top" category (with no parent). + * + * @param int $categoryid a category id. + * @return bool + * @throws \dml_exception + */ + public static function question_is_top_category(int $categoryid): bool { + global $DB; + return 0 == $DB->get_field('question_categories', 'parent', ['id' => $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 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)); + } + } + + /** + * Only for the use of add_indented_names(). + * + * Recursively adds an indentedname field to each category, starting with the category + * with id $id, and dealing with that category and all its children, and + * return a new array, with those categories in the right order. + * + * @param array $categories an array of categories which has had childids + * fields added by flatten_category_tree(). Passed by reference for + * performance only. It is not modfied. + * @param int $id the category to start the indenting process from. + * @param int $depth the indent depth. Used in recursive calls. + * @param int $nochildrenof + * @return array a new array of categories, in the right order for the tree. + */ + public static function flatten_category_tree(array &$categories, $id, int $depth = 0, int $nochildrenof = -1): array { + + // Indent the name of this category. + $newcategories = []; + $newcategories[$id] = $categories[$id]; + $newcategories[$id]->indentedname = str_repeat('   ', $depth) . + $categories[$id]->name; + + // Recursively indent the children. + foreach ($categories[$id]->childids as $childid) { + if ($childid != $nochildrenof) { + $newcategories = $newcategories + self::flatten_category_tree( + $categories, $childid, $depth + 1, $nochildrenof); + } + } + + // Remove the childids array that were temporarily added. + unset($newcategories[$id]->childids); + + return $newcategories; + } + + /** + * Format categories into an indented list reflecting the tree structure. + * + * @param array $categories An array of category objects, for example from the. + * @param int $nochildrenof + * @return array The formatted list of categories. + */ + public static function add_indented_names(array $categories, int $nochildrenof = -1): array { + + // Add an array to each category to hold the child category ids. This array + // will be removed again by flatten_category_tree(). It should not be used + // outside these two functions. + foreach (array_keys($categories) as $id) { + $categories[$id]->childids = []; + } + + // Build the tree structure, and record which categories are top-level. + // We have to be careful, because the categories array may include published + // categories from other courses, but not their parents. + $toplevelcategoryids = []; + foreach (array_keys($categories) as $id) { + if (!empty($categories[$id]->parent) && + array_key_exists($categories[$id]->parent, $categories)) { + $categories[$categories[$id]->parent]->childids[] = $id; + } else { + $toplevelcategoryids[] = $id; + } + } + + // Flatten the tree to and add the indents. + $newcategories = []; + foreach ($toplevelcategoryids as $id) { + $newcategories = $newcategories + self::flatten_category_tree( + $categories, $id, 0, $nochildrenof); + } + + return $newcategories; + } + + /** + * Output a select menu of question categories. + * + * Categories from this course and (optionally) published categories from other courses + * are included. Optionally, only categories the current user may edit can be included. + * + * @param array $contexts + * @param bool $top + * @param int $currentcat + * @param string $selected optionally, the id of a category to be selected by + * default in the dropdown. + * @param int $nochildrenof + * @throws \coding_exception|\dml_exception + */ + public static function question_category_select_menu(array $contexts, bool $top = false, int $currentcat = 0, + string $selected = "", int $nochildrenof = -1): void { + $categoriesarray = self::question_category_options($contexts, $top, $currentcat, + false, $nochildrenof, false); + if ($selected) { + $choose = ''; + } else { + $choose = 'choosedots'; + } + $options = []; + foreach ($categoriesarray as $group => $opts) { + $options[] = [$group => $opts]; + } + echo html_writer::label(get_string('questioncategory', 'core_question'), + 'id_movetocategory', false, ['class' => 'accesshide']); + $attrs = [ + 'id' => 'id_movetocategory', + 'class' => 'custom-select', + 'data-action' => 'toggle', + 'data-togglegroup' => 'qbank', + 'data-toggle' => 'action', + 'disabled' => true, + ]; + echo html_writer::select($options, 'category', $selected, $choose, $attrs); + } + + /** + * Get all the category objects, including a count of the number of questions in that category, + * for all the categories in the lists $contexts. + * + * @param mixed $contexts either a single contextid, or a comma-separated list of context ids. + * @param string $sortorder used as the ORDER BY clause in the select statement. + * @param bool $top Whether to return the top categories or not. + * @return array of category objects. + * @throws \dml_exception + */ + public static function get_categories_for_contexts($contexts, string $sortorder = 'parent, sortorder, name ASC', + bool $top = false): array { + global $DB; + $topwhere = $top ? '' : 'AND c.parent <> 0'; + return $DB->get_records_sql(" + SELECT c.*, (SELECT count(1) FROM {question} q + WHERE c.id = q.category AND q.hidden='0' AND q.parent='0') AS questioncount + FROM {question_categories} c + WHERE c.contextid IN ($contexts) $topwhere + ORDER BY $sortorder"); + } + + /** + * Output an array of question categories. + * + * @param array $contexts The list of contexts. + * @param bool $top Whether to return the top categories or not. + * @param int $currentcat + * @param bool $popupform + * @param int $nochildrenof + * @param bool $escapecontextnames Whether the returned name of the thing is to be HTML escaped or not. + * @return array + * @throws \coding_exception|\dml_exception + */ + public static function question_category_options(array $contexts, bool $top = false, int $currentcat = 0, + bool $popupform = false, int $nochildrenof = -1, + bool $escapecontextnames = true): array { + global $CFG; + $pcontexts = []; + foreach ($contexts as $context) { + $pcontexts[] = $context->id; + } + $contextslist = join(', ', $pcontexts); + + $categories = self::get_categories_for_contexts($contextslist, 'parent, sortorder, name ASC', $top); + + if ($top) { + $categories = self::question_fix_top_names($categories); + } + + $categories = self::question_add_context_in_key($categories); + $categories = self::add_indented_names($categories, $nochildrenof); + + // Sort cats out into different contexts. + $categoriesarray = []; + foreach ($pcontexts as $contextid) { + $context = \context::instance_by_id($contextid); + $contextstring = $context->get_context_name(true, true, $escapecontextnames); + foreach ($categories as $category) { + if ($category->contextid == $contextid) { + $cid = $category->id; + if ($currentcat != $cid || $currentcat == 0) { + $a = new \stdClass; + $a->name = format_string($category->indentedname, true, + ['context' => $context]); + if ($category->idnumber !== null && $category->idnumber !== '') { + $a->idnumber = s($category->idnumber); + } + if (!empty($category->questioncount)) { + $a->questioncount = $category->questioncount; + } + if (isset($a->idnumber) && isset($a->questioncount)) { + $formattedname = get_string('categorynamewithidnumberandcount', 'question', $a); + } else if (isset($a->idnumber)) { + $formattedname = get_string('categorynamewithidnumber', 'question', $a); + } else if (isset($a->questioncount)) { + $formattedname = get_string('categorynamewithcount', 'question', $a); + } else { + $formattedname = $a->name; + } + $categoriesarray[$contextstring][$cid] = $formattedname; + } + } + } + } + if ($popupform) { + $popupcats = []; + foreach ($categoriesarray as $contextstring => $optgroup) { + $group = []; + foreach ($optgroup as $key => $value) { + $key = str_replace($CFG->wwwroot, '', $key); + $group[$key] = $value; + } + $popupcats[] = [$contextstring => $group]; + } + return $popupcats; + } else { + return $categoriesarray; + } + } + + /** + * Add context in categories key. + * + * @param array $categories The list of categories. + * @return array + */ + public static function question_add_context_in_key(array $categories): array { + $newcatarray = []; + foreach ($categories as $id => $category) { + $category->parent = "$category->parent,$category->contextid"; + $category->id = "$category->id,$category->contextid"; + $newcatarray["$id,$category->contextid"] = $category; + } + return $newcatarray; + } + + /** + * Finds top categories in the given categories hierarchy and replace their name with a proper localised string. + * + * @param array $categories An array of question categories. + * @param bool $escape Whether the returned name of the thing is to be HTML escaped or not. + * @return array The same question category list given to the function, with the top category names being translated. + * @throws \coding_exception + */ + public static function question_fix_top_names(array $categories, bool $escape = true): array { + + foreach ($categories as $id => $category) { + if ($category->parent == 0) { + $context = \context::instance_by_id($category->contextid); + $categories[$id]->name = get_string('topfor', 'question', $context->get_context_name(false, false, $escape)); + } + } + + return $categories; + } +} diff --git a/question/bank/managecategories/classes/navigation.php b/question/bank/managecategories/classes/navigation.php new file mode 100644 index 00000000000..5d1ff98977a --- /dev/null +++ b/question/bank/managecategories/classes/navigation.php @@ -0,0 +1,42 @@ +. + +namespace qbank_managecategories; + +/** + * Class navigation. + * + * Plugin entrypoint for navigation. + * + * @package qbank_managecategories + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Guillermo Gomez Arias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class navigation extends \core_question\local\bank\navigation_node_base { + + public function get_navigation_title(): string { + return get_string('categories', 'question'); + } + + public function get_navigation_key(): string { + return 'categories'; + } + + public function get_navigation_url(): \moodle_url { + return new \moodle_url('/question/bank/managecategories/category.php'); + } +} diff --git a/question/bank/managecategories/classes/plugin_feature.php b/question/bank/managecategories/classes/plugin_feature.php new file mode 100644 index 00000000000..312ba63195e --- /dev/null +++ b/question/bank/managecategories/classes/plugin_feature.php @@ -0,0 +1,35 @@ +. + +namespace qbank_managecategories; + +/** + * Class plugin_feature. + * + * Entry point for qbank plugin. + * Every qbank plugin must extent this class. + * + * @package qbank_managecategories + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class plugin_feature extends \core_question\local\bank\plugin_features_base { + + public function get_navigation_node(): ?object { + return new navigation(); + } +} diff --git a/question/bank/managecategories/classes/privacy/provider.php b/question/bank/managecategories/classes/privacy/provider.php new file mode 100644 index 00000000000..de587972b66 --- /dev/null +++ b/question/bank/managecategories/classes/privacy/provider.php @@ -0,0 +1,38 @@ +. + +namespace qbank_managecategories\privacy; + +/** + * Privacy Subsystem for qbank_managecategories implementing null_provider. + * + * @package qbank_managecategories + * @category privacy + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Guillermo Gomez Arias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider { + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason(): string { + return 'privacy:metadata'; + } +} diff --git a/question/bank/managecategories/classes/question_category_list.php b/question/bank/managecategories/classes/question_category_list.php new file mode 100644 index 00000000000..91869f7a173 --- /dev/null +++ b/question/bank/managecategories/classes/question_category_list.php @@ -0,0 +1,135 @@ +. + +namespace qbank_managecategories; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir. '/listlib.php'); + +use stdClass; +use moodle_list; + +/** + * Class representing a list of question categories. + * + * @package qbank_managecategories + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_category_list extends moodle_list { + + /** + * Table name. + * @var $table + */ + public $table = "question_categories"; + + /** + * List item class name. + * @var $listitemclassname + */ + public $listitemclassname = '\qbank_managecategories\question_category_list_item'; + + /** + * Reference to list displayed below this one. + * @var $nextlist + */ + public $nextlist = null; + + /** + * Reference to list displayed above this one. + * @var $lastlist + */ + public $lastlist = null; + + /** + * Context. + * @var $context + */ + public $context = null; + + /** + * Sort by string. + * @var $sortby + */ + public $sortby = 'parent, sortorder, name'; + + /** + * Constructor. + * + * @param string $type + * @param string $attributes + * @param boolean $editable + * @param \moodle_url $pageurl url for this page + * @param integer $page if 0 no pagination. (These three params only used in top level list.) + * @param string $pageparamname name of url param that is used for passing page no + * @param integer $itemsperpage no of top level items. + * @param \context $context + */ + public function __construct($type='ul', $attributes='', $editable = false, $pageurl=null, + $page = 0, $pageparamname = 'page', $itemsperpage = 20, $context = null) { + parent::__construct('ul', '', $editable, $pageurl, $page, 'cpage', $itemsperpage); + $this->context = $context; + } + + /** + * Set the array of records of list items. + */ + public function get_records() : void { + $this->records = helper::get_categories_for_contexts($this->context->id, $this->sortby); + } + + /** + * Returns the highest category id that the $item can have as its parent. + * Note: question categories cannot go higher than the TOP category. + * + * @param \list_item $item The item which its top level parent is going to be returned. + * @return int + */ + public function get_top_level_parent_id($item) : int { + // Put the item at the highest level it can go. + $topcategory = question_get_top_category($item->item->contextid, true); + return $topcategory->id; + } + + /** + * Process any actions. + * + * @param integer $left id of item to move left + * @param integer $right id of item to move right + * @param integer $moveup id of item to move up + * @param integer $movedown id of item to move down + * @return void + */ + public function process_actions($left, $right, $moveup, $movedown) : void { + $category = new stdClass(); + if (!empty($left)) { + // Moved Left (In to another category). + $category->id = $left; + $category->contextid = $this->context->id; + $event = \core\event\question_category_moved::create_from_question_category_instance($category); + $event->trigger(); + } else if (!empty($right)) { + // Moved Right (Out of the current category). + $category->id = $right; + $category->contextid = $this->context->id; + $event = \core\event\question_category_moved::create_from_question_category_instance($category); + $event->trigger(); + } + parent::process_actions($left, $right, $moveup, $movedown); + } +} diff --git a/question/bank/managecategories/classes/question_category_list_item.php b/question/bank/managecategories/classes/question_category_list_item.php new file mode 100644 index 00000000000..676ca97a077 --- /dev/null +++ b/question/bank/managecategories/classes/question_category_list_item.php @@ -0,0 +1,116 @@ +. + +namespace qbank_managecategories; + +use moodle_url; + +/** + * An item in a list of question categories. + * + * @package qbank_managecategories + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_category_list_item extends \list_item { + + /** + * Override set_icon_html function. + * + * @param bool $first Is the first on the list. + * @param bool $last Is the last on the list. + * @param \list_item $lastitem Last item. + */ + public function set_icon_html($first, $last, $lastitem) : void { + global $CFG; + $category = $this->item; + $url = new moodle_url('/question/bank/managecategories/category.php', + ($this->parentlist->pageurl->params() + ['edit' => $category->id])); + $this->icons['edit'] = $this->image_icon(get_string('editthiscategory', 'question'), $url, 'edit'); + parent::set_icon_html($first, $last, $lastitem); + $toplevel = ($this->parentlist->parentitem === null);// This is a top level item. + if (($this->parentlist->nextlist !== null) && $last && $toplevel && (count($this->parentlist->items) > 1)) { + $url = new moodle_url($this->parentlist->pageurl, + [ + 'movedowncontext' => $this->id, + 'tocontext' => $this->parentlist->nextlist->context->id, + 'sesskey' => sesskey() + ] + ); + $this->icons['down'] = $this->image_icon( + get_string('shareincontext', 'question', + $this->parentlist->nextlist->context->get_context_name()), $url, 'down'); + } + if (($this->parentlist->lastlist !== null) && $first && $toplevel && (count($this->parentlist->items) > 1)) { + $url = new moodle_url($this->parentlist->pageurl, + [ + 'moveupcontext' => $this->id, + 'tocontext' => $this->parentlist->lastlist->context->id, + 'sesskey' => sesskey() + ] + ); + $this->icons['up'] = $this->image_icon( + get_string('shareincontext', 'question', + $this->parentlist->lastlist->context->get_context_name()), $url, 'up'); + } + } + + /** + * Override item_html function. + * + * @param array $extraargs + * @return string Item html. + * @throws \moodle_exception + */ + public function item_html($extraargs = []) : string { + global $PAGE, $OUTPUT; + $str = $extraargs['str']; + $category = $this->item; + + // Each section adds html to be displayed as part of this list item. + $nodeparent = $PAGE->settingsnav->find('questionbank', \navigation_node::TYPE_CONTAINER); + $questionbankurl = new moodle_url($nodeparent->action->get_path(), $this->parentlist->pageurl->params()); + $questionbankurl->param('cat', $category->id . ',' . $category->contextid); + $categoryname = format_string($category->name, true, ['context' => $this->parentlist->context]); + $idnumber = null; + if ($category->idnumber !== null && $category->idnumber !== '') { + $idnumber = $category->idnumber; + } + $questioncount = ' (' . $category->questioncount . ')'; + $categorydesc = format_text($category->info, $category->infoformat, + ['context' => $this->parentlist->context, 'noclean' => true]); + + // Don't allow delete if this is the top category, or the last editable category in this context. + $deleteurl = null; + if ($category->parent && !helper::question_is_only_child_of_top_category_in_context($category->id)) { + $deleteurl = new moodle_url($this->parentlist->pageurl, ['delete' => $this->id, 'sesskey' => sesskey()]); + } + + // Render each question category. + $data = + [ + 'questionbankurl' => $questionbankurl, + 'categoryname' => $categoryname, + 'idnumber' => $idnumber, + 'questioncount' => $questioncount, + 'categorydesc' => $categorydesc, + 'deleteurl' => $deleteurl, + 'deletetitle' => $str->delete + ]; + + return $OUTPUT->render_from_template(helper::PLUGINNAME . '/listitem', $data); + } +} diff --git a/question/bank/managecategories/classes/question_category_object.php b/question/bank/managecategories/classes/question_category_object.php new file mode 100644 index 00000000000..e5c5f0ef795 --- /dev/null +++ b/question/bank/managecategories/classes/question_category_object.php @@ -0,0 +1,504 @@ +. + +namespace qbank_managecategories; + +/** + * QUESTION_PAGE_LENGTH - Number of categories to display on page. + */ +define('QUESTION_PAGE_LENGTH', 25); + +use context; +use moodle_exception; +use moodle_url; +use qbank_managecategories\form\question_category_edit_form; +use question_bank; +use stdClass; + +/** + * Class for performing operations on question categories. + * + * @package qbank_managecategories + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_category_object { + + /** + * @var array common language strings. + */ + public $str; + + /** + * @var array nested lists to display categories. + */ + public $editlists = []; + + /** + * @var string tab. + */ + public $tab; + + /** + * @var int tab size. + */ + public $tabsize = 3; + + /** + * @var moodle_url Object representing url for this page + */ + public $pageurl; + + /** + * @var question_category_edit_form Object representing form for adding / editing categories. + */ + public $catform; + + /** + * Constructor. + * + * @param int $page page number. + * @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 $currentcat id of the category to be edited. 0 if none. + * @param int|null $defaultcategory id of the current category. null if none. + * @param int $todelete id of the category to delete. 0 if none. + * @param context[] $addcontexts contexts where the current user can add questions. + */ + public function __construct($page, $pageurl, $contexts, $currentcat, $defaultcategory, $todelete, $addcontexts) { + + $this->tab = str_repeat(' ', $this->tabsize); + + $this->str = new stdClass(); + $this->str->course = get_string('course'); + $this->str->category = get_string('category', 'question'); + $this->str->categoryinfo = get_string('categoryinfo', 'question'); + $this->str->questions = get_string('questions', 'question'); + $this->str->add = get_string('add'); + $this->str->delete = get_string('delete'); + $this->str->moveup = get_string('moveup'); + $this->str->movedown = get_string('movedown'); + $this->str->edit = get_string('editthiscategory', 'question'); + $this->str->hide = get_string('hide'); + $this->str->order = get_string('order'); + $this->str->parent = get_string('parent', 'question'); + $this->str->add = get_string('add'); + $this->str->action = get_string('action'); + $this->str->top = get_string('top'); + $this->str->addcategory = get_string('addcategory', 'question'); + $this->str->editcategory = get_string('editcategory', 'question'); + $this->str->cancel = get_string('cancel'); + $this->str->editcategories = get_string('editcategories', 'question'); + $this->str->page = get_string('page'); + + $this->pageurl = $pageurl; + + $this->initialize($page, $contexts, $currentcat, $defaultcategory, $todelete, $addcontexts); + } + + /** + * Initializes this classes general category-related variables + * + * @param int $page page number. + * @param context[] $contexts contexts where the current user can edit categories. + * @param int $currentcat id of the category to be edited. 0 if none. + * @param int|null $defaultcategory id of the current category. null if none. + * @param int $todelete id of the category to delete. 0 if none. + * @param context[] $addcontexts contexts where the current user can add questions. + */ + public function initialize($page, $contexts, $currentcat, $defaultcategory, $todelete, $addcontexts): void { + $lastlist = null; + foreach ($contexts as $context) { + $this->editlists[$context->id] = + new question_category_list('ul', '', true, $this->pageurl, $page, 'cpage', QUESTION_PAGE_LENGTH, $context); + $this->editlists[$context->id]->lastlist =& $lastlist; + if ($lastlist !== null) { + $lastlist->nextlist =& $this->editlists[$context->id]; + } + $lastlist =& $this->editlists[$context->id]; + } + + $count = 1; + $paged = false; + foreach ($this->editlists as $key => $list) { + list($paged, $count) = $this->editlists[$key]->list_from_records($paged, $count); + } + $this->catform = new question_category_edit_form($this->pageurl, compact('contexts', 'currentcat')); + if (!$currentcat) { + $this->catform->set_data(['parent' => $defaultcategory]); + } + } + + /** + * Displays the user interface. + * + */ + public function display_user_interface(): void { + + // Interface for editing existing categories. + $this->output_edit_lists(); + + echo \html_writer::empty_tag('br'); + // Interface for adding a new category. + $this->output_new_table(); + echo \html_writer::empty_tag('br'); + + } + + /** + * Outputs a table to allow entry of a new category + */ + public function output_new_table(): void { + $this->catform->display(); + } + + /** + * Outputs a list to allow editing/rearranging of existing categories. + * + * $this->initialize() must have already been called + * + */ + public function output_edit_lists(): void { + global $OUTPUT; + + echo $OUTPUT->heading_with_help(get_string('editcategories', 'question'), 'editcategories', 'question'); + + foreach ($this->editlists as $context => $list) { + $listhtml = $list->to_html(0, ['str' => $this->str]); + if ($listhtml) { + echo $OUTPUT->box_start('boxwidthwide boxaligncenter generalbox questioncategories contextlevel' . + $list->context->contextlevel); + $fullcontext = context::instance_by_id($context); + echo $OUTPUT->heading(get_string('questioncatsfor', 'question', $fullcontext->get_context_name()), 3); + echo $listhtml; + echo $OUTPUT->box_end(); + } + } + echo $list->display_page_numbers(); + } + + /** + * Gets all the courseids for the given categories. + * + * @param array $categories contains category objects in a tree representation + * @return array courseids flat array in form categoryid=>courseid + */ + public function get_course_ids(array $categories): array { + $courseids = []; + foreach ($categories as $key => $cat) { + $courseids[$key] = $cat->course; + if (!empty($cat->children)) { + $courseids = array_merge($courseids, $this->get_course_ids($cat->children)); + } + } + return $courseids; + } + + /** + * Edit a category. + * + * @param int $categoryid Category id. + */ + public function edit_single_category(int $categoryid): void { + // Interface for adding a new category. + global $DB; + // Interface for editing existing categories. + $category = $DB->get_record("question_categories", ["id" => $categoryid]); + if (empty($category)) { + throw new moodle_exception('invalidcategory', '', '', $categoryid); + } else if ($category->parent == 0) { + throw new moodle_exception('cannotedittopcat', 'question', '', $categoryid); + } else { + $category->parent = "{$category->parent},{$category->contextid}"; + $category->submitbutton = get_string('savechanges'); + $category->categoryheader = $this->str->edit; + $this->catform->set_data($category); + $this->catform->display(); + } + } + + /** + * Sets the viable parents. + * + * Viable parents are any except for the category itself, or any of it's descendants + * The parentstrings parameter is passed by reference and changed by this function. + * + * @param array $parentstrings a list of parentstrings + * @param object $category Category object + */ + public function set_viable_parents(array &$parentstrings, object $category): void { + + unset($parentstrings[$category->id]); + if (isset($category->children)) { + foreach ($category->children as $child) { + $this->set_viable_parents($parentstrings, $child); + } + } + } + + /** + * Gets question categories. + * + * @param int|null $parent - if given, restrict records to those with this parent id. + * @param string $sort - [[sortfield [,sortfield]] {ASC|DESC}]. + * @return array categories. + */ + public function get_question_categories(int $parent = null, string $sort = "sortorder ASC"): array { + global $COURSE, $DB; + if (is_null($parent)) { + $categories = $DB->get_records('question_categories', ['course' => $COURSE->id], $sort); + } else { + $select = "parent = ? AND course = ?"; + $categories = $DB->get_records_select('question_categories', $select, [$parent, $COURSE->id], $sort); + } + return $categories; + } + + /** + * Deletes an existing question category. + * + * @param int $categoryid id of category to delete. + */ + public function delete_category(int $categoryid): void { + global $CFG, $DB; + helper::question_can_delete_cat($categoryid); + if (!$category = $DB->get_record("question_categories", ["id" => $categoryid])) { // Security. + throw new moodle_exception('unknowcategory'); + } + // 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(); + + } + + /** + * 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 { + helper::question_can_delete_cat($oldcat); + $this->move_questions($oldcat, $newcat); + $this->delete_category($oldcat); + } + + /** + * Display the form to move a category. + * + * @param int $questionsincategory + * @param object $category + * @throws \coding_exception + */ + public function display_move_form($questionsincategory, $category): void { + global $OUTPUT; + $vars = new stdClass(); + $vars->name = $category->name; + $vars->count = $questionsincategory; + echo $OUTPUT->box(get_string('categorymove', 'question', $vars), 'generalbox boxaligncenter'); + $this->moveform->display(); + } + + /** + * 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 { + global $DB; + $questionids = $DB->get_records_select_menu('question', + 'category = ? AND (parent = 0 OR parent = id)', [$oldcat], '', 'id,1'); + question_move_questions_to_category(array_keys($questionids), $newcat); + } + + /** + * 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 bool $return if true, return rather than redirecting. + * @param int|string $newinfoformat description format. One of the FORMAT_ constants. + * @param null $idnumber the idnumber. '' is converted to null. + * @return bool|int New category id if successful, else false. + */ + public function add_category($newparent, $newcategory, $newinfo, $return = false, $newinfoformat = FORMAT_HTML, + $idnumber = null): int { + global $DB; + if (empty($newcategory)) { + throw new moodle_exception('categorynamecantbeblank', 'question'); + } + list($parentid, $contextid) = explode(',', $newparent); + // ...moodle_form makes sure select element output is legal no need for further cleaning. + require_capability('moodle/question:managecategory', 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 ((string) $idnumber === '') { + $idnumber = null; + } else if (!empty($contextid)) { + // While this check already exists in the form validation, this is a backstop preventing unnecessary errors. + if ($DB->record_exists('question_categories', + ['idnumber' => $idnumber, 'contextid' => $contextid])) { + $idnumber = null; + } + } + + $cat = new stdClass(); + $cat->parent = $parentid; + $cat->contextid = $contextid; + $cat->name = $newcategory; + $cat->info = $newinfo; + $cat->infoformat = $newinfoformat; + $cat->sortorder = 999; + $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(); + + if ($return) { + return $categoryid; + } else { + // Always redirect after successful action. + redirect($this->pageurl); + } + } + + /** + * 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 int $newparent 'categoryid,contextid' of the parent category to set. + * @param string $newname category name. + * @param string $newinfo category description. + * @param int|string $newinfoformat description format. One of the FORMAT_ constants. + * @param int $idnumber the idnumber. '' is converted to null. + * @param bool $redirect if true, will redirect once the DB is updated (default). + */ + public function update_category($updateid, $newparent, $newname, $newinfo, $newinfoformat = FORMAT_HTML, + $idnumber = null, $redirect = true): void { + global $CFG, $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 = helper::question_is_only_child_of_top_category_in_context($updateid); + + if (!empty($newparent) && !$lastcategoryinthiscontext) { + list($parentid, $tocontextid) = explode(',', $newparent); + } else { + $parentid = $oldcat->parent; + $tocontextid = $oldcat->contextid; + } + + // Check permissions. + $fromcontext = context::instance_by_id($oldcat->contextid); + require_capability('moodle/question:managecategory', $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); + require_capability('moodle/question:managecategory', $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 ((string) $idnumber === '') { + $idnumber = null; + } else if (!empty($tocontextid)) { + // While this check already exists in the form validation, this is a backstop preventing unnecessary errors. + if ($DB->record_exists_select('question_categories', + 'idnumber = ? AND contextid = ? AND id <> ?', + [$idnumber, $tocontextid, $updateid])) { + $idnumber = null; + } + } + + // 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(); + } + $DB->update_record('question_categories', $cat); + + // Log the update of this category. + $event = \core\event\question_category_updated::create_from_question_category_instance($cat); + $event->trigger(); + + // If the category name has changed, rename any random questions in that category. + if ($oldcat->name != $cat->name) { + $where = "qtype = 'random' AND category = ? AND " . $DB->sql_compare_text('questiontext') . " = ?"; + + $randomqtype = question_bank::get_qtype('random'); + $randomqname = $randomqtype->question_name($cat, false); + $DB->set_field_select('question', 'name', $randomqname, $where, [$cat->id, '0']); + + $randomqname = $randomqtype->question_name($cat, true); + $DB->set_field_select('question', 'name', $randomqname, $where, [$cat->id, '1']); + } + + 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); + } + + // Cat param depends on the context id, so update it. + $this->pageurl->param('cat', $updateid . ',' . $tocontextid); + if ($redirect) { + // Always redirect after successful action. + redirect($this->pageurl); + } + } +} diff --git a/question/bank/managecategories/lang/en/qbank_managecategories.php b/question/bank/managecategories/lang/en/qbank_managecategories.php new file mode 100644 index 00000000000..8dd1d599acf --- /dev/null +++ b/question/bank/managecategories/lang/en/qbank_managecategories.php @@ -0,0 +1,27 @@ +. + +/** + * Strings for component 'qbank_managecategories', language 'en' + * + * @package qbank_managecategories + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Guillermo Gomez Arias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['pluginname'] = 'Manage categories'; +$string['privacy:metadata'] = 'The Manage Categories plugin does not store any user data.'; diff --git a/question/bank/managecategories/templates/listitem.mustache b/question/bank/managecategories/templates/listitem.mustache new file mode 100644 index 00000000000..5d6d8c3b955 --- /dev/null +++ b/question/bank/managecategories/templates/listitem.mustache @@ -0,0 +1,57 @@ +{{! + 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 . +}} +{{! + @template qbank_managecategories/listitem + + This template renders the list item for each category. + + Example context (json): + { + "questionbankurl": "question/edit.php?cmid=", + "categoryname": "Default for Miscellaneous", + "idnumber": "1", + "questioncount": "1", + "categorydesc": "The default category for questions shared in context Miscellaneous", + "deleteurl": "url", + "deletetitle": "Advanced" + } +}} + + +
+ {{categoryname}} + {{#idnumber}} + + + {{#str}}idnumber, question{{/str}} + + {{idnumber}} + + {{/idnumber}} + {{questioncount}} + + +{{#categorydesc}} +
+ {{{categorydesc}}} +
+{{/categorydesc}} +{{#deleteurl}} + + {{# pix }} t/delete, core, {{deletetitle}} {{/ pix }} + +{{/deleteurl}} diff --git a/question/tests/behat/move_question_categories.feature b/question/bank/managecategories/tests/behat/move_question_categories.feature similarity index 100% rename from question/tests/behat/move_question_categories.feature rename to question/bank/managecategories/tests/behat/move_question_categories.feature diff --git a/question/bank/managecategories/tests/behat/question_categories.feature b/question/bank/managecategories/tests/behat/question_categories.feature new file mode 100644 index 00000000000..70b4b936457 --- /dev/null +++ b/question/bank/managecategories/tests/behat/question_categories.feature @@ -0,0 +1,66 @@ +@qbank @qbank_managecategories @javascript +Feature: A teacher can put questions in categories in the question bank + In order to organize my questions + As a teacher + I create and edit categories and move questions between them + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | weeks | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + And the following "question categories" exist: + | contextlevel | reference | questioncategory | name | + | Course | C1 | Top | top | + | Course | C1 | top | Default for C1 | + | Course | C1 | Default for C1 | Subcategory | + | Course | C1 | top | Used category | + And the following "questions" exist: + | questioncategory | qtype | name | questiontext | + | Used category | essay | Test question to be moved | Write about whatever you want | + And I log in as "teacher1" + And I am on "Course 1" course homepage + + Scenario: A new question category can be created + When I navigate to "Question bank > Categories" in current page administration + And I set the following fields to these values: + | Name | New Category 1 | + | Parent category | Top | + | Category info | Created as a test | + | ID number | newcatidnumber | + And I press "submitbutton" + Then I should see "New Category 1" + And I should see "ID number" + And I should see "newcatidnumber" + And I should see "(0)" + And I should see "Created as a test" in the "New Category 1" "list_item" + And "New Category 1 [newcatidnumber]" "option" should exist in the "Parent category" "select" + + Scenario: A question category can be edited + When I navigate to "Question bank > Categories" in current page administration + And I click on "Edit this category" "link" in the "Subcategory" "list_item" + And the field "parent" matches value "   Default for C1" + And I set the following fields to these values: + | Name | New name | + | Category info | I was edited | + And I press "Save changes" + Then I should see "New name" + And I should see "I was edited" in the "New name" "list_item" + + Scenario: An empty question category can be deleted + When I navigate to "Question bank > Categories" in current page administration + And I click on "Delete" "link" in the "Subcategory" "list_item" + Then I should not see "Subcategory" + + Scenario: An non-empty question category can be deleted if you move the contents elsewhere + When I navigate to "Question bank > Categories" in current page administration + And I click on "Delete" "link" in the "Used category" "list_item" + And I should see "The category 'Used category' contains 1 questions" + And I press "Save in category" + Then I should not see "Used category" + And I should see "Default for C1 (1)" diff --git a/question/bank/managecategories/tests/behat/question_categories_idnumber.feature b/question/bank/managecategories/tests/behat/question_categories_idnumber.feature new file mode 100644 index 00000000000..95226aa5046 --- /dev/null +++ b/question/bank/managecategories/tests/behat/question_categories_idnumber.feature @@ -0,0 +1,52 @@ +@qbank @qbank_managecategories +Feature: A teacher can put questions with idnumbers in categories with idnumbers in the question bank + In order to organize my questions + As a teacher + I create and edit categories (now with idnumbers) + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | weeks | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + And I log in as "teacher1" + And I am on "Course 1" course homepage + + Scenario: A new question category can only be created with a unique idnumber for a context + # Note need to create the top category each time. + When the following "question categories" exist: + | contextlevel | reference | questioncategory | name | idnumber | + | Course | C1 | Top | top | | + | Course | C1 | top | Used category | c1used | + And I navigate to "Question bank > Categories" in current page administration + And I set the following fields to these values: + | Name | Sub used category | + | Parent category | Used category | + | Category info | Created as a test | + | ID number | c1used | + And I press "Add category" + # Standard warning. + Then I should see "This ID number is already in use" + # Correction to a unique idnumber for the context. + And I set the field "ID number" to "c1unused" + And I press "Add category" + Then I should see "Sub used category" + And I should see "ID number" + And I should see "c1unused" + And I should see "(0)" + And I should see "Created as a test" in the "Sub used category" "list_item" + + Scenario: A question category can be edited and saved without changing the idnumber + When the following "question categories" exist: + | contextlevel | reference | questioncategory | name | idnumber | + | Course | C1 | Top | top | | + | Course | C1 | top | Used category | c1used | + And I navigate to "Question bank > Categories" in current page administration + And I click on "Edit this category" "link" in the "Used category" "list_item" + And I press "Save changes" + Then I should not see "This ID number is already in use" diff --git a/question/bank/managecategories/tests/behat/view_manage_categories_plugin.feature b/question/bank/managecategories/tests/behat/view_manage_categories_plugin.feature new file mode 100644 index 00000000000..b74387bdc53 --- /dev/null +++ b/question/bank/managecategories/tests/behat/view_manage_categories_plugin.feature @@ -0,0 +1,50 @@ +@qbank @qbank_managecategories @javascript +Feature: Use the qbank plugin manager page for managecategories + In order to check the plugin behaviour with enable and disable + + Background: + Given the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "activities" exist: + | activity | name | course | idnumber | + | quiz | Test quiz | C1 | quiz1 | + And the following "question categories" exist: + | contextlevel | reference | name | + | Course | C1 | Test questions | + And the following "questions" exist: + | questioncategory | qtype | name | questiontext | + | Test questions | truefalse | First question | Answer the first question | + + Scenario: Enable/disable managecategories plugin from the base view + Given I log in as "admin" + When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration + And I should see "Manage categories" + And I click on "Disable" "link" in the "Manage categories" "table_row" + And I am on the "Test quiz" "quiz activity" page + And I navigate to "Question bank" in current page administration + Then I should not see "Categories" + And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration + And I click on "Enable" "link" in the "Manage categories" "table_row" + And I am on the "Test quiz" "quiz activity" page + And I navigate to "Question bank" in current page administration + And I should see "Categories" + + Scenario: Enable/disable the tab New category when tyring to add a random question to a quiz + Given I log in as "admin" + When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration + And I should see "Manage categories" + And I click on "Disable" "link" in the "Manage categories" "table_row" + And I am on the "Test quiz" "quiz activity" page + And I navigate to "Edit quiz" in current page administration + And I open the "last" add to quiz menu + And I follow "a random question" + Then I should not see "New category" + And I press "id_cancel" + And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration + And I click on "Enable" "link" in the "Manage categories" "table_row" + And I am on the "Test quiz" "quiz activity" page + And I navigate to "Edit quiz" in current page administration + And I open the "last" add to quiz menu + And I follow "a random question" + And I should see "New category" diff --git a/question/bank/managecategories/tests/helper_test.php b/question/bank/managecategories/tests/helper_test.php new file mode 100644 index 00000000000..a875edbc09c --- /dev/null +++ b/question/bank/managecategories/tests/helper_test.php @@ -0,0 +1,228 @@ +. + +namespace qbank_managecategories; + +/** + * Unit tests for helper class. + * + * @package qbank_managecategories + * @copyright 2006 The Open University + * @author 2021, Guillermo Gomez Arias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \qbank_managecategories\helper + */ +class helper_test extends \advanced_testcase { + + /** + * @var \context_module module context. + */ + protected $context; + + /** + * @var \stdClass course object. + */ + protected $course; + + /** + * @var \component_generator_base question generator. + */ + protected $qgenerator; + + /** + * @var \stdClass quiz object. + */ + protected $quiz; + + /** + * Tests initial setup. + */ + protected function setUp(): void { + parent::setUp(); + self::setAdminUser(); + $this->resetAfterTest(); + + $datagenerator = $this->getDataGenerator(); + $this->course = $datagenerator->create_course(); + $this->quiz = $datagenerator->create_module('quiz', ['course' => $this->course->id]); + $this->qgenerator = $datagenerator->get_plugin_generator('core_question'); + $this->context = \context_module::instance($this->quiz->cmid); + } + + /** + * Test question_remove_stale_questions_from_category function. + * + * @covers ::question_remove_stale_questions_from_category + */ + public function test_question_remove_stale_questions_from_category() { + 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. + $DB->set_field('question', 'hidden', 1, ['id' => $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. + $DB->set_field('question', 'hidden', 1, ['id' => $q2a->id]); + $DB->set_field('question', 'hidden', 1, ['id' => $q2b->id]); + quiz_add_quiz_question($q2b->id, $this->quiz); + quiz_add_random_questions($this->quiz, 0, $qcat2->id, 1, false); + + // 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 q.* + FROM {question} q + JOIN {quiz_slots} s ON s.questionid = q.id + WHERE q.qtype = :qtype + AND s.quizid = :quizid", + ['qtype' => 'random', 'quizid' => $this->quiz->id], 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. + + $this->assertEquals(2, $DB->count_records('question', ['category' => $qcat1->id])); + $this->assertEquals(4, $DB->count_records('question', ['category' => $qcat2->id])); + + // Non-existing category, nothing will happen. + helper::question_remove_stale_questions_from_category(0); + $this->assertEquals(2, $DB->count_records('question', ['category' => $qcat1->id])); + $this->assertEquals(4, $DB->count_records('question', ['category' => $qcat2->id])); + + // First category, should be empty afterwards. + helper::question_remove_stale_questions_from_category($qcat1->id); + $this->assertEquals(0, $DB->count_records('question', ['category' => $qcat1->id])); + $this->assertEquals(4, $DB->count_records('question', ['category' => $qcat2->id])); + $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, $DB->count_records('question', ['category' => $qcat1->id])); + $this->assertEquals(2, $DB->count_records('question', ['category' => $qcat2->id])); + $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', ['id' => $q2d->id])); + } + + /** + * Test delete top category in function question_can_delete_cat. + * + * @covers ::question_can_delete_cat + * @covers ::question_is_top_category + */ + public function test_question_can_delete_cat_top_category() { + + $qcategory1 = $this->qgenerator->create_question_category(['contextid' => $this->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')); + helper::question_can_delete_cat($categorytop); + } + + /** + * Test delete only child category in function question_can_delete_cat. + * + * @covers ::question_can_delete_cat + * @covers ::question_is_only_child_of_top_category_in_context + */ + public function test_question_can_delete_cat_child_category() { + + $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); + } + + /** + * Test delete category in function question_can_delete_cat without capabilities. + * + * @covers ::question_can_delete_cat + */ + public function test_question_can_delete_cat_capability() { + + $qcategory1 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); + $qcategory2 = $this->qgenerator->create_question_category(['contextid' => $this->context->id, 'parent' => $qcategory1->id]); + + // This call should not throw an exception as admin user has the capabilities moodle/question:managecategory. + helper::question_can_delete_cat($qcategory2->id); + + // Try to delete a category with and user without the capability. + $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); + } + + /** + * Test question_category_select_menu function. + * + * @covers ::question_category_select_menu + * @covers ::question_category_options + */ + public function test_question_category_select_menu() { + + $this->qgenerator->create_question_category(['contextid' => $this->context->id, 'name' => 'Test this question category']); + $contexts = new \question_edit_contexts($this->context); + + ob_start(); + helper::question_category_select_menu($contexts->having_cap('moodle/question:add')); + $output = ob_get_clean(); + + // Test the select menu of question categories output. + $this->assertStringContainsString('Question category', $output); + $this->assertStringContainsString('', $output); + $this->assertStringContainsString('Test this question category', $output); + } + + /** + * Test that question_category_options function returns the correct category tree. + * + * @covers ::question_category_options + * @covers ::get_categories_for_contexts + * @covers ::question_fix_top_names + * @covers ::question_add_context_in_key + * @covers ::add_indented_names + */ + public function test_question_category_options() { + + $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]); + + $contexts = new \question_edit_contexts($this->context); + + // Validate that we have the array with the categories tree. + $categorycontexts = helper::question_category_options($contexts->having_cap('moodle/question:add')); + foreach ($categorycontexts as $categorycontext) { + $this->assertCount(3, $categorycontext); + } + + // Validate that we have the array with the categories tree and that top category is there. + $categorycontexts = helper::question_category_options($contexts->having_cap('moodle/question:add'), true); + foreach ($categorycontexts as $categorycontext) { + $this->assertCount(4, $categorycontext); + } + } +} diff --git a/question/bank/managecategories/tests/question_category_object_test.php b/question/bank/managecategories/tests/question_category_object_test.php new file mode 100644 index 00000000000..3f677364137 --- /dev/null +++ b/question/bank/managecategories/tests/question_category_object_test.php @@ -0,0 +1,343 @@ +. + +namespace qbank_managecategories; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/question/editlib.php'); + +use context; +use context_course; +use context_module; +use moodle_url; +use question_edit_contexts; +use stdClass; + +/** + * Unit tests for qbank_managecategories\question_category_object. + * + * @package qbank_managecategories + * @copyright 2019 the Open University + * @author 2021, Guillermo Gomez Arias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \qbank_managecategories\question_category_object + */ +class question_category_object_test extends \advanced_testcase { + + /** + * @var question_category_object used in the tests. + */ + protected $qcobject; + + /** + * @var context a context to use. + */ + protected $context; + + /** + * @var stdClass top category in context. + */ + protected $topcat; + + /** + * @var stdClass course object. + */ + protected $course; + + /** + * @var stdClass quiz object. + */ + protected $quiz; + + /** + * @var question_edit_contexts + */ + private $qcontexts; + + /** + * @var false|object|stdClass|null + */ + private $defaultcategoryobj; + + /** + * @var string + */ + private $defaultcategory; + + /** + * @var question_category_object + */ + private $qcobjectquiz; + + protected function setUp(): void { + parent::setUp(); + self::setAdminUser(); + $this->resetAfterTest(); + $this->context = context_course::instance(SITEID); + $contexts = new question_edit_contexts($this->context); + $this->topcat = question_get_top_category($this->context->id, true); + $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')); + + // Set up tests in a quiz context. + $this->course = $this->getDataGenerator()->create_course(); + $this->quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $this->course->id]); + $this->qcontexts = new question_edit_contexts(context_module::instance($this->quiz->cmid)); + + $this->defaultcategoryobj = question_make_default_categories([$this->qcontexts->lowest()]); + $this->defaultcategory = $this->defaultcategoryobj->id . ',' . $this->defaultcategoryobj->contextid; + + $this->qcobjectquiz = new question_category_object( + 1, + new moodle_url('/mod/quiz/edit.php', ['cmid' => $this->quiz->cmid]), + $this->qcontexts->having_one_edit_tab_cap('categories'), + $this->defaultcategoryobj->id, + $this->defaultcategory, + null, + $this->qcontexts->having_cap('moodle/question:add')); + + } + + /** + * Test creating a category. + * + * @covers ::add_category + */ + public function test_add_category_no_idnumber() { + global $DB; + + $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, + 'New category', '', true, 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. + * + * @covers ::add_category + */ + public function test_add_category_set_idnumber_0() { + global $DB; + + $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, + 'New category', '', true, 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 blanks it. + * (In reality, this would probably get caught by form validation.) + * + * @covers ::add_category + */ + public function test_add_category_try_to_set_duplicate_idnumber() { + global $DB; + + $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, + 'Existing category', '', true, FORMAT_HTML, 'frog'); + + $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, + 'New category', '', true, FORMAT_HTML, 'frog'); + + $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); + $this->assertSame('New category', $newcat->name); + $this->assertNull($newcat->idnumber); + } + + /** + * Test updating a category. + * + * @covers ::update_category + */ + public function test_update_category() { + global $DB; + + $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, + 'Old name', 'Description', true, FORMAT_HTML, 'frog'); + + $this->qcobject->update_category($id, $this->topcat->id . ',' . $this->topcat->contextid, + 'New name', 'New description', FORMAT_HTML, '0', false); + + $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); + $this->assertSame('New name', $newcat->name); + $this->assertSame('0', $newcat->idnumber); + } + + /** + * Test updating a category to remove the idnumber. + * + * @covers ::update_category + */ + public function test_update_category_removing_idnumber() { + global $DB; + + $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, + 'Old name', 'Description', true, FORMAT_HTML, 'frog'); + + $this->qcobject->update_category($id, $this->topcat->id . ',' . $this->topcat->contextid, + 'New name', 'New description', FORMAT_HTML, '', false); + + $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); + $this->assertSame('New name', $newcat->name); + $this->assertNull($newcat->idnumber); + } + + /** + * Test updating a category without changing the idnumber. + * + * @covers ::update_category + */ + public function test_update_category_dont_change_idnumber() { + global $DB; + + $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, + 'Old name', 'Description', true, FORMAT_HTML, 'frog'); + + $this->qcobject->update_category($id, $this->topcat->id . ',' . $this->topcat->contextid, + 'New name', 'New description', FORMAT_HTML, 'frog', false); + + $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); + $this->assertSame('New name', $newcat->name); + $this->assertSame('frog', $newcat->idnumber); + } + + /** + * Trying to update a category so its idnumber duplicates idnumber blanks it. + * (In reality, this would probably get caught by form validation.) + * + * @covers ::update_category + */ + public function test_update_category_try_to_set_duplicate_idnumber() { + global $DB; + + $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, + 'Existing category', '', true, FORMAT_HTML, 'toad'); + $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, + 'old name', '', true, FORMAT_HTML, 'frog'); + + $this->qcobject->update_category($id, $this->topcat->id . ',' . $this->topcat->contextid, + 'New name', '', FORMAT_HTML, 'toad', false); + + $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); + $this->assertSame('New name', $newcat->name); + $this->assertNull($newcat->idnumber); + } + + /** + * Test the question category created event. + * + * @covers ::add_category + */ + public function test_question_category_created() { + // Trigger and capture the event. + $sink = $this->redirectEvents(); + $categoryid = $this->qcobjectquiz->add_category($this->defaultcategory, 'newcategory', '', true); + $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_module::instance($this->quiz->cmid), $event->get_context()); + $expected = [$this->course->id, 'quiz', 'addcategory', 'view.php?id=' . $this->quiz->cmid , $categoryid, $this->quiz->cmid]; + $this->assertEventLegacyLogData($expected, $event); + $this->assertEventContextNotUsed($event); + } + + /** + * Test the question category deleted event. + * + * @covers ::delete_category + */ + public function test_question_category_deleted() { + // Create the category. + $categoryid = $this->qcobjectquiz->add_category($this->defaultcategory, 'newcategory', '', true); + + // Trigger and capture the event. + $sink = $this->redirectEvents(); + $this->qcobjectquiz->delete_category($categoryid); + $events = $sink->get_events(); + $event = reset($events); + + // Check that the event data is valid. + $this->assertInstanceOf('\core\event\question_category_deleted', $event); + $this->assertEquals(context_module::instance($this->quiz->cmid), $event->get_context()); + $this->assertEquals($categoryid, $event->objectid); + $this->assertDebuggingNotCalled(); + } + + /** + * Test the question category updated event. + * + * @covers ::update_category + */ + public function test_question_category_updated() { + // Create the category. + $categoryid = $this->qcobjectquiz->add_category($this->defaultcategory, 'newcategory', '', true); + + // Trigger and capture the event. + $sink = $this->redirectEvents(); + $this->qcobjectquiz->update_category($categoryid, $this->defaultcategory, 'updatedcategory', '', FORMAT_HTML, '', false); + $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_module::instance($this->quiz->cmid), $event->get_context()); + $this->assertEquals($categoryid, $event->objectid); + $this->assertDebuggingNotCalled(); + } + + /** + * Test the question category viewed event. + * There is no external API for viewing the category, so the unit test will simply + * create and trigger the event and ensure data is returned as expected. + * + * @covers ::add_category + */ + public function test_question_category_viewed() { + // Create the category. + $categoryid = $this->qcobjectquiz->add_category($this->defaultcategory, 'newcategory', '', true); + + // Log the view of this category. + $category = new stdClass(); + $category->id = $categoryid; + $context = context_module::instance($this->quiz->cmid); + $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_module::instance($this->quiz->cmid), $event->get_context()); + $this->assertEquals($categoryid, $event->objectid); + $this->assertDebuggingNotCalled(); + + } +} diff --git a/question/bank/managecategories/version.php b/question/bank/managecategories/version.php new file mode 100644 index 00000000000..731c5ffe1a3 --- /dev/null +++ b/question/bank/managecategories/version.php @@ -0,0 +1,31 @@ +. + +/** + * Plugin version and other meta-data are defined here. + * + * @package qbank_managecategories + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Guillermo Gomez Arias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->component = 'qbank_managecategories'; +$plugin->version = 2021070700; +$plugin->requires = 2021052500; +$plugin->maturity = MATURITY_STABLE; diff --git a/question/category_class.php b/question/category_class.php index 16121e41f2f..e4e576b34a5 100644 --- a/question/category_class.php +++ b/question/category_class.php @@ -39,6 +39,9 @@ require_once($CFG->dirroot . '/question/move_form.php'); * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @deprecated since Moodle 4.0 MDL-71585 + * @see \qbank_managecategories\question_category_list + * @todo deprecation on MDL-71679 */ class question_category_list extends moodle_list { public $table = "question_categories"; @@ -56,6 +59,9 @@ class question_category_list extends moodle_list { public $sortby = 'parent, sortorder, name'; public function __construct($type='ul', $attributes='', $editable = false, $pageurl=null, $page = 0, $pageparamname = 'page', $itemsperpage = 20, $context = null){ + debugging('Class question_category_list in \core_question\category_class is deprecated, + please use qbank_managecategories\question_category_list instead.', DEBUG_DEVELOPER); + parent::__construct('ul', '', $editable, $pageurl, $page, 'cpage', $itemsperpage); $this->context = $context; } @@ -111,9 +117,14 @@ class question_category_list extends moodle_list { * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @deprecated since Moodle 4.0 MDL-71585 + * @see \qbank_managecategories\question_category_list_item + * @todo deprecation on MDL-71679 */ class question_category_list_item extends list_item { public function set_icon_html($first, $last, $lastitem){ + debugging('Function set_icon_html() in \core_question\category_class is deprecated, + please use qbank_managecategories\question_category_list_item::set_icon_html() instead.', DEBUG_DEVELOPER); global $CFG; $category = $this->item; $url = new moodle_url('/question/category.php', ($this->parentlist->pageurl->params() + array('edit'=>$category->id))); @@ -134,6 +145,8 @@ class question_category_list_item extends list_item { public function item_html($extraargs = array()){ global $CFG, $PAGE, $OUTPUT; + debugging('Function item_html() in \core_question\category_class is deprecated, + please use qbank_managecategories\question_category_list_item::item_html() instead.', DEBUG_DEVELOPER); $str = $extraargs['str']; $category = $this->item; @@ -174,6 +187,9 @@ class question_category_list_item extends list_item { * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @deprecated since Moodle 4.0 MDL-71585 + * @see \qbank_managecategories\question_category_object + * @todo deprecation on MDL-71679 */ class question_category_object { @@ -211,6 +227,8 @@ class question_category_object { * @param context[] $addcontexts contexts where the current user can add questions. */ public function __construct($page, $pageurl, $contexts, $currentcat, $defaultcategory, $todelete, $addcontexts) { + debugging('Class question_category_list in \core_question\category_class is deprecated, + please use qbank_managecategories\question_category_object instead.', DEBUG_DEVELOPER); $this->tab = str_repeat(' ', $this->tabsize); diff --git a/question/category_form.php b/question/category_form.php index c0a36aa56f8..6832bbda03b 100644 --- a/question/category_form.php +++ b/question/category_form.php @@ -34,10 +34,15 @@ require_once($CFG->libdir.'/formslib.php'); * * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\form\category_form */ class question_category_edit_form extends moodleform { protected function definition() { + debugging('Class question_export_form in \core_question\category_form is deprecated, + please use core_question\bank\managecategories\form\category_form instead.', DEBUG_DEVELOPER); + $mform = $this->_form; $contexts = $this->_customdata['contexts']; diff --git a/question/classes/bank/search/category_condition.php b/question/classes/bank/search/category_condition.php index 27bb096a3cf..a47ff1ec2fe 100644 --- a/question/classes/bank/search/category_condition.php +++ b/question/classes/bank/search/category_condition.php @@ -25,6 +25,8 @@ namespace core_question\bank\search; +use qbank_managecategories\helper; + /** * This class controls from which category questions are listed. * @@ -116,7 +118,7 @@ class category_condition extends condition { public function display_options() { global $PAGE; $displaydata = []; - $catmenu = question_category_options($this->contexts, true, 0, + $catmenu = helper::question_category_options($this->contexts, true, 0, true, -1, false); $displaydata['categoryselect'] = \html_writer::select($catmenu, 'category', $this->cat, [], array('class' => 'searchoptions custom-select', 'id' => 'id_selectacategory')); diff --git a/question/classes/local/bank/view.php b/question/classes/local/bank/view.php index 5865cacde30..355cff9a812 100644 --- a/question/classes/local/bank/view.php +++ b/question/classes/local/bank/view.php @@ -26,6 +26,7 @@ namespace core_question\local\bank; use core_question\bank\search\condition; use qbank_editquestion\editquestion_helper; +use qbank_managecategories\helper; /** * This class prints a view of the question bank. @@ -1057,7 +1058,7 @@ class view { 'data-toggle' => 'action', 'disabled' => true, ]); - question_category_select_menu($addcontexts, false, 0, "{$category->id},{$category->contextid}"); + helper::question_category_select_menu($addcontexts, false, 0, "{$category->id},{$category->contextid}"); } } diff --git a/question/editlib.php b/question/editlib.php index 0d5fabab652..82440ec8fd9 100644 --- a/question/editlib.php +++ b/question/editlib.php @@ -99,15 +99,16 @@ function get_questions_category( $category, $noparent=false, $recurse=true, $exp * * @param int $categoryid a category id. * @return bool + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 */ function question_is_only_child_of_top_category_in_context($categoryid) { - 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", array($categoryid)); + debugging('Function question_is_only_child_of_top_category_in_context() + has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::question_is_only_child_of_top_category_in_context() instead.', + DEBUG_DEVELOPER); + return \qbank_managecategories\helper::question_is_only_child_of_top_category_in_contextt($categoryid); } /** @@ -115,27 +116,28 @@ function question_is_only_child_of_top_category_in_context($categoryid) { * * @param int $categoryid a category id. * @return bool + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 */ function question_is_top_category($categoryid) { - global $DB; - return 0 == $DB->get_field('question_categories', 'parent', array('id' => $categoryid)); + debugging('Function question_is_top_category() has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::question_is_top_category() instead.', DEBUG_DEVELOPER); + return \qbank_managecategories\helper::question_is_top_category($categoryid); } /** * Ensures that this user is allowed to delete this category. * * @param int $todelete a category id. + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 */ function question_can_delete_cat($todelete) { - global $DB; - if (question_is_top_category($todelete)) { - print_error('cannotdeletetopcat', 'question'); - } else if (question_is_only_child_of_top_category_in_context($todelete)) { - print_error('cannotdeletecate', 'question'); - } else { - $contextid = $DB->get_field('question_categories', 'contextid', array('id' => $todelete)); - require_capability('moodle/question:managecategory', context::instance_by_id($contextid)); - } + debugging('Function question_can_delete_cat() has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::question_can_delete_cat() instead.', DEBUG_DEVELOPER); + \qbank_managecategories\helper::question_can_delete_cat($todelete); } diff --git a/question/format.php b/question/format.php index 878dd9500ae..6c77a2b8c16 100644 --- a/question/format.php +++ b/question/format.php @@ -1065,7 +1065,7 @@ class qformat_default { * back into an array of category names. * * Each category name is cleaned by a call to clean_param(, PARAM_TEXT), - * which matches the cleaning in question/category_form.php. + * which matches the cleaning in question/bank/managecategories/category_form.php. * * @param string $path * @return array of category names. diff --git a/question/move_form.php b/question/move_form.php index 2b8ac8c7e33..60fa84caf36 100644 --- a/question/move_form.php +++ b/question/move_form.php @@ -34,9 +34,14 @@ require_once($CFG->libdir . '/formslib.php'); * * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\form\question_move_form */ class question_move_form extends moodleform { protected function definition() { + debugging('Class question_move_form in \core_question\move_form is deprecated, + please use qbank_managecategories\form\question_move_form instead.', DEBUG_DEVELOPER); + $mform = $this->_form; $currentcat = $this->_customdata['currentcat']; diff --git a/question/tests/behat/question_categories.feature b/question/tests/behat/question_categories.feature index fbcba7531f1..bf2dffeff2a 100644 --- a/question/tests/behat/question_categories.feature +++ b/question/tests/behat/question_categories.feature @@ -1,8 +1,8 @@ -@core @core_question @javascript -Feature: A teacher can put questions in categories in the question bank +@core @core_question +Feature: A teacher can move questions between categories in the question bank In order to organize my questions As a teacher - I create and edit categories and move questions between them + I move questions between categories Background: Given the following "users" exist: @@ -26,42 +26,6 @@ Feature: A teacher can put questions in categories in the question bank And I log in as "teacher1" And I am on "Course 1" course homepage - Scenario: A new question category can be created - When I navigate to "Question bank > Categories" in current page administration - And I set the following fields to these values: - | Name | New Category 1 | - | Parent category | Top | - | Category info | Created as a test | - | ID number | newcatidnumber | - And I press "submitbutton" - Then I should see "New Category 1 ID number newcatidnumber (0)" - And I should see "Created as a test" in the "New Category 1" "list_item" - And "New Category 1 [newcatidnumber]" "option" should exist in the "Parent category" "select" - - Scenario: A question category can be edited - When I navigate to "Question bank > Categories" in current page administration - And I click on "Edit this category" "link" in the "Subcategory" "list_item" - And the field "parent" matches value "   Default for C1" - And I set the following fields to these values: - | Name | New name | - | Category info | I was edited | - And I press "Save changes" - Then I should see "New name" - And I should see "I was edited" in the "New name" "list_item" - - Scenario: An empty question category can be deleted - When I navigate to "Question bank > Categories" in current page administration - And I click on "Delete" "link" in the "Subcategory" "list_item" - Then I should not see "Subcategory" - - Scenario: An non-empty question category can be deleted if you move the contents elsewhere - When I navigate to "Question bank > Categories" in current page administration - And I click on "Delete" "link" in the "Used category" "list_item" - And I should see "The category 'Used category' contains 1 questions" - And I press "Save in category" - Then I should not see "Used category" - And I should see "Default for C1 (1)" - @javascript Scenario: Move a question between categories via the question page When I navigate to "Question bank > Questions" in current page administration diff --git a/question/tests/behat/question_categories_idnumber.feature b/question/tests/behat/question_categories_idnumber.feature index 5665a021ffe..88bee950185 100644 --- a/question/tests/behat/question_categories_idnumber.feature +++ b/question/tests/behat/question_categories_idnumber.feature @@ -1,8 +1,8 @@ @core @core_question -Feature: A teacher can put questions with idnumbers in categories with idnumbers in the question bank +Feature: A teacher can put questions with idnumbers in categories in the question bank In order to organize my questions As a teacher - I create and edit categories and move questions between them (now with idnumbers) + I move questions between categories (now with idnumbers) Background: Given the following "users" exist: @@ -17,37 +17,6 @@ Feature: A teacher can put questions with idnumbers in categories with idnumbers And I log in as "teacher1" And I am on "Course 1" course homepage - Scenario: A new question category can only be created with a unique idnumber for a context - # Note need to create the top category each time. - When the following "question categories" exist: - | contextlevel | reference | questioncategory | name | idnumber | - | Course | C1 | Top | top | | - | Course | C1 | top | Used category | c1used | - And I navigate to "Question bank > Categories" in current page administration - And I set the following fields to these values: - | Name | Sub used category | - | Parent category | Used category | - | Category info | Created as a test | - | ID number | c1used | - And I press "Add category" - # Standard warning. - Then I should see "This ID number is already in use" - # Correction to a unique idnumber for the context. - And I set the field "ID number" to "c1unused" - And I press "Add category" - Then I should see "Sub used category ID number c1unused (0)" - And I should see "Created as a test" in the "Sub used category" "list_item" - - Scenario: A question category can be edited and saved without changing the idnumber - When the following "question categories" exist: - | contextlevel | reference | questioncategory | name | idnumber | - | Course | C1 | Top | top | | - | Course | C1 | top | Used category | c1used | - And I navigate to "Question bank > Categories" in current page administration - And I click on "Edit this category" "link" in the "Used category" "list_item" - And I press "Save changes" - Then I should not see "This ID number is already in use" - Scenario: A question can only have a unique idnumber within a category When the following "question categories" exist: | contextlevel | reference | questioncategory | name | idnumber | diff --git a/question/tests/category_class_test.php b/question/tests/category_class_test.php deleted file mode 100644 index adbe41cea3a..00000000000 --- a/question/tests/category_class_test.php +++ /dev/null @@ -1,178 +0,0 @@ -. - -/** - * Events tests. - * - * @package core_question - * @copyright 2019 the Open University - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -defined('MOODLE_INTERNAL') || die(); - -global $CFG; - -require_once($CFG->dirroot . '/question/editlib.php'); -require_once($CFG->dirroot . '/question/category_class.php'); - -class core_question_category_class_testcase extends advanced_testcase { - - /** - * @var question_category_object used in the tests. - */ - protected $qcobject; - - /** - * @var context a context to use. - */ - protected $context; - - /** - * @var stdClass top category in context. - */ - protected $topcat; - - protected function setUp(): void { - parent::setUp(); - self::setAdminUser(); - $this->resetAfterTest(); - $this->context = context_course::instance(SITEID); - $contexts = new question_edit_contexts($this->context); - $this->topcat = question_get_top_category($this->context->id, true); - $this->qcobject = new question_category_object(null, - new moodle_url('/question/category.php', ['courseid' => SITEID]), - $contexts->having_one_edit_tab_cap('categories'), 0, null, 0, - $contexts->having_cap('moodle/question:add')); - } - - /** - * Test creating a category. - */ - public function test_add_category_no_idnumber() { - global $DB; - - $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, - 'New category', '', true, 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() { - global $DB; - - $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, - 'New category', '', true, 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 blanks it. - * (In reality, this would probably get caught by form validation.) - */ - public function test_add_category_try_to_set_duplicate_idnumber() { - global $DB; - - $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, - 'Existing category', '', true, FORMAT_HTML, 'frog'); - - $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, - 'New category', '', true, FORMAT_HTML, 'frog'); - - $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); - $this->assertSame('New category', $newcat->name); - $this->assertNull($newcat->idnumber); - } - - /** - * Test updating a category. - */ - public function test_update_category() { - global $DB; - - $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, - 'Old name', 'Description', true, FORMAT_HTML, 'frog'); - - $this->qcobject->update_category($id, $this->topcat->id . ',' . $this->topcat->contextid, - 'New name', 'New description', FORMAT_HTML, '0', false); - - $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); - $this->assertSame('New name', $newcat->name); - $this->assertSame('0', $newcat->idnumber); - } - - /** - * Test updating a category to remove the idnumber. - */ - public function test_update_category_removing_idnumber() { - global $DB; - - $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, - 'Old name', 'Description', true, FORMAT_HTML, 'frog'); - - $this->qcobject->update_category($id, $this->topcat->id . ',' . $this->topcat->contextid, - 'New name', 'New description', FORMAT_HTML, '', false); - - $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); - $this->assertSame('New name', $newcat->name); - $this->assertNull($newcat->idnumber); - } - - /** - * Test updating a category without changing the idnumber. - */ - public function test_update_category_dont_change_idnumber() { - global $DB; - - $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, - 'Old name', 'Description', true, FORMAT_HTML, 'frog'); - - $this->qcobject->update_category($id, $this->topcat->id . ',' . $this->topcat->contextid, - 'New name', 'New description', FORMAT_HTML, 'frog', false); - - $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); - $this->assertSame('New name', $newcat->name); - $this->assertSame('frog', $newcat->idnumber); - } - - /** - * Trying to update a category so its idnumber duplicates idnumber blanks it. - * (In reality, this would probably get caught by form validation.) - */ - public function test_update_category_try_to_set_duplicate_idnumber() { - global $DB; - - $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, - 'Existing category', '', true, FORMAT_HTML, 'toad'); - $id = $this->qcobject->add_category($this->topcat->id . ',' . $this->topcat->contextid, - 'old name', '', true, FORMAT_HTML, 'frog'); - - $this->qcobject->update_category($id, $this->topcat->id . ',' . $this->topcat->contextid, - 'New name', '', FORMAT_HTML, 'toad', false); - - $newcat = $DB->get_record('question_categories', ['id' => $id], '*', MUST_EXIST); - $this->assertSame('New name', $newcat->name); - $this->assertNull($newcat->idnumber); - } -} diff --git a/question/tests/events_test.php b/question/tests/events_test.php index fda6de4fb4d..8de791624a9 100644 --- a/question/tests/events_test.php +++ b/question/tests/events_test.php @@ -27,7 +27,8 @@ defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/editlib.php'); -require_once($CFG->dirroot . '/question/category_class.php'); + +use qbank_managecategories\question_category_object; class core_question_events_testcase extends advanced_testcase { @@ -38,166 +39,6 @@ class core_question_events_testcase extends advanced_testcase { $this->resetAfterTest(); } - /** - * Test the question category created event. - */ - public function test_question_category_created() { - $this->setAdminUser(); - $course = $this->getDataGenerator()->create_course(); - $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); - - $contexts = new question_edit_contexts(context_module::instance($quiz->cmid)); - - $defaultcategoryobj = question_make_default_categories([$contexts->lowest()]); - $defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid; - - $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')); - - // Trigger and capture the event. - $sink = $this->redirectEvents(); - $categoryid = $qcobject->add_category($defaultcategory, 'newcategory', '', true); - $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_module::instance($quiz->cmid), $event->get_context()); - $expected = [$course->id, 'quiz', 'addcategory', 'view.php?id=' . $quiz->cmid , $categoryid, $quiz->cmid]; - $this->assertEventLegacyLogData($expected, $event); - $this->assertEventContextNotUsed($event); - } - - /** - * Test the question category deleted event. - */ - public function test_question_category_deleted() { - $this->setAdminUser(); - $course = $this->getDataGenerator()->create_course(); - $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); - - $contexts = new question_edit_contexts(context_module::instance($quiz->cmid)); - - $defaultcategoryobj = question_make_default_categories([$contexts->lowest()]); - $defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid; - - $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); - - // Trigger and capture the event. - $sink = $this->redirectEvents(); - $qcobject->delete_category($categoryid); - $events = $sink->get_events(); - $event = reset($events); - - // Check that the event data is valid. - $this->assertInstanceOf('\core\event\question_category_deleted', $event); - $this->assertEquals(context_module::instance($quiz->cmid), $event->get_context()); - $this->assertEquals($categoryid, $event->objectid); - $this->assertDebuggingNotCalled(); - } - - /** - * Test the question category updated event. - */ - public function test_question_category_updated() { - $this->setAdminUser(); - $course = $this->getDataGenerator()->create_course(); - $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); - - $contexts = new question_edit_contexts(context_module::instance($quiz->cmid)); - - $defaultcategoryobj = question_make_default_categories([$contexts->lowest()]); - $defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid; - - $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); - - // Trigger and capture the event. - $sink = $this->redirectEvents(); - $qcobject->update_category($categoryid, $defaultcategory, 'updatedcategory', '', FORMAT_HTML, '', false); - $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_module::instance($quiz->cmid), $event->get_context()); - $this->assertEquals($categoryid, $event->objectid); - $this->assertDebuggingNotCalled(); - } - - /** - * Test the question category viewed event. - * There is no external API for viewing the category, so the unit test will simply - * create and trigger the event and ensure data is returned as expected. - */ - public function test_question_category_viewed() { - - $this->setAdminUser(); - $course = $this->getDataGenerator()->create_course(); - $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); - - $contexts = new question_edit_contexts(context_module::instance($quiz->cmid)); - - $defaultcategoryobj = question_make_default_categories([$contexts->lowest()]); - $defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid; - - $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); - - // Log the view of this category. - $category = new stdClass(); - $category->id = $categoryid; - $context = context_module::instance($quiz->cmid); - $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_module::instance($quiz->cmid), $event->get_context()); - $this->assertEquals($categoryid, $event->objectid); - $this->assertDebuggingNotCalled(); - - } - /** * Test the questions imported event. * There is no easy way to trigger this event using the API, so the unit test will simply