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