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