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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body