Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.
   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  namespace qbank_managecategories;
  18  
  19  use context;
  20  use core_question\local\bank\question_version_status;
  21  use moodle_exception;
  22  use html_writer;
  23  
  24  /**
  25   * Class helper contains all the library functions.
  26   *
  27   * Library functions used by qbank_managecategories.
  28   * This code is based on lib/questionlib.php by Martin Dougiamas.
  29   *
  30   * @package    qbank_managecategories
  31   * @copyright  2021 Catalyst IT Australia Pty Ltd
  32   * @author     Guillermo Gomez Arias <guillermogomez@catalyst-au.net>
  33   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  34   */
  35  class helper {
  36  
  37      /**
  38       * Name of this plugin.
  39       */
  40      const PLUGINNAME = 'qbank_managecategories';
  41  
  42      /**
  43       * Remove stale questions from a category.
  44       *
  45       * While questions should not be left behind when they are not used any more,
  46       * it does happen, maybe via restore, or old logic, or uncovered scenarios. When
  47       * this happens, the users are unable to delete the question category unless
  48       * they move those stale questions to another one category, but to them the
  49       * category is empty as it does not contain anything. The purpose of this function
  50       * is to detect the questions that may have gone stale and remove them.
  51       *
  52       * You will typically use this prior to checking if the category contains questions.
  53       *
  54       * The stale questions (unused and hidden to the user) handled are:
  55       * - hidden questions
  56       * - random questions
  57       *
  58       * @param int $categoryid The category ID.
  59       * @throws \dml_exception
  60       */
  61      public static function question_remove_stale_questions_from_category(int $categoryid): void {
  62          global $DB;
  63  
  64          $sql = "SELECT q.id
  65                    FROM {question} q
  66                    JOIN {question_versions} qv ON qv.questionid = q.id
  67                    JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
  68                   WHERE qbe.questioncategoryid = :categoryid
  69                     AND (q.qtype = :qtype OR qv.status = :status)";
  70  
  71          $params = ['categoryid' => $categoryid, 'qtype' => 'random', 'status' => question_version_status::QUESTION_STATUS_HIDDEN];
  72          $questions = $DB->get_records_sql($sql, $params);
  73          foreach ($questions as $question) {
  74              // The function question_delete_question does not delete questions in use.
  75              question_delete_question($question->id);
  76          }
  77      }
  78  
  79      /**
  80       * Checks whether this is the only child of a top category in a context.
  81       *
  82       * @param int $categoryid a category id.
  83       * @return bool
  84       * @throws \dml_exception
  85       */
  86      public static function question_is_only_child_of_top_category_in_context(int $categoryid): bool {
  87          global $DB;
  88          return 1 == $DB->count_records_sql("
  89              SELECT count(*)
  90                FROM {question_categories} c
  91                JOIN {question_categories} p ON c.parent = p.id
  92                JOIN {question_categories} s ON s.parent = c.parent
  93               WHERE c.id = ? AND p.parent = 0", [$categoryid]);
  94      }
  95  
  96      /**
  97       * Checks whether the category is a "Top" category (with no parent).
  98       *
  99       * @param int $categoryid a category id.
 100       * @return bool
 101       * @throws \dml_exception
 102       */
 103      public static function question_is_top_category(int $categoryid): bool {
 104          global $DB;
 105          return 0 == $DB->get_field('question_categories', 'parent', ['id' => $categoryid]);
 106      }
 107  
 108      /**
 109       * Ensures that this user is allowed to delete this category.
 110       *
 111       * @param int $todelete a category id.
 112       * @throws \required_capability_exception
 113       * @throws \dml_exception|moodle_exception
 114       */
 115      public static function question_can_delete_cat(int $todelete): void {
 116          global $DB;
 117          if (self::question_is_top_category($todelete)) {
 118              throw new moodle_exception('cannotdeletetopcat', 'question');
 119          } else if (self::question_is_only_child_of_top_category_in_context($todelete)) {
 120              throw new moodle_exception('cannotdeletecate', 'question');
 121          } else {
 122              $contextid = $DB->get_field('question_categories', 'contextid', ['id' => $todelete]);
 123              require_capability('moodle/question:managecategory', context::instance_by_id($contextid));
 124          }
 125      }
 126  
 127      /**
 128       * Only for the use of add_indented_names().
 129       *
 130       * Recursively adds an indentedname field to each category, starting with the category
 131       * with id $id, and dealing with that category and all its children, and
 132       * return a new array, with those categories in the right order.
 133       *
 134       * @param array $categories an array of categories which has had childids
 135       *          fields added by flatten_category_tree(). Passed by reference for
 136       *          performance only. It is not modfied.
 137       * @param int $id the category to start the indenting process from.
 138       * @param int $depth the indent depth. Used in recursive calls.
 139       * @param int $nochildrenof
 140       * @return array a new array of categories, in the right order for the tree.
 141       */
 142      public static function flatten_category_tree(array &$categories, $id, int $depth = 0, int $nochildrenof = -1): array {
 143  
 144          // Indent the name of this category.
 145          $newcategories = [];
 146          $newcategories[$id] = $categories[$id];
 147          $newcategories[$id]->indentedname = str_repeat('&nbsp;&nbsp;&nbsp;', $depth) .
 148              $categories[$id]->name;
 149  
 150          // Recursively indent the children.
 151          foreach ($categories[$id]->childids as $childid) {
 152              if ($childid != $nochildrenof) {
 153                  $newcategories = $newcategories + self::flatten_category_tree(
 154                          $categories, $childid, $depth + 1, $nochildrenof);
 155              }
 156          }
 157  
 158          // Remove the childids array that were temporarily added.
 159          unset($newcategories[$id]->childids);
 160  
 161          return $newcategories;
 162      }
 163  
 164      /**
 165       * Format categories into an indented list reflecting the tree structure.
 166       *
 167       * @param array $categories An array of category objects, for example from the.
 168       * @param int $nochildrenof
 169       * @return array The formatted list of categories.
 170       */
 171      public static function add_indented_names(array $categories, int $nochildrenof = -1): array {
 172  
 173          // Add an array to each category to hold the child category ids. This array
 174          // will be removed again by flatten_category_tree(). It should not be used
 175          // outside these two functions.
 176          foreach (array_keys($categories) as $id) {
 177              $categories[$id]->childids = [];
 178          }
 179  
 180          // Build the tree structure, and record which categories are top-level.
 181          // We have to be careful, because the categories array may include published
 182          // categories from other courses, but not their parents.
 183          $toplevelcategoryids = [];
 184          foreach (array_keys($categories) as $id) {
 185              if (!empty($categories[$id]->parent) &&
 186                  array_key_exists($categories[$id]->parent, $categories)) {
 187                  $categories[$categories[$id]->parent]->childids[] = $id;
 188              } else {
 189                  $toplevelcategoryids[] = $id;
 190              }
 191          }
 192  
 193          // Flatten the tree to and add the indents.
 194          $newcategories = [];
 195          foreach ($toplevelcategoryids as $id) {
 196              $newcategories = $newcategories + self::flatten_category_tree(
 197                      $categories, $id, 0, $nochildrenof);
 198          }
 199  
 200          return $newcategories;
 201      }
 202  
 203      /**
 204       * Output a select menu of question categories.
 205       *
 206       * Categories from this course and (optionally) published categories from other courses
 207       * are included. Optionally, only categories the current user may edit can be included.
 208       *
 209       * @param array $contexts
 210       * @param bool $top
 211       * @param int $currentcat
 212       * @param string $selected optionally, the id of a category to be selected by
 213       *      default in the dropdown.
 214       * @param int $nochildrenof
 215       * @param bool $return to return the string of the select menu or echo that from the method
 216       * @throws \coding_exception|\dml_exception
 217       */
 218      public static function question_category_select_menu(array $contexts, bool $top = false, int $currentcat = 0,
 219                                             string $selected = "", int $nochildrenof = -1, bool $return = false) {
 220          $categoriesarray = self::question_category_options($contexts, $top, $currentcat,
 221              false, $nochildrenof, false);
 222          $choose = '';
 223          $options = [];
 224          foreach ($categoriesarray as $group => $opts) {
 225              $options[] = [$group => $opts];
 226          }
 227          $outputhtml = html_writer::label(get_string('questioncategory', 'core_question'),
 228              'id_movetocategory', false, ['class' => 'accesshide']);
 229          $attrs = [
 230              'id' => 'id_movetocategory',
 231              'class' => 'custom-select',
 232              'data-action' => 'toggle',
 233              'data-togglegroup' => 'qbank',
 234              'data-toggle' => 'action',
 235              'disabled' => false,
 236          ];
 237          $outputhtml .= html_writer::select($options, 'category', $selected, $choose, $attrs);
 238          if ($return) {
 239              return $outputhtml;
 240          } else {
 241              echo $outputhtml;
 242          }
 243      }
 244  
 245      /**
 246       * Get all the category objects, including a count of the number of questions in that category,
 247       * for all the categories in the lists $contexts.
 248       *
 249       * @param context $contexts
 250       * @param string $sortorder used as the ORDER BY clause in the select statement.
 251       * @param bool $top Whether to return the top categories or not.
 252       * @param int $showallversions 1 to show all versions not only the latest.
 253       * @return array of category objects.
 254       * @throws \dml_exception
 255       */
 256      public static function get_categories_for_contexts($contexts, string $sortorder = 'parent, sortorder, name ASC',
 257                                                         bool $top = false, int $showallversions = 0): array {
 258          global $DB;
 259          $topwhere = $top ? '' : 'AND c.parent <> 0';
 260          $statuscondition = "AND (qv.status = '". question_version_status::QUESTION_STATUS_READY . "' " .
 261              " OR qv.status = '" . question_version_status::QUESTION_STATUS_DRAFT . "' )";
 262  
 263          $sql = "SELECT c.*,
 264                      (SELECT COUNT(1)
 265                         FROM {question} q
 266                         JOIN {question_versions} qv ON qv.questionid = q.id
 267                         JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
 268                        WHERE q.parent = '0'
 269                          $statuscondition
 270                              AND c.id = qbe.questioncategoryid
 271                              AND ($showallversions = 1
 272                                  OR (qv.version = (SELECT MAX(v.version)
 273                                                      FROM {question_versions} v
 274                                                      JOIN {question_bank_entries} be ON be.id = v.questionbankentryid
 275                                                     WHERE be.id = qbe.id)
 276                                     )
 277                                  )
 278                              ) AS questioncount
 279                    FROM {question_categories} c
 280                   WHERE c.contextid IN ($contexts) $topwhere
 281                ORDER BY $sortorder";
 282  
 283          return $DB->get_records_sql($sql);
 284      }
 285  
 286      /**
 287       * Output an array of question categories.
 288       *
 289       * @param array $contexts The list of contexts.
 290       * @param bool $top Whether to return the top categories or not.
 291       * @param int $currentcat
 292       * @param bool $popupform
 293       * @param int $nochildrenof
 294       * @param bool $escapecontextnames Whether the returned name of the thing is to be HTML escaped or not.
 295       * @return array
 296       * @throws \coding_exception|\dml_exception
 297       */
 298      public static function question_category_options(array $contexts, bool $top = false, int $currentcat = 0,
 299                                                       bool $popupform = false, int $nochildrenof = -1,
 300                                                       bool $escapecontextnames = true): array {
 301          global $CFG;
 302          $pcontexts = [];
 303          foreach ($contexts as $context) {
 304              $pcontexts[] = $context->id;
 305          }
 306          $contextslist = join(', ', $pcontexts);
 307  
 308          $categories = self::get_categories_for_contexts($contextslist, 'parent, sortorder, name ASC', $top);
 309  
 310          if ($top) {
 311              $categories = self::question_fix_top_names($categories);
 312          }
 313  
 314          $categories = self::question_add_context_in_key($categories);
 315          $categories = self::add_indented_names($categories, $nochildrenof);
 316  
 317          // Sort cats out into different contexts.
 318          $categoriesarray = [];
 319          foreach ($pcontexts as $contextid) {
 320              $context = \context::instance_by_id($contextid);
 321              $contextstring = $context->get_context_name(true, true, $escapecontextnames);
 322              foreach ($categories as $category) {
 323                  if ($category->contextid == $contextid) {
 324                      $cid = $category->id;
 325                      if ($currentcat != $cid || $currentcat == 0) {
 326                          $a = new \stdClass;
 327                          $a->name = format_string($category->indentedname, true,
 328                              ['context' => $context]);
 329                          if ($category->idnumber !== null && $category->idnumber !== '') {
 330                              $a->idnumber = s($category->idnumber);
 331                          }
 332                          if (!empty($category->questioncount)) {
 333                              $a->questioncount = $category->questioncount;
 334                          }
 335                          if (isset($a->idnumber) && isset($a->questioncount)) {
 336                              $formattedname = get_string('categorynamewithidnumberandcount', 'question', $a);
 337                          } else if (isset($a->idnumber)) {
 338                              $formattedname = get_string('categorynamewithidnumber', 'question', $a);
 339                          } else if (isset($a->questioncount)) {
 340                              $formattedname = get_string('categorynamewithcount', 'question', $a);
 341                          } else {
 342                              $formattedname = $a->name;
 343                          }
 344                          $categoriesarray[$contextstring][$cid] = $formattedname;
 345                      }
 346                  }
 347              }
 348          }
 349          if ($popupform) {
 350              $popupcats = [];
 351              foreach ($categoriesarray as $contextstring => $optgroup) {
 352                  $group = [];
 353                  foreach ($optgroup as $key => $value) {
 354                      $key = str_replace($CFG->wwwroot, '', $key);
 355                      $group[$key] = $value;
 356                  }
 357                  $popupcats[] = [$contextstring => $group];
 358              }
 359              return $popupcats;
 360          } else {
 361              return $categoriesarray;
 362          }
 363      }
 364  
 365      /**
 366       * Add context in categories key.
 367       *
 368       * @param array $categories The list of categories.
 369       * @return array
 370       */
 371      public static function question_add_context_in_key(array $categories): array {
 372          $newcatarray = [];
 373          foreach ($categories as $id => $category) {
 374              $category->parent = "$category->parent,$category->contextid";
 375              $category->id = "$category->id,$category->contextid";
 376              $newcatarray["$id,$category->contextid"] = $category;
 377          }
 378          return $newcatarray;
 379      }
 380  
 381      /**
 382       * Finds top categories in the given categories hierarchy and replace their name with a proper localised string.
 383       *
 384       * @param array $categories An array of question categories.
 385       * @param bool $escape Whether the returned name of the thing is to be HTML escaped or not.
 386       * @return array The same question category list given to the function, with the top category names being translated.
 387       * @throws \coding_exception
 388       */
 389      public static function question_fix_top_names(array $categories, bool $escape = true): array {
 390  
 391          foreach ($categories as $id => $category) {
 392              if ($category->parent == 0) {
 393                  $context = \context::instance_by_id($category->contextid);
 394                  $categories[$id]->name = get_string('topfor', 'question', $context->get_context_name(false, false, $escape));
 395              }
 396          }
 397  
 398          return $categories;
 399      }
 400  }