Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.

Differences Between: [Versions 400 and 401] [Versions 400 and 402] [Versions 400 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  use core_plugin_manager;
  28  use core_question\bank\search\condition;
  29  use core_question\local\statistics\statistics_bulk_loader;
  30  use qbank_columnsortorder\column_manager;
  31  use qbank_editquestion\editquestion_helper;
  32  
  33  defined('MOODLE_INTERNAL') || die();
  34  
  35  require_once($CFG->dirroot . '/question/editlib.php');
  36  
  37  /**
  38   * This class prints a view of the question bank.
  39   *
  40   * including
  41   *  + Some controls to allow users to to select what is displayed.
  42   *  + A list of questions as a table.
  43   *  + Further controls to do things with the questions.
  44   *
  45   * This class gives a basic view, and provides plenty of hooks where subclasses
  46   * can override parts of the display.
  47   *
  48   * The list of questions presented as a table is generated by creating a list of
  49   * core_question\bank\column objects, one for each 'column' to be displayed. These
  50   * manage
  51   *  + outputting the contents of that column, given a $question object, but also
  52   *  + generating the right fragments of SQL to ensure the necessary data is present,
  53   *    and sorted in the right order.
  54   *  + outputting table headers.
  55   *
  56   * @copyright 2009 Tim Hunt
  57   * @author    2021 Safat Shahin <safatshahin@catalyst-au.net>
  58   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  59   */
  60  class view {
  61  
  62      /**
  63       * Maximum number of sorts allowed.
  64       */
  65      const MAX_SORTS = 3;
  66  
  67      /**
  68       * @var \moodle_url base URL for the current page. Used as the
  69       * basis for making URLs for actions that reload the page.
  70       */
  71      protected $baseurl;
  72  
  73      /**
  74       * @var \moodle_url used as a basis for URLs that edit a question.
  75       */
  76      protected $editquestionurl;
  77  
  78      /**
  79       * @var \core_question\local\bank\question_edit_contexts
  80       */
  81      protected $contexts;
  82  
  83      /**
  84       * @var object|\cm_info|null if we are in a module context, the cm.
  85       */
  86      public $cm;
  87  
  88      /**
  89       * @var object the course we are within.
  90       */
  91      public $course;
  92  
  93      /**
  94       * @var column_base[] these are all the 'columns' that are
  95       * part of the display. Array keys are the class name.
  96       */
  97      protected $requiredcolumns;
  98  
  99      /**
 100       * @var column_base[] these are the 'columns' that are
 101       * actually displayed as a column, in order. Array keys are the class name.
 102       */
 103      protected $visiblecolumns;
 104  
 105      /**
 106       * @var column_base[] these are the 'columns' that are
 107       * actually displayed as an additional row (e.g. question text), in order.
 108       * Array keys are the class name.
 109       */
 110      protected $extrarows;
 111  
 112      /**
 113       * @var array list of column class names for which columns to sort on.
 114       */
 115      protected $sort;
 116  
 117      /**
 118       * @var int|null id of the a question to highlight in the list (if present).
 119       */
 120      protected $lastchangedid;
 121  
 122      /**
 123       * @var string SQL to count the number of questions matching the current
 124       * search conditions.
 125       */
 126      protected $countsql;
 127  
 128      /**
 129       * @var string SQL to actually load the question data to display.
 130       */
 131      protected $loadsql;
 132  
 133      /**
 134       * @var array params used by $countsql and $loadsql (which currently must be the same).
 135       */
 136      protected $sqlparams;
 137  
 138      /**
 139       * @var ?array Stores all the average statistics that this question bank view needs.
 140       *
 141       * This field gets initialised in {@see display_question_list()}. It is a two dimensional
 142       * $this->loadedstatistics[$questionid][$fieldname] = $average value of that statistics for that question.
 143       * Column classes in qbank plugins can access these values using {@see get_aggregate_statistic()}.
 144       */
 145      protected $loadedstatistics = null;
 146  
 147      /**
 148       * @var condition[] search conditions.
 149       */
 150      protected $searchconditions = [];
 151  
 152      /**
 153       * @var string url of the new question page.
 154       */
 155      public $returnurl;
 156  
 157      /**
 158       * @var bool enable or disable filters while calling the API.
 159       */
 160      public $enablefilters = true;
 161  
 162      /**
 163       * @var array to pass custom filters instead of the specified ones.
 164       */
 165      public $customfilterobjects = null;
 166  
 167      /**
 168       * @var array $bulkactions to identify the bulk actions for the api.
 169       */
 170      public $bulkactions = [];
 171  
 172      /**
 173       * Constructor for view.
 174       *
 175       * @param \core_question\local\bank\question_edit_contexts $contexts
 176       * @param \moodle_url $pageurl
 177       * @param object $course course settings
 178       * @param object $cm (optional) activity settings.
 179       */
 180      public function __construct($contexts, $pageurl, $course, $cm = null) {
 181          $this->contexts = $contexts;
 182          $this->baseurl = $pageurl;
 183          $this->course = $course;
 184          $this->cm = $cm;
 185  
 186          // Create the url of the new question page to forward to.
 187          $this->returnurl = $pageurl->out_as_local_url(false);
 188          $this->editquestionurl = new \moodle_url('/question/bank/editquestion/question.php', ['returnurl' => $this->returnurl]);
 189          if ($this->cm !== null) {
 190              $this->editquestionurl->param('cmid', $this->cm->id);
 191          } else {
 192              $this->editquestionurl->param('courseid', $this->course->id);
 193          }
 194  
 195          $this->lastchangedid = optional_param('lastchanged', 0, PARAM_INT);
 196  
 197          // Possibly the heading part can be removed.
 198          $this->init_columns($this->wanted_columns(), $this->heading_column());
 199          $this->init_sort();
 200          $this->init_search_conditions();
 201          $this->init_bulk_actions();
 202      }
 203  
 204      /**
 205       * Initialize bulk actions.
 206       */
 207      protected function init_bulk_actions(): void {
 208          $plugins = \core_component::get_plugin_list_with_class('qbank', 'plugin_feature', 'plugin_feature.php');
 209          foreach ($plugins as $componentname => $plugin) {
 210              if (!\core\plugininfo\qbank::is_plugin_enabled($componentname)) {
 211                  continue;
 212              }
 213  
 214              $pluginentrypoint = new $plugin();
 215              $bulkactions = $pluginentrypoint->get_bulk_actions();
 216              if (!is_array($bulkactions)) {
 217                  $bulkactions = [$bulkactions];
 218              }
 219  
 220              foreach ($bulkactions as $bulkactionobject) {
 221                  $this->bulkactions[$bulkactionobject->get_key()] = [
 222                      'title' => $bulkactionobject->get_bulk_action_title(),
 223                      'url' => $bulkactionobject->get_bulk_action_url(),
 224                      'capabilities' => $bulkactionobject->get_bulk_action_capabilities()
 225                  ];
 226              }
 227  
 228          }
 229      }
 230  
 231      /**
 232       * Initialize search conditions from plugins
 233       * local_*_get_question_bank_search_conditions() must return an array of
 234       * \core_question\bank\search\condition objects.
 235       */
 236      protected function init_search_conditions(): void {
 237          $searchplugins = get_plugin_list_with_function('local', 'get_question_bank_search_conditions');
 238          foreach ($searchplugins as $component => $function) {
 239              foreach ($function($this) as $searchobject) {
 240                  $this->add_searchcondition($searchobject);
 241              }
 242          }
 243      }
 244  
 245      /**
 246       * Get the list of qbank plugins with available objects for features.
 247       *
 248       * @return array
 249       */
 250      protected function get_question_bank_plugins(): array {
 251          $questionbankclasscolumns = [];
 252          $newpluginclasscolumns = [];
 253          $corequestionbankcolumns = [
 254                  'checkbox_column',
 255                  'question_type_column',
 256                  'question_name_idnumber_tags_column',
 257                  'edit_menu_column',
 258                  'edit_action_column',
 259                  'copy_action_column',
 260                  'tags_action_column',
 261                  'preview_action_column',
 262                  'history_action_column',
 263                  'delete_action_column',
 264                  'export_xml_action_column',
 265                  'question_status_column',
 266                  'version_number_column',
 267                  'creator_name_column',
 268                  'comment_count_column'
 269          ];
 270          if (question_get_display_preference('qbshowtext', 0, PARAM_INT, new \moodle_url(''))) {
 271              $corequestionbankcolumns[] = 'question_text_row';
 272          }
 273  
 274          foreach ($corequestionbankcolumns as $fullname) {
 275              $shortname = $fullname;
 276              if (class_exists('core_question\\local\\bank\\' . $fullname)) {
 277                  $fullname = 'core_question\\local\\bank\\' . $fullname;
 278                  $questionbankclasscolumns[$shortname] = new $fullname($this);
 279              } else {
 280                  $questionbankclasscolumns[$shortname] = '';
 281              }
 282          }
 283          $plugins = \core_component::get_plugin_list_with_class('qbank', 'plugin_feature', 'plugin_feature.php');
 284          foreach ($plugins as $componentname => $plugin) {
 285              $pluginentrypointobject = new $plugin();
 286              $plugincolumnobjects = $pluginentrypointobject->get_question_columns($this);
 287              // Don't need the plugins without column objects.
 288              if (empty($plugincolumnobjects)) {
 289                  unset($plugins[$componentname]);
 290                  continue;
 291              }
 292              foreach ($plugincolumnobjects as $columnobject) {
 293                  $columnname = $columnobject->get_column_name();
 294                  foreach ($corequestionbankcolumns as $key => $corequestionbankcolumn) {
 295                      if (!\core\plugininfo\qbank::is_plugin_enabled($componentname)) {
 296                          unset($questionbankclasscolumns[$columnname]);
 297                          continue;
 298                      }
 299                      // Check if it has custom preference selector to view/hide.
 300                      if ($columnobject->has_preference()) {
 301                          if (!$columnobject->get_preference()) {
 302                              continue;
 303                          }
 304                      }
 305                      if ($corequestionbankcolumn === $columnname) {
 306                          $questionbankclasscolumns[$columnname] = $columnobject;
 307                      } else {
 308                          // Any community plugin for column/action.
 309                          $newpluginclasscolumns[$columnname] = $columnobject;
 310                      }
 311                  }
 312              }
 313          }
 314  
 315          // New plugins added at the end of the array, will change in sorting feature.
 316          foreach ($newpluginclasscolumns as $key => $newpluginclasscolumn) {
 317              $questionbankclasscolumns[$key] = $newpluginclasscolumn;
 318          }
 319  
 320          // Check if qbank_columnsortorder is enabled.
 321          if (array_key_exists('columnsortorder', core_plugin_manager::instance()->get_enabled_plugins('qbank'))) {
 322              $columnorder = new column_manager();
 323              $questionbankclasscolumns = $columnorder->get_sorted_columns($questionbankclasscolumns);
 324          }
 325  
 326          // Mitigate the error in case of any regression.
 327          foreach ($questionbankclasscolumns as $shortname => $questionbankclasscolumn) {
 328              if (empty($questionbankclasscolumn)) {
 329                  unset($questionbankclasscolumns[$shortname]);
 330              }
 331          }
 332  
 333          return $questionbankclasscolumns;
 334      }
 335  
 336      /**
 337       * Loads all the available columns.
 338       *
 339       * @return array
 340       */
 341      protected function wanted_columns(): array {
 342          $this->requiredcolumns = [];
 343          $questionbankcolumns = $this->get_question_bank_plugins();
 344          foreach ($questionbankcolumns as $classobject) {
 345              if (empty($classobject)) {
 346                  continue;
 347              }
 348              $this->requiredcolumns[$classobject->get_column_name()] = $classobject;
 349          }
 350  
 351          return $this->requiredcolumns;
 352      }
 353  
 354  
 355      /**
 356       * Check a column object from its name and get the object for sort.
 357       *
 358       * @param string $columnname
 359       */
 360      protected function get_column_type($columnname) {
 361          if (empty($this->requiredcolumns[$columnname])) {
 362              $this->requiredcolumns[$columnname] = new $columnname($this);
 363          }
 364      }
 365  
 366      /**
 367       * Specify the column heading
 368       *
 369       * @return string Column name for the heading
 370       */
 371      protected function heading_column(): string {
 372          return 'qbank_viewquestionname\viewquestionname_column_helper';
 373      }
 374  
 375      /**
 376       * Initializing table columns
 377       *
 378       * @param array $wanted Collection of column names
 379       * @param string $heading The name of column that is set as heading
 380       */
 381      protected function init_columns($wanted, $heading = ''): void {
 382          // If we are using the edit menu column, allow it to absorb all the actions.
 383          foreach ($wanted as $column) {
 384              if ($column instanceof edit_menu_column) {
 385                  $wanted = $column->claim_menuable_columns($wanted);
 386                  break;
 387              }
 388          }
 389  
 390          // Now split columns into real columns and rows.
 391          $this->visiblecolumns = [];
 392          $this->extrarows = [];
 393          foreach ($wanted as $column) {
 394              if ($column->is_extra_row()) {
 395                  $this->extrarows[$column->get_column_name()] = $column;
 396              } else {
 397                  $this->visiblecolumns[$column->get_column_name()] = $column;
 398              }
 399          }
 400  
 401          if (array_key_exists($heading, $this->requiredcolumns)) {
 402              $this->requiredcolumns[$heading]->set_as_heading();
 403          }
 404      }
 405  
 406      /**
 407       * Checks if the column included in the output.
 408       *
 409       * @param string $colname a column internal name.
 410       * @return bool is this column included in the output?
 411       */
 412      public function has_column($colname): bool {
 413          return isset($this->visiblecolumns[$colname]);
 414      }
 415  
 416      /**
 417       * Get the count of the columns.
 418       *
 419       * @return int The number of columns in the table.
 420       */
 421      public function get_column_count(): int {
 422          return count($this->visiblecolumns);
 423      }
 424  
 425      /**
 426       * Get course id.
 427       * @return mixed
 428       */
 429      public function get_courseid() {
 430          return $this->course->id;
 431      }
 432  
 433      /**
 434       * Initialise sorting.
 435       */
 436      protected function init_sort(): void {
 437          $this->init_sort_from_params();
 438          if (empty($this->sort)) {
 439              $this->sort = $this->default_sort();
 440          }
 441      }
 442  
 443      /**
 444       * Deal with a sort name of the form columnname, or colname_subsort by
 445       * breaking it up, validating the bits that are present, and returning them.
 446       * If there is no subsort, then $subsort is returned as ''.
 447       *
 448       * @param string $sort the sort parameter to process.
 449       * @return array [$colname, $subsort].
 450       */
 451      protected function parse_subsort($sort): array {
 452          // Do the parsing.
 453          if (strpos($sort, '-') !== false) {
 454              list($colname, $subsort) = explode('-', $sort, 2);
 455          } else {
 456              $colname = $sort;
 457              $subsort = '';
 458          }
 459          // Validate the column name.
 460          $this->get_column_type($colname);
 461          $column = $this->requiredcolumns[$colname];
 462          if (!isset($column) || !$column->is_sortable()) {
 463              for ($i = 1; $i <= self::MAX_SORTS; $i++) {
 464                  $this->baseurl->remove_params('qbs' . $i);
 465              }
 466              throw new \moodle_exception('unknownsortcolumn', '', $link = $this->baseurl->out(), $colname);
 467          }
 468          // Validate the subsort, if present.
 469          if ($subsort) {
 470              $subsorts = $column->is_sortable();
 471              if (!is_array($subsorts) || !isset($subsorts[$subsort])) {
 472                  throw new \moodle_exception('unknownsortcolumn', '', $link = $this->baseurl->out(), $sort);
 473              }
 474          }
 475          return [$colname, $subsort];
 476      }
 477  
 478      /**
 479       * Initialise sort from parameters.
 480       */
 481      protected function init_sort_from_params(): void {
 482          $this->sort = [];
 483          for ($i = 1; $i <= self::MAX_SORTS; $i++) {
 484              if (!$sort = optional_param('qbs' . $i, '', PARAM_TEXT)) {
 485                  break;
 486              }
 487              // Work out the appropriate order.
 488              $order = 1;
 489              if ($sort[0] == '-') {
 490                  $order = -1;
 491                  $sort = substr($sort, 1);
 492                  if (!$sort) {
 493                      break;
 494                  }
 495              }
 496              // Deal with subsorts.
 497              list($colname) = $this->parse_subsort($sort);
 498              $this->get_column_type($colname);
 499              $this->sort[$sort] = $order;
 500          }
 501      }
 502  
 503      /**
 504       * Sort to parameters.
 505       *
 506       * @param array $sorts
 507       * @return array
 508       */
 509      protected function sort_to_params($sorts): array {
 510          $params = [];
 511          $i = 0;
 512          foreach ($sorts as $sort => $order) {
 513              $i += 1;
 514              if ($order < 0) {
 515                  $sort = '-' . $sort;
 516              }
 517              $params['qbs' . $i] = $sort;
 518          }
 519          return $params;
 520      }
 521  
 522      /**
 523       * Default sort for question data.
 524       * @return int[]
 525       */
 526      protected function default_sort(): array {
 527          $defaultsort = [];
 528          if (class_exists('\\qbank_viewquestiontype\\question_type_column')) {
 529              $sort = 'qbank_viewquestiontype\question_type_column';
 530          }
 531          $defaultsort[$sort] = 1;
 532          if (class_exists('\\qbank_viewquestionname\\question_name_idnumber_tags_column')) {
 533              $sort = 'qbank_viewquestionname\question_name_idnumber_tags_column';
 534          }
 535          $defaultsort[$sort . '-name'] = 1;
 536  
 537          return $defaultsort;
 538      }
 539  
 540      /**
 541       * Gets the primary sort order according to the default sort.
 542       *
 543       * @param string $sort a column or column_subsort name.
 544       * @return int the current sort order for this column -1, 0, 1
 545       */
 546      public function get_primary_sort_order($sort): int {
 547          $order = reset($this->sort);
 548          $primarysort = key($this->sort);
 549          if ($sort == $primarysort) {
 550              return $order;
 551          } else {
 552              return 0;
 553          }
 554      }
 555  
 556      /**
 557       * Get a URL to redisplay the page with a new sort for the question bank.
 558       *
 559       * @param string $sort the column, or column_subsort to sort on.
 560       * @param bool $newsortreverse whether to sort in reverse order.
 561       * @return string The new URL.
 562       */
 563      public function new_sort_url($sort, $newsortreverse): string {
 564          if ($newsortreverse) {
 565              $order = -1;
 566          } else {
 567              $order = 1;
 568          }
 569          // Tricky code to add the new sort at the start, removing it from where it was before, if it was present.
 570          $newsort = array_reverse($this->sort);
 571          if (isset($newsort[$sort])) {
 572              unset($newsort[$sort]);
 573          }
 574          $newsort[$sort] = $order;
 575          $newsort = array_reverse($newsort);
 576          if (count($newsort) > self::MAX_SORTS) {
 577              $newsort = array_slice($newsort, 0, self::MAX_SORTS, true);
 578          }
 579          return $this->baseurl->out(true, $this->sort_to_params($newsort));
 580      }
 581  
 582      /**
 583       * Create the SQL query to retrieve the indicated questions, based on
 584       * \core_question\bank\search\condition filters.
 585       */
 586      protected function build_query(): void {
 587          // Get the required tables and fields.
 588          $joins = [];
 589          $fields = ['qv.status', 'qc.id as categoryid', 'qv.version', 'qv.id as versionid', 'qbe.id as questionbankentryid'];
 590          if (!empty($this->requiredcolumns)) {
 591              foreach ($this->requiredcolumns as $column) {
 592                  $extrajoins = $column->get_extra_joins();
 593                  foreach ($extrajoins as $prefix => $join) {
 594                      if (isset($joins[$prefix]) && $joins[$prefix] != $join) {
 595                          throw new \coding_exception('Join ' . $join . ' conflicts with previous join ' . $joins[$prefix]);
 596                      }
 597                      $joins[$prefix] = $join;
 598                  }
 599                  $fields = array_merge($fields, $column->get_required_fields());
 600              }
 601          }
 602          $fields = array_unique($fields);
 603  
 604          // Build the order by clause.
 605          $sorts = [];
 606          foreach ($this->sort as $sort => $order) {
 607              list($colname, $subsort) = $this->parse_subsort($sort);
 608              $sorts[] = $this->requiredcolumns[$colname]->sort_expression($order < 0, $subsort);
 609          }
 610  
 611          // Build the where clause.
 612          $latestversion = 'qv.version = (SELECT MAX(v.version)
 613                                            FROM {question_versions} v
 614                                            JOIN {question_bank_entries} be
 615                                              ON be.id = v.questionbankentryid
 616                                           WHERE be.id = qbe.id)';
 617          $tests = ['q.parent = 0', $latestversion];
 618          $this->sqlparams = [];
 619          foreach ($this->searchconditions as $searchcondition) {
 620              if ($searchcondition->where()) {
 621                  $tests[] = '((' . $searchcondition->where() .'))';
 622              }
 623              if ($searchcondition->params()) {
 624                  $this->sqlparams = array_merge($this->sqlparams, $searchcondition->params());
 625              }
 626          }
 627          // Build the SQL.
 628          $sql = ' FROM {question} q ' . implode(' ', $joins);
 629          $sql .= ' WHERE ' . implode(' AND ', $tests);
 630          $this->countsql = 'SELECT count(1)' . $sql;
 631          $this->loadsql = 'SELECT ' . implode(', ', $fields) . $sql . ' ORDER BY ' . implode(', ', $sorts);
 632      }
 633  
 634      /**
 635       * Get the number of questions.
 636       * @return int
 637       */
 638      protected function get_question_count(): int {
 639          global $DB;
 640          return $DB->count_records_sql($this->countsql, $this->sqlparams);
 641      }
 642  
 643      /**
 644       * Load the questions we need to display.
 645       *
 646       * @param int $page page to display.
 647       * @param int $perpage number of questions per page.
 648       * @return \moodle_recordset questionid => data about each question.
 649       */
 650      protected function load_page_questions($page, $perpage): \moodle_recordset {
 651          global $DB;
 652          $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, $page * $perpage, $perpage);
 653          if (empty($questions)) {
 654              $questions->close();
 655              // No questions on this page. Reset to page 0.
 656              $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, 0, $perpage);
 657          }
 658          return $questions;
 659      }
 660  
 661      /**
 662       * Returns the base url.
 663       */
 664      public function base_url(): \moodle_url {
 665          return $this->baseurl;
 666      }
 667  
 668      /**
 669       * Get the URL for editing a question as a moodle url.
 670       *
 671       * @param int $questionid the question id.
 672       * @return \moodle_url the URL, HTML-escaped.
 673       */
 674      public function edit_question_moodle_url($questionid) {
 675          return new \moodle_url($this->editquestionurl, ['id' => $questionid]);
 676      }
 677  
 678      /**
 679       * Get the URL for editing a question as a HTML-escaped string.
 680       *
 681       * @param int $questionid the question id.
 682       * @return string the URL, HTML-escaped.
 683       */
 684      public function edit_question_url($questionid) {
 685          return $this->edit_question_moodle_url($questionid)->out();
 686      }
 687  
 688      /**
 689       * Get the URL for duplicating a question as a moodle url.
 690       *
 691       * @param int $questionid the question id.
 692       * @return \moodle_url the URL.
 693       */
 694      public function copy_question_moodle_url($questionid) {
 695          return new \moodle_url($this->editquestionurl, ['id' => $questionid, 'makecopy' => 1]);
 696      }
 697  
 698      /**
 699       * Get the URL for duplicating a given question.
 700       * @param int $questionid the question id.
 701       * @return string the URL, HTML-escaped.
 702       */
 703      public function copy_question_url($questionid) {
 704          return $this->copy_question_moodle_url($questionid)->out();
 705      }
 706  
 707      /**
 708       * Get the context we are displaying the question bank for.
 709       * @return \context context object.
 710       */
 711      public function get_most_specific_context(): \context {
 712          return $this->contexts->lowest();
 713      }
 714  
 715      /**
 716       * Get the URL to preview a question.
 717       * @param \stdClass $questiondata the data defining the question.
 718       * @return \moodle_url the URL.
 719       * @deprecated since Moodle 4.0
 720       * @see \qbank_previewquestion\helper::question_preview_url()
 721       * @todo Final deprecation on Moodle 4.4 MDL-72438
 722       */
 723      public function preview_question_url($questiondata) {
 724          debugging('Function preview_question_url() has been deprecated and moved to qbank_previewquestion plugin,
 725           please use qbank_previewquestion\helper::question_preview_url() instead.', DEBUG_DEVELOPER);
 726          return question_preview_url($questiondata->id, null, null, null, null,
 727                  $this->get_most_specific_context());
 728      }
 729  
 730      /**
 731       * Shows the question bank interface.
 732       *
 733       * The function also processes a number of actions:
 734       *
 735       * Actions affecting the question pool:
 736       * move           Moves a question to a different category
 737       * deleteselected Deletes the selected questions from the category
 738       * Other actions:
 739       * category      Chooses the category
 740       * params: $tabname question bank edit tab name, for permission checking
 741       * $pagevars current list of page variables
 742       *
 743       * @param string $tabname
 744       * @param array $pagevars
 745       */
 746      public function display($pagevars, $tabname): void {
 747  
 748          $page = $pagevars['qpage'];
 749          $perpage = $pagevars['qperpage'];
 750          $cat = $pagevars['cat'];
 751          $recurse = $pagevars['recurse'];
 752          $showhidden = $pagevars['showhidden'];
 753          $showquestiontext = $pagevars['qbshowtext'];
 754          $tagids = [];
 755          if (!empty($pagevars['qtagids'])) {
 756              $tagids = $pagevars['qtagids'];
 757          }
 758  
 759          echo \html_writer::start_div('questionbankwindow boxwidthwide boxaligncenter');
 760  
 761          $editcontexts = $this->contexts->having_one_edit_tab_cap($tabname);
 762  
 763          // Show the filters and search options.
 764          $this->wanted_filters($cat, $tagids, $showhidden, $recurse, $editcontexts, $showquestiontext);
 765  
 766          // Continues with list of questions.
 767          $this->display_question_list($this->baseurl, $cat, null, $page, $perpage,
 768                                          $this->contexts->having_cap('moodle/question:add'));
 769          echo \html_writer::end_div();
 770  
 771      }
 772  
 773      /**
 774       * The filters for the question bank.
 775       *
 776       * @param string $cat 'categoryid,contextid'
 777       * @param array $tagids current list of selected tags
 778       * @param bool $showhidden whether deleted questions should be displayed
 779       * @param int $recurse Whether to include subcategories
 780       * @param array $editcontexts parent contexts
 781       * @param bool $showquestiontext whether the text of each question should be shown in the list
 782       */
 783      public function wanted_filters($cat, $tagids, $showhidden, $recurse, $editcontexts, $showquestiontext): void {
 784          global $CFG;
 785          list(, $contextid) = explode(',', $cat);
 786          $catcontext = \context::instance_by_id($contextid);
 787          $thiscontext = $this->get_most_specific_context();
 788          // Category selection form.
 789          $this->display_question_bank_header();
 790  
 791          // Display tag filter if usetags setting is enabled/enablefilters is true.
 792          if ($this->enablefilters) {
 793              if (is_array($this->customfilterobjects)) {
 794                  foreach ($this->customfilterobjects as $filterobjects) {
 795                      $this->searchconditions[] = $filterobjects;
 796                  }
 797              } else {
 798                  if ($CFG->usetags) {
 799                      array_unshift($this->searchconditions,
 800                              new \core_question\bank\search\tag_condition([$catcontext, $thiscontext], $tagids));
 801                  }
 802  
 803                  array_unshift($this->searchconditions, new \core_question\bank\search\hidden_condition(!$showhidden));
 804                  array_unshift($this->searchconditions, new \core_question\bank\search\category_condition(
 805                          $cat, $recurse, $editcontexts, $this->baseurl, $this->course));
 806              }
 807          }
 808          $this->display_options_form($showquestiontext);
 809      }
 810  
 811      /**
 812       * Print the text if category id not available.
 813       */
 814      protected function print_choose_category_message(): void {
 815          echo \html_writer::start_tag('p', ['style' => "\"text-align:center;\""]);
 816          echo \html_writer::tag('b', get_string('selectcategoryabove', 'question'));
 817          echo \html_writer::end_tag('p');
 818      }
 819  
 820      /**
 821       * Gets current selected category.
 822       * @param string $categoryandcontext
 823       * @return false|mixed|\stdClass
 824       */
 825      protected function get_current_category($categoryandcontext) {
 826          global $DB, $OUTPUT;
 827          list($categoryid, $contextid) = explode(',', $categoryandcontext);
 828          if (!$categoryid) {
 829              $this->print_choose_category_message();
 830              return false;
 831          }
 832  
 833          if (!$category = $DB->get_record('question_categories',
 834                  ['id' => $categoryid, 'contextid' => $contextid])) {
 835              echo $OUTPUT->box_start('generalbox questionbank');
 836              echo $OUTPUT->notification('Category not found!');
 837              echo $OUTPUT->box_end();
 838              return false;
 839          }
 840  
 841          return $category;
 842      }
 843  
 844      /**
 845       * Display the form with options for which questions are displayed and how they are displayed.
 846       *
 847       * @param bool $showquestiontext Display the text of the question within the list.
 848       */
 849      protected function display_options_form($showquestiontext): void {
 850          global $PAGE;
 851  
 852          // The html will be refactored in the filter feature implementation.
 853          echo \html_writer::start_tag('form', ['method' => 'get',
 854                  'action' => new \moodle_url($this->baseurl), 'id' => 'displayoptions']);
 855          echo \html_writer::start_div();
 856  
 857          $excludes = ['recurse', 'showhidden', 'qbshowtext'];
 858          // If the URL contains any tags then we need to prevent them
 859          // being added to the form as hidden elements because the tags
 860          // are managed separately.
 861          if ($this->baseurl->param('qtagids[0]')) {
 862              $index = 0;
 863              while ($this->baseurl->param("qtagids[{$index}]")) {
 864                  $excludes[] = "qtagids[{$index}]";
 865                  $index++;
 866              }
 867          }
 868          echo \html_writer::input_hidden_params($this->baseurl, $excludes);
 869  
 870          $advancedsearch = [];
 871  
 872          foreach ($this->searchconditions as $searchcondition) {
 873              if ($searchcondition->display_options_adv()) {
 874                  $advancedsearch[] = $searchcondition;
 875              }
 876              echo $searchcondition->display_options();
 877          }
 878          $this->display_showtext_checkbox($showquestiontext);
 879          if (!empty($advancedsearch)) {
 880              $this->display_advanced_search_form($advancedsearch);
 881          }
 882  
 883          $go = \html_writer::empty_tag('input', ['type' => 'submit', 'value' => get_string('go')]);
 884          echo \html_writer::tag('noscript', \html_writer::div($go), ['class' => 'inline']);
 885          echo \html_writer::end_div();
 886          echo \html_writer::end_tag('form');
 887          $PAGE->requires->yui_module('moodle-question-searchform', 'M.question.searchform.init');
 888      }
 889  
 890      /**
 891       * Print the "advanced" UI elements for the form to select which questions. Hidden by default.
 892       *
 893       * @param array $advancedsearch
 894       */
 895      protected function display_advanced_search_form($advancedsearch): void {
 896          print_collapsible_region_start('', 'advancedsearch',
 897                  get_string('advancedsearchoptions', 'question'),
 898                  'question_bank_advanced_search');
 899          foreach ($advancedsearch as $searchcondition) {
 900              echo $searchcondition->display_options_adv();
 901          }
 902          print_collapsible_region_end();
 903      }
 904  
 905      /**
 906       * Display the checkbox UI for toggling the display of the question text in the list.
 907       * @param bool $showquestiontext the current or default value for whether to display the text.
 908       */
 909      protected function display_showtext_checkbox($showquestiontext): void {
 910          global $PAGE;
 911          $displaydata = [
 912                  'checked' => $showquestiontext
 913          ];
 914          if (class_exists('qbank_viewquestiontext\\question_text_row')) {
 915              if (\core\plugininfo\qbank::is_plugin_enabled('qbank_viewquestiontext')) {
 916                  echo $PAGE->get_renderer('core_question', 'bank')->render_showtext_checkbox($displaydata);
 917              }
 918          }
 919      }
 920  
 921      /**
 922       * Display the header element for the question bank.
 923       */
 924      protected function display_question_bank_header(): void {
 925          global $OUTPUT;
 926          echo $OUTPUT->heading(get_string('questionbank', 'question'), 2);
 927      }
 928  
 929      /**
 930       * Create a new question form.
 931       *
 932       * @param false|mixed|\stdClass $category
 933       * @param bool $canadd
 934       */
 935      protected function create_new_question_form($category, $canadd): void {
 936          if (\core\plugininfo\qbank::is_plugin_enabled('qbank_editquestion')) {
 937              echo editquestion_helper::create_new_question_button($category->id,
 938                      $this->requiredcolumns['edit_action_column']->editquestionurl->params(), $canadd);
 939          }
 940      }
 941  
 942      /**
 943       * Prints the table of questions in a category with interactions
 944       *
 945       * @param \moodle_url $pageurl     The URL to reload this page.
 946       * @param string     $categoryandcontext 'categoryID,contextID'.
 947       * @param int        $recurse     Whether to include subcategories.
 948       * @param int        $page        The number of the page to be displayed
 949       * @param int        $perpage     Number of questions to show per page
 950       * @param array      $addcontexts contexts where the user is allowed to add new questions.
 951       */
 952      protected function display_question_list($pageurl, $categoryandcontext, $recurse = 1, $page = 0,
 953                                                  $perpage = 100, $addcontexts = []): void {
 954          global $OUTPUT;
 955          // This function can be moderately slow with large question counts and may time out.
 956          // We probably do not want to raise it to unlimited, so randomly picking 5 minutes.
 957          // Note: We do not call this in the loop because quiz ob_ captures this function (see raise() PHP doc).
 958          \core_php_time_limit::raise(300);
 959  
 960          $category = $this->get_current_category($categoryandcontext);
 961  
 962          list($categoryid, $contextid) = explode(',', $categoryandcontext);
 963          $catcontext = \context::instance_by_id($contextid);
 964  
 965          $canadd = has_capability('moodle/question:add', $catcontext);
 966  
 967          $this->create_new_question_form($category, $canadd);
 968  
 969          $this->build_query();
 970          $totalnumber = $this->get_question_count();
 971          if ($totalnumber == 0) {
 972              return;
 973          }
 974          $questionsrs = $this->load_page_questions($page, $perpage);
 975          $questions = [];
 976          foreach ($questionsrs as $question) {
 977              if (!empty($question->id)) {
 978                  $questions[$question->id] = $question;
 979              }
 980          }
 981          $questionsrs->close();
 982  
 983          // Bulk load any required statistics.
 984          $this->load_required_statistics($questions);
 985  
 986          // Bulk load any extra data that any column requires.
 987          foreach ($this->requiredcolumns as $name => $column) {
 988              $column->load_additional_data($questions);
 989          }
 990  
 991          $pageingurl = new \moodle_url($pageurl, $pageurl->params());
 992          $pagingbar = new \paging_bar($totalnumber, $page, $perpage, $pageingurl);
 993          $pagingbar->pagevar = 'qpage';
 994  
 995          $this->display_top_pagnation($OUTPUT->render($pagingbar));
 996  
 997          // This html will be refactored in the bulk actions implementation.
 998          echo \html_writer::start_tag('form', ['action' => $pageurl, 'method' => 'post', 'id' => 'questionsubmit']);
 999          echo \html_writer::start_tag('fieldset', ['class' => 'invisiblefieldset', 'style' => "display: block;"]);
1000          echo \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey()]);
1001          echo \html_writer::input_hidden_params($this->baseurl);
1002  
1003          $this->display_questions($questions);
1004  
1005          $this->display_bottom_pagination($OUTPUT->render($pagingbar), $totalnumber, $perpage, $pageurl);
1006  
1007          $this->display_bottom_controls($catcontext);
1008  
1009          echo \html_writer::end_tag('fieldset');
1010          echo \html_writer::end_tag('form');
1011      }
1012  
1013      /**
1014       * Work out the list of all the required statistics fields for this question bank view.
1015       *
1016       * This gathers all the required fields from all columns, so they can all be loaded at once.
1017       *
1018       * @return string[] the names of all the required fields for this question bank view.
1019       */
1020      protected function determine_required_statistics(): array {
1021          $requiredfields = [];
1022          foreach ($this->requiredcolumns as $column) {
1023              $requiredfields = array_merge($requiredfields, $column->get_required_statistics_fields());
1024          }
1025  
1026          return array_unique($requiredfields);
1027      }
1028  
1029      /**
1030       * Load the aggregate statistics that all the columns require.
1031       *
1032       * @param \stdClass[] $questions the questions that will be displayed indexed by question id.
1033       */
1034      protected function load_required_statistics(array $questions): void {
1035          $requiredstatistics = $this->determine_required_statistics();
1036          $this->loadedstatistics = statistics_bulk_loader::load_aggregate_statistics(
1037                  array_keys($questions), $requiredstatistics);
1038      }
1039  
1040      /**
1041       * Get the aggregated value of a particular statistic for a particular question.
1042       *
1043       * You can only get values for the questions on the current page of the question bank view,
1044       * and only if you declared the need for this statistic in the get_required_statistics_fields()
1045       * method of your question bank column.
1046       *
1047       * @param int $questionid the id of a question
1048       * @param string $fieldname the name of a statistics field, e.g. 'facility'.
1049       * @return float|null the average (across all users) of this statistic for this question.
1050       *      Null if the value is not available right now.
1051       */
1052      public function get_aggregate_statistic(int $questionid, string $fieldname): ?float {
1053          if (!array_key_exists($questionid, $this->loadedstatistics)) {
1054              throw new \coding_exception('Question ' . $questionid . ' is not on the current page of ' .
1055                      'this question bank view, so its statistics are not available.');
1056          }
1057  
1058          // Must be array_key_exists, not isset, because we care about null values.
1059          if (!array_key_exists($fieldname, $this->loadedstatistics[$questionid])) {
1060              throw new \coding_exception('Statistics field ' . $fieldname . ' was not requested by any ' .
1061                      'question bank column in this view, so it is not available.');
1062          }
1063  
1064          return $this->loadedstatistics[$questionid][$fieldname];
1065      }
1066  
1067      /**
1068       * Display the top pagination bar.
1069       *
1070       * @param object $pagination
1071       */
1072      protected function display_top_pagnation($pagination): void {
1073          global $PAGE;
1074          $displaydata = [
1075                  'pagination' => $pagination
1076          ];
1077          echo $PAGE->get_renderer('core_question', 'bank')->render_question_pagination($displaydata);
1078      }
1079  
1080      /**
1081       * Display bottom pagination bar.
1082       *
1083       * @param string $pagination
1084       * @param int $totalnumber
1085       * @param int $perpage
1086       * @param \moodle_url $pageurl
1087       */
1088      protected function display_bottom_pagination($pagination, $totalnumber, $perpage, $pageurl): void {
1089          global $PAGE;
1090          $displaydata = array (
1091                  'extraclasses' => 'pagingbottom',
1092                  'pagination' => $pagination,
1093                  'biggertotal' => true,
1094          );
1095          if ($totalnumber > DEFAULT_QUESTIONS_PER_PAGE) {
1096              $displaydata['showall'] = true;
1097              if ($perpage == DEFAULT_QUESTIONS_PER_PAGE) {
1098                  $url = new \moodle_url($pageurl, array_merge($pageurl->params(),
1099                          ['qpage' => 0, 'qperpage' => MAXIMUM_QUESTIONS_PER_PAGE]));
1100                  if ($totalnumber > MAXIMUM_QUESTIONS_PER_PAGE) {
1101                      $displaydata['totalnumber'] = MAXIMUM_QUESTIONS_PER_PAGE;
1102                  } else {
1103                      $displaydata['biggertotal'] = false;
1104                      $displaydata['totalnumber'] = $totalnumber;
1105                  }
1106              } else {
1107                  $url = new \moodle_url($pageurl, array_merge($pageurl->params(),
1108                          ['qperpage' => DEFAULT_QUESTIONS_PER_PAGE]));
1109                  $displaydata['totalnumber'] = DEFAULT_QUESTIONS_PER_PAGE;
1110              }
1111              $displaydata['showallurl'] = $url;
1112          }
1113          echo $PAGE->get_renderer('core_question', 'bank')->render_question_pagination($displaydata);
1114      }
1115  
1116      /**
1117       * Display the controls at the bottom of the list of questions.
1118       *
1119       * @param \context $catcontext The context of the category being displayed.
1120       */
1121      protected function display_bottom_controls(\context $catcontext): void {
1122          $caneditall = has_capability('moodle/question:editall', $catcontext);
1123          $canuseall = has_capability('moodle/question:useall', $catcontext);
1124          $canmoveall = has_capability('moodle/question:moveall', $catcontext);
1125          if ($caneditall || $canmoveall || $canuseall) {
1126              global $PAGE;
1127              $bulkactiondatas = [];
1128              $params = $this->base_url()->params();
1129              $params['returnurl'] = $this->base_url();
1130              foreach ($this->bulkactions as $key => $action) {
1131                  // Check capabilities.
1132                  $capcount = 0;
1133                  foreach ($action['capabilities'] as $capability) {
1134                      if (has_capability($capability, $catcontext)) {
1135                          $capcount ++;
1136                      }
1137                  }
1138                  // At least one cap need to be there.
1139                  if ($capcount === 0) {
1140                      unset($this->bulkactions[$key]);
1141                      continue;
1142                  }
1143                  $actiondata = new \stdClass();
1144                  $actiondata->actionname = $action['title'];
1145                  $actiondata->actionkey = $key;
1146                  $actiondata->actionurl = new \moodle_url($action['url'], $params);
1147                  $bulkactiondata[] = $actiondata;
1148  
1149                  $bulkactiondatas ['bulkactionitems'] = $bulkactiondata;
1150              }
1151              // We dont need to show this section if none of the plugins are enabled.
1152              if (!empty($bulkactiondatas)) {
1153                  echo $PAGE->get_renderer('core_question', 'bank')->render_bulk_actions_ui($bulkactiondatas);
1154              }
1155          }
1156      }
1157  
1158      /**
1159       * Display the questions.
1160       *
1161       * @param array $questions
1162       */
1163      protected function display_questions($questions): void {
1164          echo \html_writer::start_tag('div',
1165                  ['class' => 'categoryquestionscontainer', 'id' => 'questionscontainer']);
1166          $this->print_table($questions);
1167          echo \html_writer::end_tag('div');
1168      }
1169  
1170      /**
1171       * Prints the actual table with question.
1172       *
1173       * @param array $questions
1174       */
1175      protected function print_table($questions): void {
1176          // Start of the table.
1177          echo \html_writer::start_tag('table', ['id' => 'categoryquestions', 'class' => 'table-responsive']);
1178  
1179          // Prints the table header.
1180          echo \html_writer::start_tag('thead');
1181          echo \html_writer::start_tag('tr');
1182          $this->print_table_headers();
1183          echo \html_writer::end_tag('tr');
1184          echo \html_writer::end_tag('thead');
1185  
1186          // Prints the table row or content.
1187          echo \html_writer::start_tag('tbody');
1188          $rowcount = 0;
1189          foreach ($questions as $question) {
1190              $this->print_table_row($question, $rowcount);
1191              $rowcount += 1;
1192          }
1193          echo \html_writer::end_tag('tbody');
1194  
1195          // End of the table.
1196          echo \html_writer::end_tag('table');
1197      }
1198  
1199      /**
1200       * Start of the table html.
1201       *
1202       * @deprecated since Moodle 4.0
1203       * @see print_table()
1204       * @todo Final deprecation on Moodle 4.4 MDL-72438
1205       */
1206      protected function start_table() {
1207          debugging('Function start_table() is deprecated, please use print_table() instead.', DEBUG_DEVELOPER);
1208          echo '<table id="categoryquestions" class="table table-responsive">' . "\n";
1209          echo "<thead>\n";
1210          $this->print_table_headers();
1211          echo "</thead>\n";
1212          echo "<tbody>\n";
1213      }
1214  
1215      /**
1216       * End of the table html.
1217       *
1218       * @deprecated since Moodle 4.0
1219       * @see print_table()
1220       * @todo Final deprecation on Moodle 4.4 MDL-72438
1221       */
1222      protected function end_table() {
1223          debugging('Function end_table() is deprecated, please use print_table() instead.', DEBUG_DEVELOPER);
1224          echo "</tbody>\n";
1225          echo "</table>\n";
1226      }
1227  
1228      /**
1229       * Print table headers from child classes.
1230       */
1231      protected function print_table_headers(): void {
1232          foreach ($this->visiblecolumns as $column) {
1233              $column->display_header();
1234          }
1235      }
1236  
1237      /**
1238       * Gets the classes for the row.
1239       *
1240       * @param \stdClass $question
1241       * @param int $rowcount
1242       * @return array
1243       */
1244      protected function get_row_classes($question, $rowcount): array {
1245          $classes = [];
1246          if ($question->status === question_version_status::QUESTION_STATUS_HIDDEN) {
1247              $classes[] = 'dimmed_text';
1248          }
1249          if ($question->id == $this->lastchangedid) {
1250              $classes[] = 'highlight text-dark';
1251          }
1252          $classes[] = 'r' . ($rowcount % 2);
1253          return $classes;
1254      }
1255  
1256      /**
1257       * Prints the table row from child classes.
1258       *
1259       * @param \stdClass $question
1260       * @param int $rowcount
1261       */
1262      protected function print_table_row($question, $rowcount): void {
1263          $rowclasses = implode(' ', $this->get_row_classes($question, $rowcount));
1264          $attributes = [];
1265          if ($rowclasses) {
1266              $attributes['class'] = $rowclasses;
1267          }
1268          echo \html_writer::start_tag('tr', $attributes);
1269          foreach ($this->visiblecolumns as $column) {
1270              $column->display($question, $rowclasses);
1271          }
1272          echo \html_writer::end_tag('tr');
1273          foreach ($this->extrarows as $row) {
1274              $row->display($question, $rowclasses);
1275          }
1276      }
1277  
1278      /**
1279       * Process actions for the selected action.
1280       * @deprecated since Moodle 4.0
1281       * @todo Final deprecation on Moodle 4.4 MDL-72438
1282       */
1283      public function process_actions(): void {
1284          debugging('Function process_actions() is deprecated and its code has been completely deleted.
1285           Please, remove the call from your code and check core_question\local\bank\bulk_action_base
1286            to learn more about bulk actions in qbank.', DEBUG_DEVELOPER);
1287          // Associated code is deleted to make sure any incorrect call doesnt not cause any data loss.
1288      }
1289  
1290      /**
1291       * Process actions with ui.
1292       * @return bool
1293       * @deprecated since Moodle 4.0
1294       * @todo Final deprecation on Moodle 4.4 MDL-72438
1295       */
1296      public function process_actions_needing_ui(): bool {
1297          debugging('Function process_actions_needing_ui() is deprecated and its code has been completely deleted.
1298           Please, remove the call from your code and check core_question\local\bank\bulk_action_base
1299            to learn more about bulk actions in qbank.', DEBUG_DEVELOPER);
1300          // Associated code is deleted to make sure any incorrect call doesnt not cause any data loss.
1301          return false;
1302      }
1303  
1304      /**
1305       * Add another search control to this view.
1306       * @param condition $searchcondition the condition to add.
1307       */
1308      public function add_searchcondition($searchcondition): void {
1309          $this->searchconditions[] = $searchcondition;
1310      }
1311  
1312      /**
1313       * Gets visible columns.
1314       * @return array Visible columns.
1315       */
1316      public function get_visiblecolumns(): array {
1317          return $this->visiblecolumns;
1318      }
1319  
1320      /**
1321       * Is this view showing separate versions of a question?
1322       *
1323       * @return bool
1324       */
1325      public function is_listing_specific_versions(): bool {
1326          return false;
1327      }
1328  }