Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

Differences Between: [Versions 400 and 403] [Versions 401 and 403] [Versions 402 and 403]

   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   * Class to print a view of the question bank.
  19   *
  20   * @package   core_question
  21   * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
  22   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  namespace core_question\local\bank;
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  require_once($CFG->dirroot . '/question/editlib.php');
  30  
  31  use core\plugininfo\qbank;
  32  use core\output\datafilter;
  33  use core_plugin_manager;
  34  use core_question\local\bank\condition;
  35  use core_question\local\statistics\statistics_bulk_loader;
  36  use core_question\output\question_bank_filter_ui;
  37  use core_question\local\bank\column_manager_base;
  38  use qbank_deletequestion\hidden_condition;
  39  use qbank_editquestion\editquestion_helper;
  40  use qbank_managecategories\category_condition;
  41  
  42  /**
  43   * This class prints a view of the question bank.
  44   *
  45   * including
  46   *  + Some controls to allow users to to select what is displayed.
  47   *  + A list of questions as a table.
  48   *  + Further controls to do things with the questions.
  49   *
  50   * This class gives a basic view, and provides plenty of hooks where subclasses
  51   * can override parts of the display.
  52   *
  53   * The list of questions presented as a table is generated by creating a list of
  54   * core_question\bank\column objects, one for each 'column' to be displayed. These
  55   * manage
  56   *  + outputting the contents of that column, given a $question object, but also
  57   *  + generating the right fragments of SQL to ensure the necessary data is present,
  58   *    and sorted in the right order.
  59   *  + outputting table headers.
  60   *
  61   * @copyright 2009 Tim Hunt
  62   * @author    2021 Safat Shahin <safatshahin@catalyst-au.net>
  63   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  64   */
  65  class view {
  66  
  67      /**
  68       * Maximum number of sorts allowed.
  69       */
  70      const MAX_SORTS = 3;
  71  
  72      /**
  73       * @var \moodle_url base URL for the current page. Used as the
  74       * basis for making URLs for actions that reload the page.
  75       */
  76      protected $baseurl;
  77  
  78      /**
  79       * @var \moodle_url used as a basis for URLs that edit a question.
  80       */
  81      protected $editquestionurl;
  82  
  83      /**
  84       * @var \core_question\local\bank\question_edit_contexts
  85       */
  86      public $contexts;
  87  
  88      /**
  89       * @var object|\cm_info|null if we are in a module context, the cm.
  90       */
  91      public $cm;
  92  
  93      /**
  94       * @var object the course we are within.
  95       */
  96      public $course;
  97  
  98      /**
  99       * @var column_base[] these are all the 'columns' that are
 100       * part of the display. Array keys are the class name.
 101       */
 102      protected $requiredcolumns;
 103  
 104      /**
 105       * @var question_action_base[] these are all the actions that can be displayed in a question's action menu.
 106       *
 107       * Array keys are the class name.
 108       */
 109      protected $questionactions;
 110  
 111      /**
 112       * @var column_base[] these are the 'columns' that are
 113       * actually displayed as a column, in order. Array keys are the class name.
 114       */
 115      protected $visiblecolumns;
 116  
 117      /**
 118       * @var column_base[] these are the 'columns' that are
 119       * common to the question bank.
 120       */
 121      protected $corequestionbankcolumns;
 122  
 123      /**
 124       * @var column_base[] these are the 'columns' that are
 125       * actually displayed as an additional row (e.g. question text), in order.
 126       * Array keys are the class name.
 127       */
 128      protected $extrarows;
 129  
 130      /**
 131       * @var array list of column class names for which columns to sort on.
 132       */
 133      protected $sort;
 134  
 135      /**
 136       * @var int page size to use (when we are not showing all questions).
 137       */
 138      protected $pagesize = DEFAULT_QUESTIONS_PER_PAGE;
 139  
 140      /**
 141       * @var int|null id of the a question to highlight in the list (if present).
 142       */
 143      protected $lastchangedid;
 144  
 145      /**
 146       * @var string SQL to count the number of questions matching the current
 147       * search conditions.
 148       */
 149      protected $countsql;
 150  
 151      /**
 152       * @var string SQL to actually load the question data to display.
 153       */
 154      protected $loadsql;
 155  
 156      /**
 157       * @var array params used by $countsql and $loadsql (which currently must be the same).
 158       */
 159      protected $sqlparams;
 160  
 161      /**
 162       * @var ?array Stores all the average statistics that this question bank view needs.
 163       *
 164       * This field gets initialised in {@see display_question_list()}. It is a two dimensional
 165       * $this->loadedstatistics[$questionid][$fieldname] = $average value of that statistics for that question.
 166       * Column classes in qbank plugins can access these values using {@see get_aggregate_statistic()}.
 167       */
 168      protected $loadedstatistics = null;
 169  
 170      /**
 171       * @var condition[] search conditions.
 172       */
 173      protected $searchconditions = [];
 174  
 175      /**
 176       * @var string url of the new question page.
 177       */
 178      public $returnurl;
 179  
 180      /**
 181       * @var array $bulkactions to identify the bulk actions for the api.
 182       */
 183      public $bulkactions = [];
 184  
 185      /**
 186       * @var int|null Number of questions.
 187       */
 188      protected $totalcount = null;
 189  
 190      /**
 191       * @var array Parameters for the page URL.
 192       */
 193      protected $pagevars = [];
 194  
 195      /**
 196       * @var plugin_features_base[] $plugins Plugin feature objects for all enabled qbank plugins.
 197       */
 198      protected $plugins = [];
 199  
 200      /**
 201       * @var string $component the component the api is used from.
 202       */
 203      public $component = 'core_question';
 204  
 205      /**
 206       * @var string $callback name of the callback for the api call via filter js.
 207       */
 208      public $callback = 'question_data';
 209  
 210      /**
 211       * @var array $extraparams extra parameters for the extended apis.
 212       */
 213      public $extraparams = [];
 214  
 215      /**
 216       * @var column_manager_base $columnmanager The column manager, can be overridden by plugins.
 217       */
 218      protected $columnmanager;
 219  
 220      /**
 221       * Constructor for view.
 222       *
 223       * @param \core_question\local\bank\question_edit_contexts $contexts
 224       * @param \moodle_url $pageurl
 225       * @param object $course course settings
 226       * @param null $cm (optional) activity settings.
 227       * @param array $params the parameters required to initialize the api.
 228       * @param array $extraparams any extra parameters required by a particular view class.
 229       */
 230      public function __construct($contexts, $pageurl, $course, $cm = null, $params = [], $extraparams = []) {
 231          $this->contexts = $contexts;
 232          $this->baseurl = $pageurl;
 233          $this->course = $course;
 234          $this->cm = $cm;
 235          $this->extraparams = $extraparams;
 236  
 237          // Default filter condition.
 238          if (!isset($params['filter']) && isset($params['cat'])) {
 239              $params['filter']  = [];
 240              [$categoryid, $contextid] = category_condition::validate_category_param($params['cat']);
 241              if (!is_null($categoryid)) {
 242                  $category = category_condition::get_category_record($categoryid, $contextid);
 243                  $params['filter']['category'] = [
 244                      'jointype' => category_condition::JOINTYPE_DEFAULT,
 245                      'values' => [$category->id],
 246                      'filteroptions' => ['includesubcategories' => false],
 247                  ];
 248              }
 249              $params['filter']['hidden'] = [
 250                  'jointype' => hidden_condition::JOINTYPE_DEFAULT,
 251                  'values' => [0],
 252              ];
 253              $params['jointype'] = datafilter::JOINTYPE_ALL;
 254          }
 255          if (!empty($params['filter'])) {
 256              $params['filter'] = filter_condition_manager::unpack_filteroptions_param($params['filter']);
 257          }
 258          if (isset($params['filter']['jointype'])) {
 259              $params['jointype'] = $params['filter']['jointype'];
 260              unset($params['filter']['jointype']);
 261          }
 262  
 263          // Create the url of the new question page to forward to.
 264          $this->returnurl = $pageurl->out_as_local_url(false);
 265          $this->editquestionurl = new \moodle_url('/question/bank/editquestion/question.php', ['returnurl' => $this->returnurl]);
 266          if ($this->cm !== null) {
 267              $this->editquestionurl->param('cmid', $this->cm->id);
 268          } else {
 269              $this->editquestionurl->param('courseid', $this->course->id);
 270          }
 271  
 272          $this->lastchangedid = clean_param($pageurl->param('lastchanged'), PARAM_INT);
 273  
 274          $this->init_plugins();
 275          $this->init_column_manager();
 276          // Possibly the heading part can be removed.
 277          $this->set_pagevars($params);
 278          $this->init_columns($this->wanted_columns(), $this->heading_column());
 279          $this->init_question_actions();
 280          $this->init_sort();
 281          $this->init_bulk_actions();
 282      }
 283  
 284      /**
 285       * Get an array of plugin features objects for all enabled qbank plugins.
 286       *
 287       * @return void
 288       */
 289      protected function init_plugins(): void {
 290          $plugins = \core_component::get_plugin_list_with_class('qbank', 'plugin_feature', 'plugin_feature.php');
 291          foreach ($plugins as $componentname => $pluginclass) {
 292              if (!\core\plugininfo\qbank::is_plugin_enabled($componentname)) {
 293                  continue;
 294              }
 295              $this->plugins[$componentname] = new $pluginclass();
 296          }
 297          // Sort plugin list by component name.
 298          ksort($this->plugins);
 299      }
 300  
 301      /**
 302       * Allow qbank plugins to override the column manager.
 303       *
 304       * If multiple qbank plugins define a column manager, this will pick the first one sorted alphabetically.
 305       *
 306       * @return void
 307       */
 308      protected function init_column_manager(): void {
 309          $this->columnmanager = new column_manager_base();
 310          foreach ($this->plugins as $plugin) {
 311              if ($columnmanager = $plugin->get_column_manager()) {
 312                  $this->columnmanager = $columnmanager;
 313                  break;
 314              }
 315          }
 316      }
 317  
 318      /**
 319       * Initialize bulk actions.
 320       */
 321      protected function init_bulk_actions(): void {
 322          foreach ($this->plugins as $componentname => $plugin) {
 323              $bulkactions = $plugin->get_bulk_actions();
 324              if (!is_array($bulkactions)) {
 325                  debugging("The method {$componentname}::get_bulk_actions() must return an " .
 326                      "array of bulk actions instead of a single bulk action. " .
 327                      "Please update your implementation of get_bulk_actions() to return an array. " .
 328                      "Check out the qbank_bulkmove plugin for a working example.", DEBUG_DEVELOPER);
 329                  $bulkactions = [$bulkactions];
 330              }
 331  
 332              foreach ($bulkactions as $bulkactionobject) {
 333                  $this->bulkactions[$bulkactionobject->get_key()] = [
 334                      'title' => $bulkactionobject->get_bulk_action_title(),
 335                      'url' => $bulkactionobject->get_bulk_action_url(),
 336                      'capabilities' => $bulkactionobject->get_bulk_action_capabilities()
 337                  ];
 338              }
 339          }
 340      }
 341  
 342      /**
 343       * Initialize search conditions from plugins
 344       * local_*_get_question_bank_search_conditions() must return an array of
 345       * \core_question\bank\search\condition objects.
 346       *
 347       * @deprecated Since Moodle 4.3
 348       * @todo Final deprecation on Moodle 4.7 MDL-78090
 349       */
 350      protected function init_search_conditions(): void {
 351          debugging(
 352              'Function init_search_conditions() has been deprecated, please create a qbank plugin' .
 353                  'and implement a filter object instead.',
 354              DEBUG_DEVELOPER
 355          );
 356          $searchplugins = get_plugin_list_with_function('local', 'get_question_bank_search_conditions');
 357          foreach ($searchplugins as $component => $function) {
 358              foreach ($function($this) as $searchobject) {
 359                  $this->add_searchcondition($searchobject);
 360              }
 361          }
 362      }
 363  
 364      /**
 365       * Initialise list of menu actions for enabled question bank plugins.
 366       *
 367       * Menu action objects are stored in $this->menuactions, keyed by class name.
 368       *
 369       * @return void
 370       */
 371      protected function init_question_actions(): void {
 372          $this->questionactions = [];
 373          foreach ($this->plugins as $plugin) {
 374              $menuactions = $plugin->get_question_actions($this);
 375              foreach ($menuactions as $menuaction) {
 376                  $this->questionactions[$menuaction::class] = $menuaction;
 377              }
 378          }
 379      }
 380  
 381      /**
 382       * Get class for each question bank columns.
 383       *
 384       * @return array
 385       */
 386      protected function get_question_bank_plugins(): array {
 387          $questionbankclasscolumns = [];
 388          $newpluginclasscolumns = [];
 389          $corequestionbankcolumns = [
 390              'core_question\local\bank\checkbox_column' . column_base::ID_SEPARATOR . 'checkbox_column',
 391              'core_question\local\bank\edit_menu_column' . column_base::ID_SEPARATOR . 'edit_menu_column',
 392          ];
 393  
 394          foreach ($corequestionbankcolumns as $columnid) {
 395              [$columnclass, $columnname] = explode(column_base::ID_SEPARATOR, $columnid, 2);
 396              if (class_exists($columnclass)) {
 397                  $questionbankclasscolumns[$columnid] = $columnclass::from_column_name($this, $columnname);
 398              }
 399          }
 400  
 401          foreach ($this->plugins as $plugin) {
 402              $plugincolumnobjects = $plugin->get_question_columns($this);
 403              foreach ($plugincolumnobjects as $columnobject) {
 404                  $columnid = $columnobject->get_column_id();
 405                  foreach ($corequestionbankcolumns as $corequestionbankcolumn) {
 406                      // Check if it has custom preference selector to view/hide.
 407                      if ($columnobject->has_preference()) {
 408                          if (!$columnobject->get_preference()) {
 409                              continue;
 410                          }
 411                      }
 412                      if ($corequestionbankcolumn === $columnid) {
 413                          $questionbankclasscolumns[$columnid] = $columnobject;
 414                      } else {
 415                          // Any community plugin for column/action.
 416                          $newpluginclasscolumns[$columnid] = $columnobject;
 417                      }
 418                  }
 419              }
 420          }
 421  
 422          // New plugins added at the end of the array, will change in sorting feature.
 423          foreach ($newpluginclasscolumns as $key => $newpluginclasscolumn) {
 424              $questionbankclasscolumns[$key] = $newpluginclasscolumn;
 425          }
 426  
 427          $questionbankclasscolumns = $this->columnmanager->get_sorted_columns($questionbankclasscolumns);
 428          $questionbankclasscolumns = $this->columnmanager->set_columns_visibility($questionbankclasscolumns);
 429  
 430          // Mitigate the error in case of any regression.
 431          foreach ($questionbankclasscolumns as $shortname => $questionbankclasscolumn) {
 432              if (!is_object($questionbankclasscolumn) || !$questionbankclasscolumn->isvisible) {
 433                  unset($questionbankclasscolumns[$shortname]);
 434              }
 435          }
 436  
 437          return $questionbankclasscolumns;
 438      }
 439  
 440      /**
 441       * Loads all the available columns.
 442       *
 443       * @return array
 444       */
 445      protected function wanted_columns(): array {
 446          $this->requiredcolumns = [];
 447          $questionbankcolumns = $this->get_question_bank_plugins();
 448          foreach ($questionbankcolumns as $classobject) {
 449              if (empty($classobject) || !($classobject instanceof \core_question\local\bank\column_base)) {
 450                  continue;
 451              }
 452              $this->requiredcolumns[$classobject->get_column_name()] = $classobject;
 453          }
 454  
 455          return $this->requiredcolumns;
 456      }
 457  
 458  
 459      /**
 460       * Check a column object from its name and get the object for sort.
 461       *
 462       * @param string $columnname
 463       */
 464      protected function get_column_type($columnname) {
 465          if (empty($this->requiredcolumns[$columnname])) {
 466              $this->requiredcolumns[$columnname] = new $columnname($this);
 467          }
 468      }
 469  
 470      /**
 471       * Specify the column heading
 472       *
 473       * @return string Column name for the heading
 474       */
 475      protected function heading_column(): string {
 476          return 'qbank_viewquestionname\viewquestionname_column_helper';
 477      }
 478  
 479      /**
 480       * Initializing table columns
 481       *
 482       * @param array $wanted Collection of column names
 483       * @param string $heading The name of column that is set as heading
 484       */
 485      protected function init_columns($wanted, $heading = ''): void {
 486          // Now split columns into real columns and rows.
 487          $this->visiblecolumns = [];
 488          $this->extrarows = [];
 489          foreach ($wanted as $column) {
 490              if ($column->is_extra_row()) {
 491                  $this->extrarows[$column->get_column_name()] = $column;
 492              } else {
 493                  // Only add columns which are visible.
 494                  if ($column->isvisible) {
 495                      $this->visiblecolumns[$column->get_column_name()] = $column;
 496                  }
 497              }
 498          }
 499  
 500          if (array_key_exists($heading, $this->requiredcolumns)) {
 501              $this->requiredcolumns[$heading]->set_as_heading();
 502          }
 503      }
 504  
 505      /**
 506       * Checks if the column included in the output.
 507       *
 508       * @param string $colname a column internal name.
 509       * @return bool is this column included in the output?
 510       */
 511      public function has_column($colname): bool {
 512          return isset($this->visiblecolumns[$colname]);
 513      }
 514  
 515      /**
 516       * Get the count of the columns.
 517       *
 518       * @return int The number of columns in the table.
 519       */
 520      public function get_column_count(): int {
 521          return count($this->visiblecolumns);
 522      }
 523  
 524      /**
 525       * Get course id.
 526       * @return mixed
 527       */
 528      public function get_courseid() {
 529          return $this->course->id;
 530      }
 531  
 532      /**
 533       * Initialise sorting.
 534       */
 535      protected function init_sort(): void {
 536          $this->sort = [];
 537          $sorts = optional_param_array('sortdata', [], PARAM_INT);
 538          if (empty($sorts)) {
 539              $sorts = $this->get_pagevars('sortdata');
 540          }
 541          if (empty($sorts)) {
 542              $sorts = $this->default_sort();
 543          }
 544          $sorts = array_slice($sorts, 0, self::MAX_SORTS);
 545          foreach ($sorts as $sortname => $sortorder) {
 546              // Deal with subsorts.
 547              [$colname] = $this->parse_subsort($sortname);
 548              $this->get_column_type($colname);
 549          }
 550          $this->sort = $sorts;
 551      }
 552  
 553      /**
 554       * Deal with a sort name of the form columnname, or colname_subsort by
 555       * breaking it up, validating the bits that are present, and returning them.
 556       * If there is no subsort, then $subsort is returned as ''.
 557       *
 558       * @param string $sort the sort parameter to process.
 559       * @return array [$colname, $subsort].
 560       */
 561      protected function parse_subsort($sort): array {
 562          // Do the parsing.
 563          if (strpos($sort, '-') !== false) {
 564              list($colname, $subsort) = explode('-', $sort, 2);
 565          } else {
 566              $colname = $sort;
 567              $subsort = '';
 568          }
 569          $colname = str_replace('__', '\\', $colname);
 570          // Validate the column name.
 571          $this->get_column_type($colname);
 572          $column = $this->requiredcolumns[$colname];
 573          if (!isset($column) || !$column->is_sortable()) {
 574              $this->baseurl->remove_params('sortdata');
 575              throw new \moodle_exception('unknownsortcolumn', '', $this->baseurl->out(), $colname);
 576          }
 577          // Validate the subsort, if present.
 578          if ($subsort) {
 579              $subsorts = $column->is_sortable();
 580              if (!is_array($subsorts) || !isset($subsorts[$subsort])) {
 581                  throw new \moodle_exception('unknownsortcolumn', '', $this->baseurl->out(), $sort);
 582              }
 583          }
 584          return [$colname, $subsort];
 585      }
 586  
 587      /**
 588       * Sort to parameters.
 589       *
 590       * @param array $sorts
 591       * @return array
 592       */
 593      protected function sort_to_params($sorts): array {
 594          $params = [];
 595          foreach ($sorts as $sortname => $sortorder) {
 596              $params['sortdata[' . $sortname . ']'] = $sortorder;
 597          }
 598          return $params;
 599      }
 600  
 601      /**
 602       * Default sort for question data.
 603       * @return int[]
 604       */
 605      protected function default_sort(): array {
 606          $defaultsort = [];
 607          if (class_exists('\\qbank_viewquestiontype\\question_type_column')) {
 608              $defaultsort['qbank_viewquestiontype__question_type_column'] = SORT_ASC;
 609          }
 610          if (class_exists('\\qbank_viewquestionname\\question_name_idnumber_tags_column')) {
 611              $defaultsort['qbank_viewquestionname__question_name_idnumber_tags_column-name'] = SORT_ASC;
 612          }
 613  
 614          return $defaultsort;
 615      }
 616  
 617      /**
 618       * Gets the primary sort order according to the default sort.
 619       *
 620       * @param string $sortname a column or column_subsort name.
 621       * @return int the current sort order for this column -1, 0, 1
 622       */
 623      public function get_primary_sort_order($sortname): int {
 624          $order = reset($this->sort);
 625          $primarysort = key($this->sort);
 626          if ($sortname == $primarysort) {
 627              return $order;
 628          }
 629  
 630          return 0;
 631      }
 632  
 633      /**
 634       * Get a URL to redisplay the page with a new sort for the question bank.
 635       *
 636       * @param string $sortname the column, or column_subsort to sort on.
 637       * @param bool $newsortreverse whether to sort in reverse order.
 638       * @return string The new URL.
 639       */
 640      public function new_sort_url($sortname, $newsortreverse): string {
 641          // Tricky code to add the new sort at the start, removing it from where it was before, if it was present.
 642          $newsort = array_reverse($this->sort);
 643          if (isset($newsort[$sortname])) {
 644              unset($newsort[$sortname]);
 645          }
 646          $newsort[$sortname] = $newsortreverse ? SORT_DESC : SORT_ASC;
 647          $newsort = array_reverse($newsort);
 648          if (count($newsort) > self::MAX_SORTS) {
 649              $newsort = array_slice($newsort, 0, self::MAX_SORTS, true);
 650          }
 651          return $this->baseurl->out(true, $this->sort_to_params($newsort));
 652      }
 653  
 654      /**
 655       * Return an array 'table_alias' => 'JOIN clause' to bring in any data that
 656       * the core view requires.
 657       *
 658       * @return string[] 'table_alias' => 'JOIN clause'
 659       */
 660      protected function get_required_joins(): array {
 661          return [
 662              'qv' => 'JOIN {question_versions} qv ON qv.questionid = q.id',
 663              'qbe' => 'JOIN {question_bank_entries} qbe on qbe.id = qv.questionbankentryid',
 664              'qc' => 'JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid',
 665          ];
 666      }
 667  
 668      /**
 669       * Return an array of fields for any data that the core view requires.
 670       *
 671       * Use table alias 'q' for the question table, or one of the ones from get_required_joins.
 672       * Every field requested must specify a table prefix.
 673       *
 674       * @return string[] fields required.
 675       */
 676      protected function get_required_fields(): array {
 677          return [
 678              'q.id',
 679              'q.qtype',
 680              'q.createdby',
 681              'qc.id as categoryid',
 682              'qc.contextid',
 683              'qv.status',
 684              'qv.version',
 685              'qv.id as versionid',
 686              'qbe.id as questionbankentryid',
 687          ];
 688      }
 689  
 690      /**
 691       * Gather query requirements from view component objects.
 692       *
 693       * This will take the required fields and joins for this view, and combine them with those for all active view components.
 694       * Fields will be de-duplicated in multiple components require the same field.
 695       * Joins will be de-duplicated if the alias and join clause match exactly.
 696       *
 697       * @throws \coding_exception If two components attempt to use the same alias for different joins.
 698       * @param view_component[] $viewcomponents List of component objects included in the current view
 699       * @return array [$fields, $joins] SQL fields and joins to add to the query.
 700       */
 701      protected function get_component_requirements(array $viewcomponents): array {
 702          $fields = $this->get_required_fields();
 703          $joins = $this->get_required_joins();
 704          if (!empty($viewcomponents)) {
 705              foreach ($viewcomponents as $viewcomponent) {
 706                  $extrajoins = $viewcomponent->get_extra_joins();
 707                  foreach ($extrajoins as $prefix => $join) {
 708                      if (isset($joins[$prefix]) && $joins[$prefix] != $join) {
 709                          throw new \coding_exception('Join ' . $join . ' conflicts with previous join ' . $joins[$prefix]);
 710                      }
 711                      $joins[$prefix] = $join;
 712                  }
 713                  $fields = array_merge($fields, $viewcomponent->get_required_fields());
 714              }
 715          }
 716          return [array_unique($fields), $joins];
 717      }
 718  
 719      /**
 720       * Create the SQL query to retrieve the indicated questions, based on
 721       * \core_question\bank\search\condition filters.
 722       */
 723      protected function build_query(): void {
 724          // Get the required tables and fields.
 725          [$fields, $joins] = $this->get_component_requirements(array_merge($this->requiredcolumns, $this->questionactions));
 726  
 727          // Build the order by clause.
 728          $sorts = [];
 729          foreach ($this->sort as $sortname => $sortorder) {
 730              [$colname, $subsort] = $this->parse_subsort($sortname);
 731              $sorts[] = $this->requiredcolumns[$colname]->sort_expression($sortorder == SORT_DESC, $subsort);
 732          }
 733  
 734          // Build the where clause.
 735          $latestversion = 'qv.version = (SELECT MAX(v.version)
 736                                            FROM {question_versions} v
 737                                            JOIN {question_bank_entries} be
 738                                              ON be.id = v.questionbankentryid
 739                                           WHERE be.id = qbe.id)';
 740          $this->sqlparams = [];
 741          $conditions = [];
 742          foreach ($this->searchconditions as $searchcondition) {
 743              if ($searchcondition->where()) {
 744                  $conditions[] = '((' . $searchcondition->where() .'))';
 745              }
 746              if ($searchcondition->params()) {
 747                  $this->sqlparams = array_merge($this->sqlparams, $searchcondition->params());
 748              }
 749          }
 750          // Get higher level filter condition.
 751          $jointype = isset($this->pagevars['jointype']) ? (int)$this->pagevars['jointype'] : condition::JOINTYPE_DEFAULT;
 752          $nonecondition = ($jointype === datafilter::JOINTYPE_NONE) ? ' NOT ' : '';
 753          $separator = ($jointype === datafilter::JOINTYPE_ALL) ? ' AND ' : ' OR ';
 754          // Build the SQL.
 755          $sql = ' FROM {question} q ' . implode(' ', $joins);
 756          $sql .= ' WHERE q.parent = 0 AND ' . $latestversion;
 757          if (!empty($conditions)) {
 758              $sql .= ' AND ' . $nonecondition . ' ( ';
 759              $sql .= implode($separator, $conditions);
 760              $sql .= ' ) ';
 761          }
 762          $this->countsql = 'SELECT count(1)' . $sql;
 763          $this->loadsql = 'SELECT ' . implode(', ', $fields) . $sql . ' ORDER BY ' . implode(', ', $sorts);
 764      }
 765  
 766      /**
 767       * Get the number of questions.
 768       *
 769       * @return int
 770       */
 771      public function get_question_count(): int {
 772          global $DB;
 773          if (is_null($this->totalcount)) {
 774              $this->totalcount = $DB->count_records_sql($this->countsql, $this->sqlparams);
 775          }
 776          return $this->totalcount;
 777      }
 778  
 779      /**
 780       * Load the questions we need to display.
 781       *
 782       * @return \moodle_recordset questionid => data about each question.
 783       */
 784      protected function load_page_questions(): \moodle_recordset {
 785          global $DB;
 786          $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams,
 787              (int)$this->pagevars['qpage'] * (int)$this->pagevars['qperpage'], $this->pagevars['qperpage']);
 788          if (empty($questions)) {
 789              $questions->close();
 790              // No questions on this page. Reset to page 0.
 791              $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, 0, $this->pagevars['qperpage']);
 792          }
 793          return $questions;
 794      }
 795  
 796      /**
 797       * Returns the base url.
 798       *
 799       * @return \moodle_url
 800       */
 801      public function base_url(): \moodle_url {
 802          return $this->baseurl;
 803      }
 804  
 805      /**
 806       * Get the URL for editing a question as a moodle url.
 807       *
 808       * @param int $questionid the question id.
 809       * @return \moodle_url the URL, HTML-escaped.
 810       */
 811      public function edit_question_moodle_url($questionid) {
 812          return new \moodle_url($this->editquestionurl, ['id' => $questionid]);
 813      }
 814  
 815      /**
 816       * Get the URL for editing a question as a HTML-escaped string.
 817       *
 818       * @param int $questionid the question id.
 819       * @return string the URL, HTML-escaped.
 820       */
 821      public function edit_question_url($questionid) {
 822          return $this->edit_question_moodle_url($questionid)->out();
 823      }
 824  
 825      /**
 826       * Get the URL for duplicating a question as a moodle url.
 827       *
 828       * @param int $questionid the question id.
 829       * @return \moodle_url the URL.
 830       */
 831      public function copy_question_moodle_url($questionid) {
 832          return new \moodle_url($this->editquestionurl, ['id' => $questionid, 'makecopy' => 1]);
 833      }
 834  
 835      /**
 836       * Get the URL for duplicating a given question.
 837       * @param int $questionid the question id.
 838       * @return string the URL, HTML-escaped.
 839       */
 840      public function copy_question_url($questionid) {
 841          return $this->copy_question_moodle_url($questionid)->out();
 842      }
 843  
 844      /**
 845       * Get the context we are displaying the question bank for.
 846       * @return \context context object.
 847       */
 848      public function get_most_specific_context(): \context {
 849          return $this->contexts->lowest();
 850      }
 851  
 852      /**
 853       * Get the URL to preview a question.
 854       * @param \stdClass $questiondata the data defining the question.
 855       * @return \moodle_url the URL.
 856       * @deprecated since Moodle 4.0
 857       * @see \qbank_previewquestion\helper::question_preview_url()
 858       * @todo Final deprecation on Moodle 4.4 MDL-72438
 859       */
 860      public function preview_question_url($questiondata) {
 861          debugging(
 862              'Function preview_question_url() has been deprecated and moved to qbank_previewquestion plugin, ' .
 863                  'please use qbank_previewquestion\helper::question_preview_url() instead.',
 864              DEBUG_DEVELOPER
 865          );
 866          return question_preview_url($questiondata->id, null, null, null, null,
 867              $this->get_most_specific_context());
 868      }
 869  
 870      /**
 871       * Get fields from the pagevars array.
 872       *
 873       * If a field is specified, that particlar pagevars field will be returned. Otherwise the entire array will be returned.
 874       *
 875       * If a field is specified but it does not exist, null will be returned.
 876       *
 877       * @param ?string $field
 878       * @return mixed
 879       */
 880      public function get_pagevars(?string $field = null): mixed {
 881          if (is_null($field)) {
 882              return $this->pagevars;
 883          } else {
 884              return $this->pagevars[$field] ?? null;
 885          }
 886      }
 887  
 888      /**
 889       * Set the pagevars property with the provided array.
 890       *
 891       * @param array $pagevars
 892       */
 893      public function set_pagevars(array $pagevars): void {
 894          $this->pagevars = $pagevars;
 895      }
 896  
 897      /**
 898       * Shows the question bank interface.
 899       */
 900      public function display(): void {
 901          $editcontexts = $this->contexts->having_one_edit_tab_cap('questions');
 902  
 903          echo \html_writer::start_div('questionbankwindow boxwidthwide boxaligncenter', [
 904              'data-component' => 'core_question',
 905              'data-callback' => 'display_question_bank',
 906              'data-contextid' => $editcontexts[array_key_last($editcontexts)]->id,
 907          ]);
 908  
 909          // Show the filters and search options.
 910          $this->wanted_filters();
 911          // Continues with list of questions.
 912          $this->display_question_list();
 913          echo \html_writer::end_div();
 914  
 915      }
 916  
 917      /**
 918       * The filters for the question bank.
 919       */
 920      public function wanted_filters(): void {
 921          global $OUTPUT;
 922          [, $contextid] = explode(',', $this->pagevars['cat']);
 923          $catcontext = \context::instance_by_id($contextid);
 924          // Category selection form.
 925          $this->display_question_bank_header();
 926          // Add search conditions.
 927          $this->add_standard_search_conditions();
 928          // Render the question bank filters.
 929          $additionalparams = [
 930              'perpage' => $this->pagevars['qperpage'],
 931          ];
 932          $filter = new question_bank_filter_ui($catcontext, $this->searchconditions, $additionalparams, $this->component,
 933                  $this->callback, static::class, 'qbank-table', $this->cm?->id, $this->pagevars,
 934                  $this->extraparams);
 935          echo $OUTPUT->render($filter);
 936      }
 937  
 938      /**
 939       * Print the text if category id not available.
 940       *
 941       * @deprecated since Moodle 4.3 MDL-72321
 942       * @todo Final deprecation on Moodle 4.7 MDL-78090
 943       */
 944      protected function print_choose_category_message(): void {
 945          debugging(
 946              'Function print_choose_category_message() is deprecated, all the features for this method is currently ' .
 947                  'handled by the qbank filter api, please have a look at ' .
 948                  'question/bank/managecategories/classes/category_confition.php for more information.',
 949              DEBUG_DEVELOPER
 950          );
 951          echo \html_writer::start_tag('p', ['style' => "\"text-align:center;\""]);
 952          echo \html_writer::tag('b', get_string('selectcategoryabove', 'question'));
 953          echo \html_writer::end_tag('p');
 954      }
 955  
 956      /**
 957       * Gets current selected category.
 958       * @param string $categoryandcontext
 959       * @return false|mixed|\stdClass
 960       *
 961       * @deprecated since Moodle 4.3 MDL-72321
 962       * @todo Final deprecation on Moodle 4.7 MDL-78090
 963       */
 964      protected function get_current_category($categoryandcontext) {
 965          debugging(
 966              'Function get_current_category() is deprecated, all the features for this method is currently handled by ' .
 967              'the qbank filter api, please have a look at question/bank/managecategories/classes/category_confition.php ' .
 968              'for more information.',
 969              DEBUG_DEVELOPER
 970          );
 971          global $DB, $OUTPUT;
 972          list($categoryid, $contextid) = explode(',', $categoryandcontext);
 973          if (!$categoryid) {
 974              $this->print_choose_category_message();
 975              return false;
 976          }
 977  
 978          if (!$category = $DB->get_record('question_categories',
 979              ['id' => $categoryid, 'contextid' => $contextid])) {
 980              echo $OUTPUT->box_start('generalbox questionbank');
 981              echo $OUTPUT->notification('Category not found!');
 982              echo $OUTPUT->box_end();
 983              return false;
 984          }
 985  
 986          return $category;
 987      }
 988  
 989      /**
 990       * Display the form with options for which questions are displayed and how they are displayed.
 991       *
 992       * @param bool $showquestiontext Display the text of the question within the list.
 993       * @deprecated since Moodle 4.3 MDL-72321
 994       * @todo Final deprecation on Moodle 4.7 MDL-78090
 995       */
 996      protected function display_options_form($showquestiontext): void {
 997          debugging(
 998              'Function display_options_form() is deprecated, this method has been replaced with mustaches in filters, ' .
 999                  'please use filtering objects',
1000              DEBUG_DEVELOPER
1001          );
1002          global $PAGE;
1003  
1004          // The html will be refactored in the filter feature implementation.
1005          echo \html_writer::start_tag('form', ['method' => 'get',
1006              'action' => new \moodle_url($this->baseurl), 'id' => 'displayoptions']);
1007          echo \html_writer::start_div();
1008  
1009          $excludes = ['recurse', 'showhidden', 'qbshowtext'];
1010          // If the URL contains any tags then we need to prevent them
1011          // being added to the form as hidden elements because the tags
1012          // are managed separately.
1013          if ($this->baseurl->param('qtagids[0]')) {
1014              $index = 0;
1015              while ($this->baseurl->param("qtagids[{$index}]")) {
1016                  $excludes[] = "qtagids[{$index}]";
1017                  $index++;
1018              }
1019          }
1020          echo \html_writer::input_hidden_params($this->baseurl, $excludes);
1021  
1022          $advancedsearch = [];
1023  
1024          foreach ($this->searchconditions as $searchcondition) {
1025              if ($searchcondition->display_options_adv()) {
1026                  $advancedsearch[] = $searchcondition;
1027              }
1028          }
1029          if (!empty($advancedsearch)) {
1030              $this->display_advanced_search_form($advancedsearch);
1031          }
1032  
1033          $go = \html_writer::empty_tag('input', ['type' => 'submit', 'value' => get_string('go')]);
1034          echo \html_writer::tag('noscript', \html_writer::div($go), ['class' => 'inline']);
1035          echo \html_writer::end_div();
1036          echo \html_writer::end_tag('form');
1037          $PAGE->requires->yui_module('moodle-question-searchform', 'M.question.searchform.init');
1038      }
1039  
1040      /**
1041       * Print the "advanced" UI elements for the form to select which questions. Hidden by default.
1042       *
1043       * @param array $advancedsearch
1044       * @deprecated since Moodle 4.3 MDL-72321
1045       * @todo Final deprecation on Moodle 4.7 MDL-78090
1046       */
1047      protected function display_advanced_search_form($advancedsearch): void {
1048          debugging(
1049              'Function display_advanced_search_form() is deprecated, this method has been replaced with mustaches in ' .
1050              'filters, please use filtering objects',
1051              DEBUG_DEVELOPER
1052          );
1053          print_collapsible_region_start('', 'advancedsearch',
1054              get_string('advancedsearchoptions', 'question'),
1055              'question_bank_advanced_search');
1056          foreach ($advancedsearch as $searchcondition) {
1057              echo $searchcondition->display_options_adv();
1058          }
1059          print_collapsible_region_end();
1060      }
1061  
1062      /**
1063       * Display the checkbox UI for toggling the display of the question text in the list.
1064       * @param bool $showquestiontext the current or default value for whether to display the text.
1065       * @deprecated since Moodle 4.3 MDL-72321
1066       * @todo Final deprecation on Moodle 4.7 MDL-78090
1067       */
1068      protected function display_showtext_checkbox($showquestiontext): void {
1069          debugging('Function display_showtext_checkbox() is deprecated, please use filtering objects', DEBUG_DEVELOPER);
1070          global $PAGE;
1071          $displaydata = [
1072              'checked' => $showquestiontext
1073          ];
1074          if (class_exists('qbank_viewquestiontext\\question_text_row')) {
1075              if (\core\plugininfo\qbank::is_plugin_enabled('qbank_viewquestiontext')) {
1076                  echo $PAGE->get_renderer('core_question', 'bank')->render_showtext_checkbox($displaydata);
1077              }
1078          }
1079      }
1080  
1081      /**
1082       * Display the header element for the question bank.
1083       */
1084      protected function display_question_bank_header(): void {
1085          global $OUTPUT;
1086          echo $OUTPUT->heading(get_string('questionbank', 'question'), 2);
1087      }
1088  
1089      /**
1090       * Does the current view allow adding new questions?
1091       *
1092       * @return bool True if the view supports adding new questions.
1093       */
1094      public function allow_add_questions(): bool {
1095          return true;
1096      }
1097  
1098      /**
1099       * Output the question bank controls for each plugin.
1100       *
1101       * Controls will be output in the order defined by the array keys returned from
1102       * {@see plugin_features_base::get_question_bank_controls}. If more than one plugin defines a control in the same position,
1103       * they will placed after one another based on the alphabetical order of the plugins.
1104       *
1105       * @param \core\context $context The current context, for permissions checks.
1106       * @param int $categoryid The current question category.
1107       */
1108      protected function get_plugin_controls(\core\context $context, int $categoryid): string {
1109          global $OUTPUT;
1110          $orderedcontrols = [];
1111          foreach ($this->plugins as $plugin) {
1112              $plugincontrols = $plugin->get_question_bank_controls($this, $context, $categoryid);
1113              foreach ($plugincontrols as $position => $plugincontrol) {
1114                  if (!array_key_exists($position, $orderedcontrols)) {
1115                      $orderedcontrols[$position] = [];
1116                  }
1117                  $orderedcontrols[$position][] = $plugincontrol;
1118              }
1119          }
1120          ksort($orderedcontrols);
1121          $output = '';
1122          foreach ($orderedcontrols as $controls) {
1123              foreach ($controls as $control) {
1124                  $output .= $OUTPUT->render($control);
1125              }
1126          }
1127          return $OUTPUT->render_from_template('core_question/question_bank_controls', ['controls' => $output]);
1128      }
1129  
1130      /**
1131       * Prints the table of questions in a category with interactions
1132       */
1133      public function display_question_list(): void {
1134          // This function can be moderately slow with large question counts and may time out.
1135          // We probably do not want to raise it to unlimited, so randomly picking 5 minutes.
1136          // Note: We do not call this in the loop because quiz ob_ captures this function (see raise() PHP doc).
1137          \core_php_time_limit::raise(300);
1138  
1139          [$categoryid, $contextid] = category_condition::validate_category_param($this->pagevars['cat']);
1140          $catcontext = \context::instance_by_id($contextid);
1141  
1142          echo \html_writer::start_tag(
1143              'div',
1144              [
1145                  'id' => 'questionscontainer',
1146                  'data-component' => $this->component,
1147                  'data-callback' => $this->callback,
1148                  'data-contextid' => $this->get_most_specific_context()->id,
1149              ]
1150          );
1151          echo $this->get_plugin_controls($catcontext, $categoryid);
1152  
1153          $this->build_query();
1154          $questionsrs = $this->load_page_questions();
1155          $totalquestions = $this->get_question_count();
1156          $questions = [];
1157          foreach ($questionsrs as $question) {
1158              if (!empty($question->id)) {
1159                  $questions[$question->id] = $question;
1160              }
1161          }
1162          $questionsrs->close();
1163  
1164          // This html will be refactored in the bulk actions implementation.
1165          echo \html_writer::start_tag('form', ['action' => $this->baseurl, 'method' => 'post', 'id' => 'questionsubmit']);
1166          echo \html_writer::start_tag('fieldset', ['class' => 'invisiblefieldset', 'style' => "display: block;"]);
1167          echo \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey()]);
1168          echo \html_writer::input_hidden_params($this->baseurl);
1169  
1170          $filtercondition = json_encode($this->get_pagevars());
1171          // Embeded filterconditon into the div.
1172          echo \html_writer::start_tag('div',
1173              ['class' => 'categoryquestionscontainer', 'data-filtercondition' => $filtercondition]);
1174          if ($totalquestions > 0) {
1175              // Bulk load any required statistics.
1176              $this->load_required_statistics($questions);
1177  
1178              // Bulk load any extra data that any column requires.
1179              foreach ($this->requiredcolumns as $column) {
1180                  $column->load_additional_data($questions);
1181              }
1182              $this->display_questions($questions, $this->pagevars['qpage'], $this->pagevars['qperpage']);
1183          }
1184          echo \html_writer::end_tag('div');
1185  
1186          $this->display_bottom_controls($catcontext);
1187  
1188          echo \html_writer::end_tag('fieldset');
1189          echo \html_writer::end_tag('form');
1190          echo \html_writer::end_tag('div');
1191      }
1192  
1193      /**
1194       * Work out the list of all the required statistics fields for this question bank view.
1195       *
1196       * This gathers all the required fields from all columns, so they can all be loaded at once.
1197       *
1198       * @return string[] the names of all the required fields for this question bank view.
1199       */
1200      protected function determine_required_statistics(): array {
1201          $requiredfields = [];
1202          foreach ($this->requiredcolumns as $column) {
1203              $requiredfields = array_merge($requiredfields, $column->get_required_statistics_fields());
1204          }
1205  
1206          return array_unique($requiredfields);
1207      }
1208  
1209      /**
1210       * Load the aggregate statistics that all the columns require.
1211       *
1212       * @param \stdClass[] $questions the questions that will be displayed indexed by question id.
1213       */
1214      protected function load_required_statistics(array $questions): void {
1215          $requiredstatistics = $this->determine_required_statistics();
1216          $this->loadedstatistics = statistics_bulk_loader::load_aggregate_statistics(
1217                  array_keys($questions), $requiredstatistics);
1218      }
1219  
1220      /**
1221       * Get the aggregated value of a particular statistic for a particular question.
1222       *
1223       * You can only get values for the questions on the current page of the question bank view,
1224       * and only if you declared the need for this statistic in the get_required_statistics_fields()
1225       * method of your question bank column.
1226       *
1227       * @param int $questionid the id of a question
1228       * @param string $fieldname the name of a statistics field, e.g. 'facility'.
1229       * @return float|null the average (across all users) of this statistic for this question.
1230       *      Null if the value is not available right now.
1231       */
1232      public function get_aggregate_statistic(int $questionid, string $fieldname): ?float {
1233          if (!array_key_exists($questionid, $this->loadedstatistics)) {
1234              throw new \coding_exception('Question ' . $questionid . ' is not on the current page of ' .
1235                      'this question bank view, so its statistics are not available.');
1236          }
1237  
1238          // Must be array_key_exists, not isset, because we care about null values.
1239          if (!array_key_exists($fieldname, $this->loadedstatistics[$questionid])) {
1240              throw new \coding_exception('Statistics field ' . $fieldname . ' was not requested by any ' .
1241                      'question bank column in this view, so it is not available.');
1242          }
1243  
1244          return $this->loadedstatistics[$questionid][$fieldname];
1245      }
1246  
1247      /**
1248       * Display the top pagination bar.
1249       *
1250       * @param object $pagination
1251       * @deprecated since Moodle 4.3
1252       * @todo Final deprecation on Moodle 4.7 MDL-78091
1253       */
1254      public function display_top_pagnation($pagination): void {
1255          debugging(
1256              'Function display_top_pagnation() is deprecated, please use display_questions() for ajax based pagination.',
1257              DEBUG_DEVELOPER
1258          );
1259          global $PAGE;
1260          $displaydata = [
1261              'pagination' => $pagination
1262          ];
1263          echo $PAGE->get_renderer('core_question', 'bank')->render_question_pagination($displaydata);
1264      }
1265  
1266      /**
1267       * Display bottom pagination bar.
1268       *
1269       * @param string $pagination
1270       * @param int $totalnumber
1271       * @param int $perpage
1272       * @param \moodle_url $pageurl
1273       * @deprecated since Moodle 4.3
1274       * @todo Final deprecation on Moodle 4.7 MDL-78091
1275       */
1276      public function display_bottom_pagination($pagination, $totalnumber, $perpage, $pageurl): void {
1277          debugging(
1278              'Function display_bottom_pagination() is deprecated, please use display_questions() for ajax based pagination.',
1279              DEBUG_DEVELOPER
1280          );
1281          global $PAGE;
1282          $displaydata = array (
1283              'extraclasses' => 'pagingbottom',
1284              'pagination' => $pagination,
1285              'biggertotal' => true,
1286          );
1287          if ($totalnumber > $this->pagesize) {
1288              $displaydata['showall'] = true;
1289              if ($perpage == $this->pagesize) {
1290                  $url = new \moodle_url($pageurl, array_merge($pageurl->params(),
1291                      ['qpage' => 0, 'qperpage' => MAXIMUM_QUESTIONS_PER_PAGE]));
1292                  if ($totalnumber > MAXIMUM_QUESTIONS_PER_PAGE) {
1293                      $displaydata['totalnumber'] = MAXIMUM_QUESTIONS_PER_PAGE;
1294                  } else {
1295                      $displaydata['biggertotal'] = false;
1296                      $displaydata['totalnumber'] = $totalnumber;
1297                  }
1298              } else {
1299                  $url = new \moodle_url($pageurl, array_merge($pageurl->params(),
1300                      ['qperpage' => $this->pagesize]));
1301                  $displaydata['totalnumber'] = $this->pagesize;
1302              }
1303              $displaydata['showallurl'] = $url;
1304          }
1305          echo $PAGE->get_renderer('core_question', 'bank')->render_question_pagination($displaydata);
1306      }
1307  
1308      /**
1309       * Display the controls at the bottom of the list of questions.
1310       *
1311       * @param \context $catcontext The context of the category being displayed.
1312       */
1313      protected function display_bottom_controls(\context $catcontext): void {
1314          $caneditall = has_capability('moodle/question:editall', $catcontext);
1315          $canuseall = has_capability('moodle/question:useall', $catcontext);
1316          $canmoveall = has_capability('moodle/question:moveall', $catcontext);
1317          if ($caneditall || $canmoveall || $canuseall) {
1318              global $PAGE;
1319              $bulkactiondatas = [];
1320              $params = $this->base_url()->params();
1321              $returnurl = new \moodle_url($this->base_url(), ['filter' => json_encode($this->pagevars['filter'])]);
1322              $params['returnurl'] = $returnurl;
1323              foreach ($this->bulkactions as $key => $action) {
1324                  // Check capabilities.
1325                  $capcount = 0;
1326                  foreach ($action['capabilities'] as $capability) {
1327                      if (has_capability($capability, $catcontext)) {
1328                          $capcount ++;
1329                      }
1330                  }
1331                  // At least one cap need to be there.
1332                  if ($capcount === 0) {
1333                      unset($this->bulkactions[$key]);
1334                      continue;
1335                  }
1336                  $actiondata = new \stdClass();
1337                  $actiondata->actionname = $action['title'];
1338                  $actiondata->actionkey = $key;
1339                  $actiondata->actionurl = new \moodle_url($action['url'], $params);
1340                  $bulkactiondata[] = $actiondata;
1341  
1342                  $bulkactiondatas ['bulkactionitems'] = $bulkactiondata;
1343              }
1344              // We dont need to show this section if none of the plugins are enabled.
1345              if (!empty($bulkactiondatas)) {
1346                  echo $PAGE->get_renderer('core_question', 'bank')->render_bulk_actions_ui($bulkactiondatas);
1347              }
1348          }
1349      }
1350  
1351      /**
1352       * Display the questions.
1353       *
1354       * @param array $questions
1355       */
1356      public function display_questions($questions, $page = 0, $perpage = DEFAULT_QUESTIONS_PER_PAGE): void {
1357          global $OUTPUT;
1358          if (!isset($this->pagevars['filter']['category'])) {
1359              // We must have a category filter selected.
1360              echo $OUTPUT->render_from_template('qbank_managecategories/choose_category', []);
1361              return;
1362          }
1363          // Pagination.
1364          $pageingurl = new \moodle_url($this->base_url());
1365          $pagingbar = new \paging_bar($this->totalcount, $page, $perpage, $pageingurl);
1366          $pagingbar->pagevar = 'qpage';
1367          echo $OUTPUT->render($pagingbar);
1368  
1369          // Table of questions.
1370          echo \html_writer::start_tag('div',
1371              ['class' => 'question_table', 'id' => 'question_table']);
1372          $this->print_table($questions);
1373          echo \html_writer::end_tag('div');
1374          echo $OUTPUT->render($pagingbar);
1375      }
1376  
1377      /**
1378       * Load the questions according to the search conditions.
1379       *
1380       * @return array
1381       */
1382      public function load_questions() {
1383          $this->build_query();
1384          $questionsrs = $this->load_page_questions();
1385          $questions = [];
1386          foreach ($questionsrs as $question) {
1387              if (!empty($question->id)) {
1388                  $questions[$question->id] = $question;
1389              }
1390          }
1391          $questionsrs->close();
1392          foreach ($this->requiredcolumns as $name => $column) {
1393              $column->load_additional_data($questions);
1394          }
1395          return $questions;
1396      }
1397  
1398      /**
1399       * Prints the actual table with question.
1400       *
1401       * @param array $questions
1402       */
1403      protected function print_table($questions): void {
1404          // Start of the table.
1405          echo \html_writer::start_tag('table', [
1406              'id' => 'categoryquestions',
1407              'class' => 'question-bank-table generaltable',
1408              'data-defaultsort' => json_encode($this->sort),
1409          ]);
1410  
1411          // Prints the table header.
1412          echo \html_writer::start_tag('thead');
1413          echo \html_writer::start_tag('tr', ['class' => 'qbank-column-list']);
1414          $this->print_table_headers();
1415          echo \html_writer::end_tag('tr');
1416          echo \html_writer::end_tag('thead');
1417  
1418          // Prints the table row or content.
1419          echo \html_writer::start_tag('tbody');
1420          $rowcount = 0;
1421          foreach ($questions as $question) {
1422              $this->print_table_row($question, $rowcount);
1423              $rowcount += 1;
1424          }
1425          echo \html_writer::end_tag('tbody');
1426  
1427          // End of the table.
1428          echo \html_writer::end_tag('table');
1429      }
1430  
1431      /**
1432       * Start of the table html.
1433       *
1434       * @see print_table()
1435       * @deprecated since Moodle 4.3 MDL-72321
1436       * @todo Final deprecation on Moodle 4.7 MDL-78090
1437       */
1438      protected function start_table() {
1439          debugging('Function start_table() is deprecated, please use print_table() instead.', DEBUG_DEVELOPER);
1440          echo '<table id="categoryquestions" class="table table-responsive">' . "\n";
1441          echo "<thead>\n";
1442          $this->print_table_headers();
1443          echo "</thead>\n";
1444          echo "<tbody>\n";
1445      }
1446  
1447      /**
1448       * End of the table html.
1449       *
1450       * @see print_table()
1451       * @deprecated since Moodle 4.3 MDL-72321
1452       * @todo Final deprecation on Moodle 4.7 MDL-78090
1453       */
1454      protected function end_table() {
1455          debugging('Function end_table() is deprecated, please use print_table() instead.', DEBUG_DEVELOPER);
1456          echo "</tbody>\n";
1457          echo "</table>\n";
1458      }
1459  
1460      /**
1461       * Print table headers from child classes.
1462       */
1463      protected function print_table_headers(): void {
1464          $columnactions = $this->columnmanager->get_column_actions($this);
1465          foreach ($this->visiblecolumns as $column) {
1466              $width = $this->columnmanager->get_column_width($column);
1467              $column->display_header($columnactions, $width);
1468          }
1469      }
1470  
1471      /**
1472       * Gets the classes for the row.
1473       *
1474       * @param \stdClass $question
1475       * @param int $rowcount
1476       * @return array
1477       */
1478      protected function get_row_classes($question, $rowcount): array {
1479          $classes = [];
1480          if ($question->status === question_version_status::QUESTION_STATUS_HIDDEN) {
1481              $classes[] = 'dimmed_text';
1482          }
1483          if ($question->id == $this->lastchangedid) {
1484              $classes[] = 'highlight text-dark';
1485          }
1486          $classes[] = 'r' . ($rowcount % 2);
1487          return $classes;
1488      }
1489  
1490      /**
1491       * Prints the table row from child classes.
1492       *
1493       * @param \stdClass $question
1494       * @param int $rowcount
1495       */
1496      public function print_table_row($question, $rowcount): void {
1497          $rowclasses = implode(' ', $this->get_row_classes($question, $rowcount));
1498          $attributes = [];
1499          if ($rowclasses) {
1500              $attributes['class'] = $rowclasses;
1501          }
1502          echo \html_writer::start_tag('tr', $attributes);
1503          foreach ($this->visiblecolumns as $column) {
1504              $column->display($question, $rowclasses);
1505          }
1506          echo \html_writer::end_tag('tr');
1507          foreach ($this->extrarows as $row) {
1508              $row->display($question, $rowclasses);
1509          }
1510      }
1511  
1512      /**
1513       * Process actions for the selected action.
1514       * @deprecated since Moodle 4.0
1515       * @todo Final deprecation on Moodle 4.4 MDL-72438
1516       */
1517      public function process_actions(): void {
1518          debugging('Function process_actions() is deprecated and its code has been completely deleted.
1519           Please, remove the call from your code and check core_question\local\bank\bulk_action_base
1520            to learn more about bulk actions in qbank.', DEBUG_DEVELOPER);
1521          // Associated code is deleted to make sure any incorrect call doesnt not cause any data loss.
1522      }
1523  
1524      /**
1525       * Process actions with ui.
1526       * @return bool
1527       * @deprecated since Moodle 4.0
1528       * @todo Final deprecation on Moodle 4.4 MDL-72438
1529       */
1530      public function process_actions_needing_ui(): bool {
1531          debugging('Function process_actions_needing_ui() is deprecated and its code has been completely deleted.
1532           Please, remove the call from your code and check core_question\local\bank\bulk_action_base
1533            to learn more about bulk actions in qbank.', DEBUG_DEVELOPER);
1534          // Associated code is deleted to make sure any incorrect call doesnt not cause any data loss.
1535          return false;
1536      }
1537  
1538      /**
1539       * Add another search control to this view.
1540       * @param condition $searchcondition the condition to add.
1541       * @param string|null $fieldname
1542       */
1543      public function add_searchcondition(condition $searchcondition, ?string $fieldname = null): void {
1544          if (is_null($fieldname)) {
1545              $this->searchconditions[] = $searchcondition;
1546          } else {
1547              $this->searchconditions[$fieldname] = $searchcondition;
1548          }
1549      }
1550  
1551      /**
1552       * Add standard search conditions.
1553       * Params must be set into this object before calling this function.
1554       */
1555      public function add_standard_search_conditions(): void {
1556          foreach ($this->plugins as $componentname => $plugin) {
1557              if (\core\plugininfo\qbank::is_plugin_enabled($componentname)) {
1558                  $pluginentrypointobject = new $plugin();
1559                  $pluginobjects = $pluginentrypointobject->get_question_filters($this);
1560                  foreach ($pluginobjects as $pluginobject) {
1561                      $this->add_searchcondition($pluginobject, $pluginobject->get_condition_key());
1562                  }
1563              }
1564          }
1565      }
1566  
1567      /**
1568       * Gets visible columns.
1569       * @return array Visible columns.
1570       */
1571      public function get_visiblecolumns(): array {
1572          return $this->visiblecolumns;
1573      }
1574  
1575      /**
1576       * Is this view showing separate versions of a question?
1577       *
1578       * @return bool
1579       */
1580      public function is_listing_specific_versions(): bool {
1581          return false;
1582      }
1583  
1584      /**
1585       * Return array of menu actions.
1586       *
1587       * @return question_action_base[]
1588       */
1589      public function get_question_actions(): array {
1590          return $this->questionactions;
1591      }
1592  
1593      /**
1594       * Display the questions table for the fragment/ajax.
1595       *
1596       * @return string HTML for the question table
1597       */
1598      public function display_questions_table(): string {
1599          $this->add_standard_search_conditions();
1600          $questions = $this->load_questions();
1601          $totalquestions = $this->get_question_count();
1602          $questionhtml = '';
1603          if ($totalquestions > 0) {
1604              $this->load_required_statistics($questions);
1605              ob_start();
1606              $this->display_questions($questions, $this->pagevars['qpage'], $this->pagevars['qperpage']);
1607              $questionhtml = ob_get_clean();
1608          }
1609          return $questionhtml;
1610      }
1611  }