Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.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  
  18  /**
  19   * Class to print a view of the question bank.
  20   *
  21   * @package   core_question
  22   * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
  23   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   */
  25  
  26  namespace core_question\bank;
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  use core_question\bank\search\condition;
  30  
  31  
  32  /**
  33   * This class prints a view of the question bank, including
  34   *  + Some controls to allow users to to select what is displayed.
  35   *  + A list of questions as a table.
  36   *  + Further controls to do things with the questions.
  37   *
  38   * This class gives a basic view, and provides plenty of hooks where subclasses
  39   * can override parts of the display.
  40   *
  41   * The list of questions presented as a table is generated by creating a list of
  42   * core_question\bank\column objects, one for each 'column' to be displayed. These
  43   * manage
  44   *  + outputting the contents of that column, given a $question object, but also
  45   *  + generating the right fragments of SQL to ensure the necessary data is present,
  46   *    and sorted in the right order.
  47   *  + outputting table headers.
  48   *
  49   * @copyright 2009 Tim Hunt
  50   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  51   */
  52  class view {
  53      const MAX_SORTS = 3;
  54  
  55      /**
  56       * @var \moodle_url base URL for the current page. Used as the
  57       * basis for making URLs for actions that reload the page.
  58       */
  59      protected $baseurl;
  60  
  61      /**
  62       * @var \moodle_url used as a basis for URLs that edit a question.
  63       */
  64      protected $editquestionurl;
  65  
  66      /**
  67       * @var \question_edit_contexts
  68       */
  69      protected $contexts;
  70  
  71      /**
  72       * @var object|\cm_info|null if we are in a module context, the cm.
  73       */
  74      protected $cm;
  75  
  76      /**
  77       * @var object the course we are within.
  78       */
  79      protected $course;
  80  
  81      /**
  82       * @var \question_bank_column_base[] these are all the 'columns' that are
  83       * part of the display. Array keys are the class name.
  84       */
  85      protected $requiredcolumns;
  86  
  87      /**
  88       * @var \question_bank_column_base[] these are the 'columns' that are
  89       * actually displayed as a column, in order. Array keys are the class name.
  90       */
  91      protected $visiblecolumns;
  92  
  93      /**
  94       * @var \question_bank_column_base[] these are the 'columns' that are
  95       * actually displayed as an additional row (e.g. question text), in order.
  96       * Array keys are the class name.
  97       */
  98      protected $extrarows;
  99  
 100      /**
 101       * @var array list of column class names for which columns to sort on.
 102       */
 103      protected $sort;
 104  
 105      /**
 106       * @var int|null id of the a question to highlight in the list (if present).
 107       */
 108      protected $lastchangedid;
 109  
 110      /**
 111       * @var string SQL to count the number of questions matching the current
 112       * search conditions.
 113       */
 114      protected $countsql;
 115  
 116      /**
 117       * @var string SQL to actually load the question data to display.
 118       */
 119      protected $loadsql;
 120  
 121      /**
 122       * @var array params used by $countsql and $loadsql (which currently must be the same).
 123       */
 124      protected $sqlparams;
 125  
 126      /**
 127       * @var condition[] search conditions.
 128       */
 129      protected $searchconditions = array();
 130  
 131      /**
 132       * Constructor
 133       * @param \question_edit_contexts $contexts
 134       * @param \moodle_url $pageurl
 135       * @param object $course course settings
 136       * @param object $cm (optional) activity settings.
 137       */
 138      public function __construct($contexts, $pageurl, $course, $cm = null) {
 139          $this->contexts = $contexts;
 140          $this->baseurl = $pageurl;
 141          $this->course = $course;
 142          $this->cm = $cm;
 143  
 144          // Create the url of the new question page to forward to.
 145          $returnurl = $pageurl->out_as_local_url(false);
 146          $this->editquestionurl = new \moodle_url('/question/question.php',
 147                  array('returnurl' => $returnurl));
 148          if ($cm !== null) {
 149              $this->editquestionurl->param('cmid', $cm->id);
 150          } else {
 151              $this->editquestionurl->param('courseid', $this->course->id);
 152          }
 153  
 154          $this->lastchangedid = optional_param('lastchanged', 0, PARAM_INT);
 155  
 156          $this->init_columns($this->wanted_columns(), $this->heading_column());
 157          $this->init_sort();
 158          $this->init_search_conditions();
 159      }
 160  
 161      /**
 162       * Initialize search conditions from plugins
 163       * local_*_get_question_bank_search_conditions() must return an array of
 164       * \core_question\bank\search\condition objects.
 165       */
 166      protected function init_search_conditions() {
 167          $searchplugins = get_plugin_list_with_function('local', 'get_question_bank_search_conditions');
 168          foreach ($searchplugins as $component => $function) {
 169              foreach ($function($this) as $searchobject) {
 170                  $this->add_searchcondition($searchobject);
 171              }
 172          }
 173      }
 174  
 175      protected function wanted_columns() {
 176          global $CFG;
 177  
 178          if (empty($CFG->questionbankcolumns)) {
 179              $questionbankcolumns = array('checkbox_column', 'question_type_column',
 180                      'question_name_idnumber_tags_column', 'edit_menu_column',
 181                      'edit_action_column', 'copy_action_column', 'tags_action_column',
 182                      'preview_action_column', 'delete_action_column', 'export_xml_action_column',
 183                      'creator_name_column', 'modifier_name_column');
 184          } else {
 185               $questionbankcolumns = explode(',', $CFG->questionbankcolumns);
 186          }
 187          if (question_get_display_preference('qbshowtext', 0, PARAM_BOOL, new \moodle_url(''))) {
 188              $questionbankcolumns[] = 'question_text_row';
 189          }
 190  
 191          foreach ($questionbankcolumns as $fullname) {
 192              if (! class_exists($fullname)) {
 193                  if (class_exists('core_question\\bank\\' . $fullname)) {
 194                      $fullname = 'core_question\\bank\\' . $fullname;
 195                  } else {
 196                      throw new \coding_exception("No such class exists: $fullname");
 197                  }
 198              }
 199              $this->requiredcolumns[$fullname] = new $fullname($this);
 200          }
 201          return $this->requiredcolumns;
 202      }
 203  
 204  
 205      /**
 206       * Get a column object from its name.
 207       *
 208       * @param string $columnname.
 209       * @return \core_question\bank\column_base.
 210       */
 211      protected function get_column_type($columnname) {
 212          if (! class_exists($columnname)) {
 213              if (class_exists('core_question\\bank\\' . $columnname)) {
 214                  $columnname = 'core_question\\bank\\' . $columnname;
 215              } else {
 216                  throw new \coding_exception("No such class exists: $columnname");
 217              }
 218          }
 219          if (empty($this->requiredcolumns[$columnname])) {
 220              $this->requiredcolumns[$columnname] = new $columnname($this);
 221          }
 222          return $this->requiredcolumns[$columnname];
 223      }
 224  
 225      /**
 226       * Specify the column heading
 227       *
 228       * @return string Column name for the heading
 229       */
 230      protected function heading_column() {
 231          return 'question_bank_question_name_column';
 232      }
 233  
 234      /**
 235       * Initializing table columns
 236       *
 237       * @param array $wanted Collection of column names
 238       * @param string $heading The name of column that is set as heading
 239       */
 240      protected function init_columns($wanted, $heading = '') {
 241          // If we are using the edit menu column, allow it to absorb all the actions.
 242          foreach ($wanted as $column) {
 243              if ($column instanceof edit_menu_column) {
 244                  $wanted = $column->claim_menuable_columns($wanted);
 245                  break;
 246              }
 247          }
 248  
 249          // Now split columns into real columns and rows.
 250          $this->visiblecolumns = array();
 251          $this->extrarows = array();
 252          foreach ($wanted as $column) {
 253              if ($column->is_extra_row()) {
 254                  $this->extrarows[get_class($column)] = $column;
 255              } else {
 256                  $this->visiblecolumns[get_class($column)] = $column;
 257              }
 258          }
 259          if (array_key_exists($heading, $this->requiredcolumns)) {
 260              $this->requiredcolumns[$heading]->set_as_heading();
 261          }
 262      }
 263  
 264      /**
 265       * @param string $colname a column internal name.
 266       * @return bool is this column included in the output?
 267       */
 268      public function has_column($colname) {
 269          return isset($this->visiblecolumns[$colname]);
 270      }
 271  
 272      /**
 273       * @return int The number of columns in the table.
 274       */
 275      public function get_column_count() {
 276          return count($this->visiblecolumns);
 277      }
 278  
 279      public function get_courseid() {
 280          return $this->course->id;
 281      }
 282  
 283      protected function init_sort() {
 284          $this->init_sort_from_params();
 285          if (empty($this->sort)) {
 286              $this->sort = $this->default_sort();
 287          }
 288      }
 289  
 290      /**
 291       * Deal with a sort name of the form columnname, or colname_subsort by
 292       * breaking it up, validating the bits that are present, and returning them.
 293       * If there is no subsort, then $subsort is returned as ''.
 294       *
 295       * @param string $sort the sort parameter to process.
 296       * @return array array($colname, $subsort).
 297       */
 298      protected function parse_subsort($sort) {
 299          // Do the parsing.
 300          if (strpos($sort, '-') !== false) {
 301              list($colname, $subsort) = explode('-', $sort, 2);
 302          } else {
 303              $colname = $sort;
 304              $subsort = '';
 305          }
 306          // Validate the column name.
 307          $column = $this->get_column_type($colname);
 308          if (!isset($column) || !$column->is_sortable()) {
 309              for ($i = 1; $i <= self::MAX_SORTS; $i++) {
 310                  $this->baseurl->remove_params('qbs' . $i);
 311              }
 312              throw new \moodle_exception('unknownsortcolumn', '', $link = $this->baseurl->out(), $colname);
 313          }
 314          // Validate the subsort, if present.
 315          if ($subsort) {
 316              $subsorts = $column->is_sortable();
 317              if (!is_array($subsorts) || !isset($subsorts[$subsort])) {
 318                  throw new \moodle_exception('unknownsortcolumn', '', $link = $this->baseurl->out(), $sort);
 319              }
 320          }
 321          return array($colname, $subsort);
 322      }
 323  
 324      protected function init_sort_from_params() {
 325          $this->sort = array();
 326          for ($i = 1; $i <= self::MAX_SORTS; $i++) {
 327              if (!$sort = optional_param('qbs' . $i, '', PARAM_TEXT)) {
 328                  break;
 329              }
 330              // Work out the appropriate order.
 331              $order = 1;
 332              if ($sort[0] == '-') {
 333                  $order = -1;
 334                  $sort = substr($sort, 1);
 335                  if (!$sort) {
 336                      break;
 337                  }
 338              }
 339              // Deal with subsorts.
 340              list($colname) = $this->parse_subsort($sort);
 341              $this->requiredcolumns[$colname] = $this->get_column_type($colname);
 342              $this->sort[$sort] = $order;
 343          }
 344      }
 345  
 346      protected function sort_to_params($sorts) {
 347          $params = array();
 348          $i = 0;
 349          foreach ($sorts as $sort => $order) {
 350              $i += 1;
 351              if ($order < 0) {
 352                  $sort = '-' . $sort;
 353              }
 354              $params['qbs' . $i] = $sort;
 355          }
 356          return $params;
 357      }
 358  
 359      protected function default_sort() {
 360          return array(
 361              'core_question\bank\question_type_column' => 1,
 362              'core_question\bank\question_name_idnumber_tags_column-name' => 1
 363          );
 364      }
 365  
 366      /**
 367       * @param string $sort a column or column_subsort name.
 368       * @return int the current sort order for this column -1, 0, 1
 369       */
 370      public function get_primary_sort_order($sort) {
 371          $order = reset($this->sort);
 372          $primarysort = key($this->sort);
 373          if ($sort == $primarysort) {
 374              return $order;
 375          } else {
 376              return 0;
 377          }
 378      }
 379  
 380      /**
 381       * Get a URL to redisplay the page with a new sort for the question bank.
 382       *
 383       * @param string $sort the column, or column_subsort to sort on.
 384       * @param bool $newsortreverse whether to sort in reverse order.
 385       * @return string The new URL.
 386       */
 387      public function new_sort_url($sort, $newsortreverse) {
 388          if ($newsortreverse) {
 389              $order = -1;
 390          } else {
 391              $order = 1;
 392          }
 393          // Tricky code to add the new sort at the start, removing it from where it was before, if it was present.
 394          $newsort = array_reverse($this->sort);
 395          if (isset($newsort[$sort])) {
 396              unset($newsort[$sort]);
 397          }
 398          $newsort[$sort] = $order;
 399          $newsort = array_reverse($newsort);
 400          if (count($newsort) > self::MAX_SORTS) {
 401              $newsort = array_slice($newsort, 0, self::MAX_SORTS, true);
 402          }
 403          return $this->baseurl->out(true, $this->sort_to_params($newsort));
 404      }
 405  
 406      /**
 407       * Create the SQL query to retrieve the indicated questions
 408       *
 409       * @param \stdClass $category no longer used.
 410       * @param bool $recurse no longer used.
 411       * @param bool $showhidden no longer used.
 412       * @deprecated since Moodle 2.7 MDL-40313.
 413       * @see build_query()
 414       * @see \core_question\bank\search\condition
 415       * @todo MDL-41978 This will be deleted in Moodle 2.8
 416       */
 417      protected function build_query_sql($category, $recurse, $showhidden) {
 418          debugging('build_query_sql() is deprecated, please use \core_question\bank\view::build_query() and ' .
 419                  '\core_question\bank\search\condition classes instead.', DEBUG_DEVELOPER);
 420          self::build_query();
 421      }
 422  
 423      /**
 424       * Create the SQL query to retrieve the indicated questions, based on
 425       * \core_question\bank\search\condition filters.
 426       */
 427      protected function build_query() {
 428          // Get the required tables and fields.
 429          $joins = array();
 430          $fields = array('q.hidden', 'q.category');
 431          foreach ($this->requiredcolumns as $column) {
 432              $extrajoins = $column->get_extra_joins();
 433              foreach ($extrajoins as $prefix => $join) {
 434                  if (isset($joins[$prefix]) && $joins[$prefix] != $join) {
 435                      throw new \coding_exception('Join ' . $join . ' conflicts with previous join ' . $joins[$prefix]);
 436                  }
 437                  $joins[$prefix] = $join;
 438              }
 439              $fields = array_merge($fields, $column->get_required_fields());
 440          }
 441          $fields = array_unique($fields);
 442  
 443          // Build the order by clause.
 444          $sorts = array();
 445          foreach ($this->sort as $sort => $order) {
 446              list($colname, $subsort) = $this->parse_subsort($sort);
 447              $sorts[] = $this->requiredcolumns[$colname]->sort_expression($order < 0, $subsort);
 448          }
 449  
 450          // Build the where clause.
 451          $tests = array('q.parent = 0');
 452          $this->sqlparams = array();
 453          foreach ($this->searchconditions as $searchcondition) {
 454              if ($searchcondition->where()) {
 455                  $tests[] = '((' . $searchcondition->where() .'))';
 456              }
 457              if ($searchcondition->params()) {
 458                  $this->sqlparams = array_merge($this->sqlparams, $searchcondition->params());
 459              }
 460          }
 461          // Build the SQL.
 462          $sql = ' FROM {question} q ' . implode(' ', $joins);
 463          $sql .= ' WHERE ' . implode(' AND ', $tests);
 464          $this->countsql = 'SELECT count(1)' . $sql;
 465          $this->loadsql = 'SELECT ' . implode(', ', $fields) . $sql . ' ORDER BY ' . implode(', ', $sorts);
 466      }
 467  
 468      protected function get_question_count() {
 469          global $DB;
 470          return $DB->count_records_sql($this->countsql, $this->sqlparams);
 471      }
 472  
 473      /**
 474       * Load the questions we need to display.
 475       *
 476       * @param int $page page to display.
 477       * @param int $perpage number of questions per page.
 478       * @return \moodle_recordset questionid => data about each question.
 479       */
 480      protected function load_page_questions($page, $perpage) {
 481          global $DB;
 482          $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, $page * $perpage, $perpage);
 483          if (empty($questions)) {
 484              $questions->close();
 485              // No questions on this page. Reset to page 0.
 486              $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, 0, $perpage);
 487          }
 488          return $questions;
 489      }
 490  
 491      public function base_url() {
 492          return $this->baseurl;
 493      }
 494  
 495      /**
 496       * Get the URL for editing a question as a {@link \moodle_url}.
 497       *
 498       * @param int $questionid the question id.
 499       * @return \moodle_url the URL, HTML-escaped.
 500       */
 501      public function edit_question_moodle_url($questionid) {
 502          return new \moodle_url($this->editquestionurl, ['id' => $questionid]);
 503      }
 504  
 505      /**
 506       * Get the URL for editing a question as a HTML-escaped string.
 507       *
 508       * @param int $questionid the question id.
 509       * @return string the URL, HTML-escaped.
 510       */
 511      public function edit_question_url($questionid) {
 512          return $this->edit_question_moodle_url($questionid)->out();
 513      }
 514  
 515      /**
 516       * Get the URL for duplicating a question as a {@link \moodle_url}.
 517       *
 518       * @param int $questionid the question id.
 519       * @return \moodle_url the URL.
 520       */
 521      public function copy_question_moodle_url($questionid) {
 522          return new \moodle_url($this->editquestionurl, ['id' => $questionid, 'makecopy' => 1]);
 523      }
 524  
 525      /**
 526       * Get the URL for duplicating a given question.
 527       * @param int $questionid the question id.
 528       * @return string the URL, HTML-escaped.
 529       */
 530      public function copy_question_url($questionid) {
 531          return $this->copy_question_moodle_url($questionid)->out();
 532      }
 533  
 534      /**
 535       * Get the context we are displaying the question bank for.
 536       * @return \context context object.
 537       */
 538      public function get_most_specific_context() {
 539          return $this->contexts->lowest();
 540      }
 541  
 542      /**
 543       * Get the URL to preview a question.
 544       * @param \stdClass $questiondata the data defining the question.
 545       * @return \moodle_url the URL.
 546       */
 547      public function preview_question_url($questiondata) {
 548          return question_preview_url($questiondata->id, null, null, null, null,
 549                  $this->get_most_specific_context());
 550      }
 551  
 552      /**
 553       * Shows the question bank editing interface.
 554       *
 555       * The function also processes a number of actions:
 556       *
 557       * Actions affecting the question pool:
 558       * move           Moves a question to a different category
 559       * deleteselected Deletes the selected questions from the category
 560       * Other actions:
 561       * category      Chooses the category
 562       *
 563       * @param string $tabname question bank edit tab name, for permission checking.
 564       * @param int $page the page number to show.
 565       * @param int $perpage the number of questions per page to show.
 566       * @param string $cat 'categoryid,contextid'.
 567       * @param int $recurse     Whether to include subcategories.
 568       * @param bool $showhidden  whether deleted questions should be displayed.
 569       * @param bool $showquestiontext whether the text of each question should be shown in the list. Deprecated.
 570       * @param array $tagids current list of selected tags.
 571       */
 572      public function display($tabname, $page, $perpage, $cat,
 573              $recurse, $showhidden, $showquestiontext, $tagids = []) {
 574          global $PAGE, $CFG;
 575  
 576          if ($this->process_actions_needing_ui()) {
 577              return;
 578          }
 579          $editcontexts = $this->contexts->having_one_edit_tab_cap($tabname);
 580          list(, $contextid) = explode(',', $cat);
 581          $catcontext = \context::instance_by_id($contextid);
 582          $thiscontext = $this->get_most_specific_context();
 583          // Category selection form.
 584          $this->display_question_bank_header();
 585  
 586          // Display tag filter if usetags setting is enabled.
 587          if ($CFG->usetags) {
 588              array_unshift($this->searchconditions,
 589                      new \core_question\bank\search\tag_condition([$catcontext, $thiscontext], $tagids));
 590              $PAGE->requires->js_call_amd('core_question/edit_tags', 'init', ['#questionscontainer']);
 591          }
 592  
 593          array_unshift($this->searchconditions, new \core_question\bank\search\hidden_condition(!$showhidden));
 594          array_unshift($this->searchconditions, new \core_question\bank\search\category_condition(
 595                  $cat, $recurse, $editcontexts, $this->baseurl, $this->course));
 596          $this->display_options_form($showquestiontext);
 597  
 598          // Continues with list of questions.
 599          $this->display_question_list($editcontexts,
 600                  $this->baseurl, $cat, $this->cm,
 601                  null, $page, $perpage, $showhidden, $showquestiontext,
 602                  $this->contexts->having_cap('moodle/question:add'));
 603  
 604      }
 605  
 606      protected function print_choose_category_message($categoryandcontext) {
 607          echo "<p style=\"text-align:center;\"><b>";
 608          print_string('selectcategoryabove', 'question');
 609          echo "</b></p>";
 610      }
 611  
 612      protected function get_current_category($categoryandcontext) {
 613          global $DB, $OUTPUT;
 614          list($categoryid, $contextid) = explode(',', $categoryandcontext);
 615          if (!$categoryid) {
 616              $this->print_choose_category_message($categoryandcontext);
 617              return false;
 618          }
 619  
 620          if (!$category = $DB->get_record('question_categories',
 621                  array('id' => $categoryid, 'contextid' => $contextid))) {
 622              echo $OUTPUT->box_start('generalbox questionbank');
 623              echo $OUTPUT->notification('Category not found!');
 624              echo $OUTPUT->box_end();
 625              return false;
 626          }
 627  
 628          return $category;
 629      }
 630  
 631      /**
 632       * prints category information
 633       * @param \stdClass $category the category row from the database.
 634       * @deprecated since Moodle 2.7 MDL-40313.
 635       * @see \core_question\bank\search\condition
 636       * @todo MDL-41978 This will be deleted in Moodle 2.8
 637       */
 638      protected function print_category_info($category) {
 639          $formatoptions = new \stdClass();
 640          $formatoptions->noclean = true;
 641          $formatoptions->overflowdiv = true;
 642          echo '<div class="boxaligncenter">';
 643          echo format_text($category->info, $category->infoformat, $formatoptions, $this->course->id);
 644          echo "</div>\n";
 645      }
 646  
 647      /**
 648       * Prints a form to choose categories
 649       * @deprecated since Moodle 2.7 MDL-40313.
 650       * @see \core_question\bank\search\condition
 651       * @todo MDL-41978 This will be deleted in Moodle 2.8
 652       */
 653      protected function display_category_form($contexts, $pageurl, $current) {
 654          global $OUTPUT;
 655  
 656          debugging('display_category_form() is deprecated, please use ' .
 657                  '\core_question\bank\search\condition instead.', DEBUG_DEVELOPER);
 658          // Get all the existing categories now.
 659          echo '<div class="choosecategory">';
 660          $catmenu = question_category_options($contexts, false, 0, true);
 661  
 662          $select = new \single_select($this->baseurl, 'category', $catmenu, $current, null, 'catmenu');
 663          $select->set_label(get_string('selectacategory', 'question'));
 664          echo $OUTPUT->render($select);
 665          echo "</div>\n";
 666      }
 667  
 668      /**
 669       * Display the options form.
 670       * @param bool $recurse no longer used.
 671       * @param bool $showhidden no longer used.
 672       * @param bool $showquestiontext whether to show the question text.
 673       * @deprecated since Moodle 2.7 MDL-40313.
 674       * @see display_options_form
 675       * @todo MDL-41978 This will be deleted in Moodle 2.8
 676       * @see \core_question\bank\search\condition
 677       */
 678      protected function display_options($recurse, $showhidden, $showquestiontext) {
 679          debugging('display_options() is deprecated, please use display_options_form instead.', DEBUG_DEVELOPER);
 680          $this->display_options_form($showquestiontext);
 681      }
 682  
 683      /**
 684       * Print a single option checkbox.
 685       * @deprecated since Moodle 2.7 MDL-40313.
 686       * @see \core_question\bank\search\condition
 687       * @see html_writer::checkbox
 688       * @todo MDL-41978 This will be deleted in Moodle 2.8
 689       */
 690      protected function display_category_form_checkbox($name, $value, $label) {
 691          debugging('display_category_form_checkbox() is deprecated, ' .
 692                  'please use \core_question\bank\search\condition instead.', DEBUG_DEVELOPER);
 693          echo '<div><input type="hidden" id="' . $name . '_off" name="' . $name . '" value="0" />';
 694          echo '<input type="checkbox" id="' . $name . '_on" name="' . $name . '" value="1"';
 695          if ($value) {
 696              echo ' checked="checked"';
 697          }
 698          echo ' onchange="getElementById(\'displayoptions\').submit(); return true;" />';
 699          echo '<label for="' . $name . '_on">' . $label . '</label>';
 700          echo "</div>\n";
 701      }
 702  
 703      /**
 704       * Display the form with options for which questions are displayed and how they are displayed.
 705       * @param bool $showquestiontext Display the text of the question within the list.
 706       * @param string $scriptpath path to the script displaying this page.
 707       * @param bool $showtextoption whether to include the 'Show question text' checkbox.
 708       */
 709      protected function display_options_form($showquestiontext, $scriptpath = '/question/edit.php',
 710              $showtextoption = true) {
 711          global $PAGE;
 712  
 713          echo \html_writer::start_tag('form', array('method' => 'get',
 714                  'action' => new \moodle_url($scriptpath), 'id' => 'displayoptions'));
 715          echo \html_writer::start_div();
 716  
 717          $excludes = array('recurse', 'showhidden', 'qbshowtext');
 718          // If the URL contains any tags then we need to prevent them
 719          // being added to the form as hidden elements because the tags
 720          // are managed separately.
 721          if ($this->baseurl->param('qtagids[0]')) {
 722              $index = 0;
 723              while ($this->baseurl->param("qtagids[{$index}]")) {
 724                  $excludes[] = "qtagids[{$index}]";
 725                  $index++;
 726              }
 727          }
 728          echo \html_writer::input_hidden_params($this->baseurl, $excludes);
 729  
 730          foreach ($this->searchconditions as $searchcondition) {
 731              echo $searchcondition->display_options();
 732          }
 733          if ($showtextoption) {
 734              $this->display_showtext_checkbox($showquestiontext);
 735          }
 736          $this->display_advanced_search_form();
 737          $go = \html_writer::empty_tag('input', array('type' => 'submit', 'value' => get_string('go')));
 738          echo \html_writer::tag('noscript', \html_writer::div($go), array('class' => 'inline'));
 739          echo \html_writer::end_div();
 740          echo \html_writer::end_tag('form');
 741          $PAGE->requires->yui_module('moodle-question-searchform', 'M.question.searchform.init');
 742      }
 743  
 744      /**
 745       * Print the "advanced" UI elements for the form to select which questions. Hidden by default.
 746       */
 747      protected function display_advanced_search_form() {
 748          print_collapsible_region_start('', 'advancedsearch', get_string('advancedsearchoptions', 'question'),
 749                                                 'question_bank_advanced_search');
 750          foreach ($this->searchconditions as $searchcondition) {
 751              echo $searchcondition->display_options_adv();
 752          }
 753          print_collapsible_region_end();
 754      }
 755  
 756      /**
 757       * Display the checkbox UI for toggling the display of the question text in the list.
 758       * @param bool $showquestiontext the current or default value for whether to display the text.
 759       */
 760      protected function display_showtext_checkbox($showquestiontext) {
 761          echo '<div>';
 762          echo \html_writer::empty_tag('input', array('type' => 'hidden', 'name' => 'qbshowtext',
 763                                                 'value' => 0, 'id' => 'qbshowtext_off'));
 764          echo \html_writer::checkbox('qbshowtext', '1', $showquestiontext, ' ' . get_string('showquestiontext', 'question'),
 765                                         array('id' => 'qbshowtext_on', 'class' => 'searchoptions mr-1'));
 766          echo "</div>\n";
 767      }
 768  
 769      /**
 770       * Display the header element for the question bank.
 771       */
 772      protected function display_question_bank_header() {
 773          global $OUTPUT;
 774          echo $OUTPUT->heading(get_string('questionbank', 'question'), 2);
 775      }
 776  
 777      protected function create_new_question_form($category, $canadd) {
 778          echo '<div class="createnewquestion">';
 779          if ($canadd) {
 780              create_new_question_button($category->id, $this->editquestionurl->params(),
 781                      get_string('createnewquestion', 'question'));
 782          } else {
 783              print_string('nopermissionadd', 'question');
 784          }
 785          echo '</div>';
 786      }
 787  
 788      /**
 789       * Prints the table of questions in a category with interactions
 790       *
 791       * @param array      $contexts    Not used!
 792       * @param \moodle_url $pageurl     The URL to reload this page.
 793       * @param string     $categoryandcontext 'categoryID,contextID'.
 794       * @param \stdClass  $cm          Not used!
 795       * @param int        $recurse     Whether to include subcategories.
 796       * @param int        $page        The number of the page to be displayed
 797       * @param int        $perpage     Number of questions to show per page
 798       * @param bool       $showhidden  Not used! This is now controlled in a different way.
 799       * @param bool       $showquestiontext Not used! This is now controlled in a different way.
 800       * @param array      $addcontexts contexts where the user is allowed to add new questions.
 801       */
 802      protected function display_question_list($contexts, $pageurl, $categoryandcontext,
 803              $cm = null, $recurse=1, $page=0, $perpage=100, $showhidden=false,
 804              $showquestiontext = false, $addcontexts = array()) {
 805          global $OUTPUT;
 806  
 807          // This function can be moderately slow with large question counts and may time out.
 808          // We probably do not want to raise it to unlimited, so randomly picking 5 minutes.
 809          // Note: We do not call this in the loop because quiz ob_ captures this function (see raise() PHP doc).
 810          \core_php_time_limit::raise(300);
 811  
 812          $category = $this->get_current_category($categoryandcontext);
 813  
 814          list($categoryid, $contextid) = explode(',', $categoryandcontext);
 815          $catcontext = \context::instance_by_id($contextid);
 816  
 817          $canadd = has_capability('moodle/question:add', $catcontext);
 818  
 819          $this->create_new_question_form($category, $canadd);
 820  
 821          $this->build_query();
 822          $totalnumber = $this->get_question_count();
 823          if ($totalnumber == 0) {
 824              return;
 825          }
 826          $questionsrs = $this->load_page_questions($page, $perpage);
 827          $questions = [];
 828          foreach ($questionsrs as $question) {
 829              $questions[$question->id] = $question;
 830          }
 831          $questionsrs->close();
 832          foreach ($this->requiredcolumns as $name => $column) {
 833              $column->load_additional_data($questions);
 834          }
 835  
 836          echo '<div class="categorypagingbarcontainer">';
 837          $pageingurl = new \moodle_url('edit.php', $pageurl->params());
 838          $pagingbar = new \paging_bar($totalnumber, $page, $perpage, $pageingurl);
 839          $pagingbar->pagevar = 'qpage';
 840          echo $OUTPUT->render($pagingbar);
 841          echo '</div>';
 842  
 843          echo '<form method="post" action="edit.php">';
 844          echo '<fieldset class="invisiblefieldset" style="display: block;">';
 845          echo '<input type="hidden" name="sesskey" value="'.sesskey().'" />';
 846          echo \html_writer::input_hidden_params($this->baseurl);
 847  
 848          echo '<div class="categoryquestionscontainer" id="questionscontainer">';
 849          $this->start_table();
 850          $rowcount = 0;
 851          foreach ($questions as $question) {
 852              $this->print_table_row($question, $rowcount);
 853              $rowcount += 1;
 854          }
 855          $this->end_table();
 856          echo "</div>\n";
 857  
 858          echo '<div class="categorypagingbarcontainer pagingbottom">';
 859          echo $OUTPUT->render($pagingbar);
 860          if ($totalnumber > DEFAULT_QUESTIONS_PER_PAGE) {
 861              if ($perpage == DEFAULT_QUESTIONS_PER_PAGE) {
 862                  $url = new \moodle_url('edit.php', array_merge($pageurl->params(),
 863                          array('qpage' => 0, 'qperpage' => MAXIMUM_QUESTIONS_PER_PAGE)));
 864                  if ($totalnumber > MAXIMUM_QUESTIONS_PER_PAGE) {
 865                      $showall = '<a href="'.$url.'">'.get_string('showperpage', 'moodle', MAXIMUM_QUESTIONS_PER_PAGE).'</a>';
 866                  } else {
 867                      $showall = '<a href="'.$url.'">'.get_string('showall', 'moodle', $totalnumber).'</a>';
 868                  }
 869              } else {
 870                  $url = new \moodle_url('edit.php', array_merge($pageurl->params(),
 871                                                array('qperpage' => DEFAULT_QUESTIONS_PER_PAGE)));
 872                  $showall = '<a href="'.$url.'">'.get_string('showperpage', 'moodle', DEFAULT_QUESTIONS_PER_PAGE).'</a>';
 873              }
 874              echo "<div class='paging'>{$showall}</div>";
 875          }
 876          echo '</div>';
 877  
 878          $this->display_bottom_controls($totalnumber, $recurse, $category, $catcontext, $addcontexts);
 879  
 880          echo '</fieldset>';
 881          echo "</form>\n";
 882      }
 883  
 884      /**
 885       * Display the controls at the bottom of the list of questions.
 886       * @param int      $totalnumber Total number of questions that might be shown (if it was not for paging).
 887       * @param bool     $recurse     Whether to include subcategories.
 888       * @param \stdClass $category    The question_category row from the database.
 889       * @param \context  $catcontext  The context of the category being displayed.
 890       * @param array    $addcontexts contexts where the user is allowed to add new questions.
 891       */
 892      protected function display_bottom_controls($totalnumber, $recurse, $category, \context $catcontext, array $addcontexts) {
 893          $caneditall = has_capability('moodle/question:editall', $catcontext);
 894          $canuseall = has_capability('moodle/question:useall', $catcontext);
 895          $canmoveall = has_capability('moodle/question:moveall', $catcontext);
 896  
 897          echo '<div class="modulespecificbuttonscontainer">';
 898          if ($caneditall || $canmoveall || $canuseall) {
 899              echo '<strong>&nbsp;'.get_string('withselected', 'question').':</strong><br />';
 900  
 901              // Print delete and move selected question.
 902              if ($caneditall) {
 903                  echo \html_writer::empty_tag('input', [
 904                      'type' => 'submit',
 905                      'class' => 'btn btn-secondary mr-1',
 906                      'name' => 'deleteselected',
 907                      'value' => get_string('delete'),
 908                      'data-action' => 'toggle',
 909                      'data-togglegroup' => 'qbank',
 910                      'data-toggle' => 'action',
 911                      'disabled' => true,
 912                  ]);
 913              }
 914  
 915              if ($canmoveall && count($addcontexts)) {
 916                  echo \html_writer::empty_tag('input', [
 917                      'type' => 'submit',
 918                      'class' => 'btn btn-secondary mr-1',
 919                      'name' => 'move',
 920                      'value' => get_string('moveto', 'question'),
 921                      'data-action' => 'toggle',
 922                      'data-togglegroup' => 'qbank',
 923                      'data-toggle' => 'action',
 924                      'disabled' => true,
 925                  ]);
 926                  question_category_select_menu($addcontexts, false, 0, "{$category->id},{$category->contextid}");
 927              }
 928          }
 929          echo "</div>\n";
 930      }
 931  
 932      protected function start_table() {
 933          echo '<table id="categoryquestions">' . "\n";
 934          echo "<thead>\n";
 935          $this->print_table_headers();
 936          echo "</thead>\n";
 937          echo "<tbody>\n";
 938      }
 939  
 940      protected function end_table() {
 941          echo "</tbody>\n";
 942          echo "</table>\n";
 943      }
 944  
 945      protected function print_table_headers() {
 946          echo "<tr>\n";
 947          foreach ($this->visiblecolumns as $column) {
 948              $column->display_header();
 949          }
 950          echo "</tr>\n";
 951      }
 952  
 953      protected function get_row_classes($question, $rowcount) {
 954          $classes = array();
 955          if ($question->hidden) {
 956              $classes[] = 'dimmed_text';
 957          }
 958          if ($question->id == $this->lastchangedid) {
 959              $classes[] = 'highlight text-dark';
 960          }
 961          $classes[] = 'r' . ($rowcount % 2);
 962          return $classes;
 963      }
 964  
 965      protected function print_table_row($question, $rowcount) {
 966          $rowclasses = implode(' ', $this->get_row_classes($question, $rowcount));
 967          if ($rowclasses) {
 968              echo '<tr class="' . $rowclasses . '">' . "\n";
 969          } else {
 970              echo "<tr>\n";
 971          }
 972          foreach ($this->visiblecolumns as $column) {
 973              $column->display($question, $rowclasses);
 974          }
 975          echo "</tr>\n";
 976          foreach ($this->extrarows as $row) {
 977              $row->display($question, $rowclasses);
 978          }
 979      }
 980  
 981      public function process_actions() {
 982          global $DB;
 983          // Now, check for commands on this page and modify variables as necessary.
 984          if (optional_param('move', false, PARAM_BOOL) and confirm_sesskey()) {
 985              // Move selected questions to new category.
 986              $category = required_param('category', PARAM_SEQUENCE);
 987              list($tocategoryid, $contextid) = explode(',', $category);
 988              if (! $tocategory = $DB->get_record('question_categories', array('id' => $tocategoryid, 'contextid' => $contextid))) {
 989                  print_error('cannotfindcate', 'question');
 990              }
 991              $tocontext = \context::instance_by_id($contextid);
 992              require_capability('moodle/question:add', $tocontext);
 993              $rawdata = (array) data_submitted();
 994              $questionids = array();
 995              foreach ($rawdata as $key => $value) {  // Parse input for question ids.
 996                  if (preg_match('!^q([0-9]+)$!', $key, $matches)) {
 997                      $key = $matches[1];
 998                      $questionids[] = $key;
 999                  }
1000              }
1001              if ($questionids) {
1002                  list($usql, $params) = $DB->get_in_or_equal($questionids);
1003                  $questions = $DB->get_records_sql("
1004                          SELECT q.*, c.contextid
1005                          FROM {question} q
1006                          JOIN {question_categories} c ON c.id = q.category
1007                          WHERE q.id {$usql}", $params);
1008                  foreach ($questions as $question) {
1009                      question_require_capability_on($question, 'move');
1010                  }
1011                  question_move_questions_to_category($questionids, $tocategory->id);
1012                  redirect($this->baseurl->out(false,
1013                          array('category' => "{$tocategoryid},{$contextid}")));
1014              }
1015          }
1016  
1017          if (optional_param('deleteselected', false, PARAM_BOOL)) { // Delete selected questions from the category.
1018              // If teacher has already confirmed the action.
1019              if (($confirm = optional_param('confirm', '', PARAM_ALPHANUM)) and confirm_sesskey()) {
1020                  $deleteselected = required_param('deleteselected', PARAM_RAW);
1021                  if ($confirm == md5($deleteselected)) {
1022                      if ($questionlist = explode(',', $deleteselected)) {
1023                          // For each question either hide it if it is in use or delete it.
1024                          foreach ($questionlist as $questionid) {
1025                              $questionid = (int)$questionid;
1026                              question_require_capability_on($questionid, 'edit');
1027                              if (questions_in_use(array($questionid))) {
1028                                  $DB->set_field('question', 'hidden', 1, array('id' => $questionid));
1029                              } else {
1030                                  question_delete_question($questionid);
1031                              }
1032                          }
1033                      }
1034                      redirect($this->baseurl);
1035                  } else {
1036                      print_error('invalidconfirm', 'question');
1037                  }
1038              }
1039          }
1040  
1041          // Unhide a question.
1042          if (($unhide = optional_param('unhide', '', PARAM_INT)) and confirm_sesskey()) {
1043              question_require_capability_on($unhide, 'edit');
1044              $DB->set_field('question', 'hidden', 0, array('id' => $unhide));
1045  
1046              // Purge these questions from the cache.
1047              \question_bank::notify_question_edited($unhide);
1048  
1049              redirect($this->baseurl);
1050          }
1051      }
1052  
1053      public function process_actions_needing_ui() {
1054          global $DB, $OUTPUT;
1055          if (optional_param('deleteselected', false, PARAM_BOOL)) {
1056              // Make a list of all the questions that are selected.
1057              $rawquestions = $_REQUEST; // This code is called by both POST forms and GET links, so cannot use data_submitted.
1058              $questionlist = '';  // comma separated list of ids of questions to be deleted
1059              $questionnames = ''; // string with names of questions separated by <br /> with
1060                                   // an asterix in front of those that are in use
1061              $inuse = false;      // set to true if at least one of the questions is in use
1062              foreach ($rawquestions as $key => $value) {    // Parse input for question ids.
1063                  if (preg_match('!^q([0-9]+)$!', $key, $matches)) {
1064                      $key = $matches[1];
1065                      $questionlist .= $key.',';
1066                      question_require_capability_on((int)$key, 'edit');
1067                      if (questions_in_use(array($key))) {
1068                          $questionnames .= '* ';
1069                          $inuse = true;
1070                      }
1071                      $questionnames .= $DB->get_field('question', 'name', array('id' => $key)) . '<br />';
1072                  }
1073              }
1074              if (!$questionlist) { // No questions were selected.
1075                  redirect($this->baseurl);
1076              }
1077              $questionlist = rtrim($questionlist, ',');
1078  
1079              // Add an explanation about questions in use.
1080              if ($inuse) {
1081                  $questionnames .= '<br />'.get_string('questionsinuse', 'question');
1082              }
1083              $baseurl = new \moodle_url('edit.php', $this->baseurl->params());
1084              $deleteurl = new \moodle_url($baseurl, array('deleteselected' => $questionlist, 'confirm' => md5($questionlist),
1085                                                   'sesskey' => sesskey()));
1086  
1087              $continue = new \single_button($deleteurl, get_string('delete'), 'post');
1088              echo $OUTPUT->confirm(get_string('deletequestionscheck', 'question', $questionnames), $continue, $baseurl);
1089  
1090              return true;
1091          }
1092  
1093          return false;
1094      }
1095  
1096      /**
1097       * Add another search control to this view.
1098       * @param condition $searchcondition the condition to add.
1099       */
1100      public function add_searchcondition($searchcondition) {
1101          $this->searchconditions[] = $searchcondition;
1102      }
1103  }