MDL-66999 theme_boost: @extend remove extends for questions
[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 /**
19  * Class to print a view of the question bank.
20  *
21  * @package   core_question
22  * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
23  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
26 namespace core_question\bank;
27 defined('MOODLE_INTERNAL') || die();
29 use core_question\bank\search\condition;
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', 'edit_menu_column',
181                     'edit_action_column', 'copy_action_column', 'tags_action_column',
182                     'preview_action_column', 'delete_action_column', 'export_xml_action_column',
183                     'creator_name_column', 'modifier_name_column');
184         } else {
185              $questionbankcolumns = explode(',', $CFG->questionbankcolumns);
186         }
187         if (question_get_display_preference('qbshowtext', 0, PARAM_BOOL, new \moodle_url(''))) {
188             $questionbankcolumns[] = 'question_text_row';
189         }
191         foreach ($questionbankcolumns as $fullname) {
192             if (! class_exists($fullname)) {
193                 if (class_exists('core_question\\bank\\' . $fullname)) {
194                     $fullname = 'core_question\\bank\\' . $fullname;
195                 } else {
196                     throw new \coding_exception("No such class exists: $fullname");
197                 }
198             }
199             $this->requiredcolumns[$fullname] = new $fullname($this);
200         }
201         return $this->requiredcolumns;
202     }
205     /**
206      * Get a column object from its name.
207      *
208      * @param string $columnname.
209      * @return \core_question\bank\column_base.
210      */
211     protected function get_column_type($columnname) {
212         if (! class_exists($columnname)) {
213             if (class_exists('core_question\\bank\\' . $columnname)) {
214                 $columnname = 'core_question\\bank\\' . $columnname;
215             } else {
216                 throw new \coding_exception("No such class exists: $columnname");
217             }
218         }
219         if (empty($this->requiredcolumns[$columnname])) {
220             $this->requiredcolumns[$columnname] = new $columnname($this);
221         }
222         return $this->requiredcolumns[$columnname];
223     }
225     /**
226      * Specify the column heading
227      *
228      * @return string Column name for the heading
229      */
230     protected function heading_column() {
231         return 'question_bank_question_name_column';
232     }
234     /**
235      * Initializing table columns
236      *
237      * @param array $wanted Collection of column names
238      * @param string $heading The name of column that is set as heading
239      */
240     protected function init_columns($wanted, $heading = '') {
241         // If we are using the edit menu column, allow it to absorb all the actions.
242         foreach ($wanted as $column) {
243             if ($column instanceof edit_menu_column) {
244                 $wanted = $column->claim_menuable_columns($wanted);
245                 break;
246             }
247         }
249         // Now split columns into real columns and rows.
250         $this->visiblecolumns = array();
251         $this->extrarows = array();
252         foreach ($wanted as $column) {
253             if ($column->is_extra_row()) {
254                 $this->extrarows[get_class($column)] = $column;
255             } else {
256                 $this->visiblecolumns[get_class($column)] = $column;
257             }
258         }
259         if (array_key_exists($heading, $this->requiredcolumns)) {
260             $this->requiredcolumns[$heading]->set_as_heading();
261         }
262     }
264     /**
265      * @param string $colname a column internal name.
266      * @return bool is this column included in the output?
267      */
268     public function has_column($colname) {
269         return isset($this->visiblecolumns[$colname]);
270     }
272     /**
273      * @return int The number of columns in the table.
274      */
275     public function get_column_count() {
276         return count($this->visiblecolumns);
277     }
279     public function get_courseid() {
280         return $this->course->id;
281     }
283     protected function init_sort() {
284         $this->init_sort_from_params();
285         if (empty($this->sort)) {
286             $this->sort = $this->default_sort();
287         }
288     }
290     /**
291      * Deal with a sort name of the form columnname, or colname_subsort by
292      * breaking it up, validating the bits that are present, and returning them.
293      * If there is no subsort, then $subsort is returned as ''.
294      *
295      * @param string $sort the sort parameter to process.
296      * @return array array($colname, $subsort).
297      */
298     protected function parse_subsort($sort) {
299         // Do the parsing.
300         if (strpos($sort, '-') !== false) {
301             list($colname, $subsort) = explode('-', $sort, 2);
302         } else {
303             $colname = $sort;
304             $subsort = '';
305         }
306         // Validate the column name.
307         $column = $this->get_column_type($colname);
308         if (!isset($column) || !$column->is_sortable()) {
309             for ($i = 1; $i <= self::MAX_SORTS; $i++) {
310                 $this->baseurl->remove_params('qbs' . $i);
311             }
312             throw new \moodle_exception('unknownsortcolumn', '', $link = $this->baseurl->out(), $colname);
313         }
314         // Validate the subsort, if present.
315         if ($subsort) {
316             $subsorts = $column->is_sortable();
317             if (!is_array($subsorts) || !isset($subsorts[$subsort])) {
318                 throw new \moodle_exception('unknownsortcolumn', '', $link = $this->baseurl->out(), $sort);
319             }
320         }
321         return array($colname, $subsort);
322     }
324     protected function init_sort_from_params() {
325         $this->sort = array();
326         for ($i = 1; $i <= self::MAX_SORTS; $i++) {
327             if (!$sort = optional_param('qbs' . $i, '', PARAM_TEXT)) {
328                 break;
329             }
330             // Work out the appropriate order.
331             $order = 1;
332             if ($sort[0] == '-') {
333                 $order = -1;
334                 $sort = substr($sort, 1);
335                 if (!$sort) {
336                     break;
337                 }
338             }
339             // Deal with subsorts.
340             list($colname) = $this->parse_subsort($sort);
341             $this->requiredcolumns[$colname] = $this->get_column_type($colname);
342             $this->sort[$sort] = $order;
343         }
344     }
346     protected function sort_to_params($sorts) {
347         $params = array();
348         $i = 0;
349         foreach ($sorts as $sort => $order) {
350             $i += 1;
351             if ($order < 0) {
352                 $sort = '-' . $sort;
353             }
354             $params['qbs' . $i] = $sort;
355         }
356         return $params;
357     }
359     protected function default_sort() {
360         return array(
361             'core_question\bank\question_type_column' => 1,
362             'core_question\bank\question_name_idnumber_tags_column-name' => 1
363         );
364     }
366     /**
367      * @param string $sort a column or column_subsort name.
368      * @return int the current sort order for this column -1, 0, 1
369      */
370     public function get_primary_sort_order($sort) {
371         $order = reset($this->sort);
372         $primarysort = key($this->sort);
373         if ($sort == $primarysort) {
374             return $order;
375         } else {
376             return 0;
377         }
378     }
380     /**
381      * Get a URL to redisplay the page with a new sort for the question bank.
382      *
383      * @param string $sort the column, or column_subsort to sort on.
384      * @param bool $newsortreverse whether to sort in reverse order.
385      * @return string The new URL.
386      */
387     public function new_sort_url($sort, $newsortreverse) {
388         if ($newsortreverse) {
389             $order = -1;
390         } else {
391             $order = 1;
392         }
393         // Tricky code to add the new sort at the start, removing it from where it was before, if it was present.
394         $newsort = array_reverse($this->sort);
395         if (isset($newsort[$sort])) {
396             unset($newsort[$sort]);
397         }
398         $newsort[$sort] = $order;
399         $newsort = array_reverse($newsort);
400         if (count($newsort) > self::MAX_SORTS) {
401             $newsort = array_slice($newsort, 0, self::MAX_SORTS, true);
402         }
403         return $this->baseurl->out(true, $this->sort_to_params($newsort));
404     }
406     /**
407      * Create the SQL query to retrieve the indicated questions
408      *
409      * @param \stdClass $category no longer used.
410      * @param bool $recurse no longer used.
411      * @param bool $showhidden no longer used.
412      * @deprecated since Moodle 2.7 MDL-40313.
413      * @see build_query()
414      * @see \core_question\bank\search\condition
415      * @todo MDL-41978 This will be deleted in Moodle 2.8
416      */
417     protected function build_query_sql($category, $recurse, $showhidden) {
418         debugging('build_query_sql() is deprecated, please use \core_question\bank\view::build_query() and ' .
419                 '\core_question\bank\search\condition classes instead.', DEBUG_DEVELOPER);
420         self::build_query();
421     }
423     /**
424      * Create the SQL query to retrieve the indicated questions, based on
425      * \core_question\bank\search\condition filters.
426      */
427     protected function build_query() {
428         // Get the required tables and fields.
429         $joins = array();
430         $fields = array('q.hidden', 'q.category');
431         foreach ($this->requiredcolumns as $column) {
432             $extrajoins = $column->get_extra_joins();
433             foreach ($extrajoins as $prefix => $join) {
434                 if (isset($joins[$prefix]) && $joins[$prefix] != $join) {
435                     throw new \coding_exception('Join ' . $join . ' conflicts with previous join ' . $joins[$prefix]);
436                 }
437                 $joins[$prefix] = $join;
438             }
439             $fields = array_merge($fields, $column->get_required_fields());
440         }
441         $fields = array_unique($fields);
443         // Build the order by clause.
444         $sorts = array();
445         foreach ($this->sort as $sort => $order) {
446             list($colname, $subsort) = $this->parse_subsort($sort);
447             $sorts[] = $this->requiredcolumns[$colname]->sort_expression($order < 0, $subsort);
448         }
450         // Build the where clause.
451         $tests = array('q.parent = 0');
452         $this->sqlparams = array();
453         foreach ($this->searchconditions as $searchcondition) {
454             if ($searchcondition->where()) {
455                 $tests[] = '((' . $searchcondition->where() .'))';
456             }
457             if ($searchcondition->params()) {
458                 $this->sqlparams = array_merge($this->sqlparams, $searchcondition->params());
459             }
460         }
461         // Build the SQL.
462         $sql = ' FROM {question} q ' . implode(' ', $joins);
463         $sql .= ' WHERE ' . implode(' AND ', $tests);
464         $this->countsql = 'SELECT count(1)' . $sql;
465         $this->loadsql = 'SELECT ' . implode(', ', $fields) . $sql . ' ORDER BY ' . implode(', ', $sorts);
466     }
468     protected function get_question_count() {
469         global $DB;
470         return $DB->count_records_sql($this->countsql, $this->sqlparams);
471     }
473     /**
474      * Load the questions we need to display.
475      *
476      * @param int $page page to display.
477      * @param int $perpage number of questions per page.
478      * @return \moodle_recordset questionid => data about each question.
479      */
480     protected function load_page_questions($page, $perpage) {
481         global $DB;
482         $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, $page * $perpage, $perpage);
483         if (empty($questions)) {
484             $questions->close();
485             // No questions on this page. Reset to page 0.
486             $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, 0, $perpage);
487         }
488         return $questions;
489     }
491     public function base_url() {
492         return $this->baseurl;
493     }
495     /**
496      * Get the URL for editing a question as a {@link \moodle_url}.
497      *
498      * @param int $questionid the question id.
499      * @return \moodle_url the URL, HTML-escaped.
500      */
501     public function edit_question_moodle_url($questionid) {
502         return new \moodle_url($this->editquestionurl, ['id' => $questionid]);
503     }
505     /**
506      * Get the URL for editing a question as a HTML-escaped string.
507      *
508      * @param int $questionid the question id.
509      * @return string the URL, HTML-escaped.
510      */
511     public function edit_question_url($questionid) {
512         return $this->edit_question_moodle_url($questionid)->out();
513     }
515     /**
516      * Get the URL for duplicating a question as a {@link \moodle_url}.
517      *
518      * @param int $questionid the question id.
519      * @return \moodle_url the URL.
520      */
521     public function copy_question_moodle_url($questionid) {
522         return new \moodle_url($this->editquestionurl, ['id' => $questionid, 'makecopy' => 1]);
523     }
525     /**
526      * Get the URL for duplicating a given question.
527      * @param int $questionid the question id.
528      * @return string the URL, HTML-escaped.
529      */
530     public function copy_question_url($questionid) {
531         return $this->copy_question_moodle_url($questionid)->out();
532     }
534     /**
535      * Get the context we are displaying the question bank for.
536      * @return \context context object.
537      */
538     public function get_most_specific_context() {
539         return $this->contexts->lowest();
540     }
542     /**
543      * Get the URL to preview a question.
544      * @param \stdClass $questiondata the data defining the question.
545      * @return \moodle_url the URL.
546      */
547     public function preview_question_url($questiondata) {
548         return question_preview_url($questiondata->id, null, null, null, null,
549                 $this->get_most_specific_context());
550     }
552     /**
553      * Shows the question bank editing interface.
554      *
555      * The function also processes a number of actions:
556      *
557      * Actions affecting the question pool:
558      * move           Moves a question to a different category
559      * deleteselected Deletes the selected questions from the category
560      * Other actions:
561      * category      Chooses the category
562      *
563      * @param string $tabname question bank edit tab name, for permission checking.
564      * @param int $page the page number to show.
565      * @param int $perpage the number of questions per page to show.
566      * @param string $cat 'categoryid,contextid'.
567      * @param int $recurse     Whether to include subcategories.
568      * @param bool $showhidden  whether deleted questions should be displayed.
569      * @param bool $showquestiontext whether the text of each question should be shown in the list. Deprecated.
570      * @param array $tagids current list of selected tags.
571      */
572     public function display($tabname, $page, $perpage, $cat,
573             $recurse, $showhidden, $showquestiontext, $tagids = []) {
574         global $PAGE, $CFG;
576         if ($this->process_actions_needing_ui()) {
577             return;
578         }
579         $editcontexts = $this->contexts->having_one_edit_tab_cap($tabname);
580         list(, $contextid) = explode(',', $cat);
581         $catcontext = \context::instance_by_id($contextid);
582         $thiscontext = $this->get_most_specific_context();
583         // Category selection form.
584         $this->display_question_bank_header();
586         // Display tag filter if usetags setting is enabled.
587         if ($CFG->usetags) {
588             array_unshift($this->searchconditions,
589                     new \core_question\bank\search\tag_condition([$catcontext, $thiscontext], $tagids));
590             $PAGE->requires->js_call_amd('core_question/edit_tags', 'init', ['#questionscontainer']);
591         }
593         array_unshift($this->searchconditions, new \core_question\bank\search\hidden_condition(!$showhidden));
594         array_unshift($this->searchconditions, new \core_question\bank\search\category_condition(
595                 $cat, $recurse, $editcontexts, $this->baseurl, $this->course));
596         $this->display_options_form($showquestiontext);
598         // Continues with list of questions.
599         $this->display_question_list($editcontexts,
600                 $this->baseurl, $cat, $this->cm,
601                 null, $page, $perpage, $showhidden, $showquestiontext,
602                 $this->contexts->having_cap('moodle/question:add'));
604     }
606     protected function print_choose_category_message($categoryandcontext) {
607         echo "<p style=\"text-align:center;\"><b>";
608         print_string('selectcategoryabove', 'question');
609         echo "</b></p>";
610     }
612     protected function get_current_category($categoryandcontext) {
613         global $DB, $OUTPUT;
614         list($categoryid, $contextid) = explode(',', $categoryandcontext);
615         if (!$categoryid) {
616             $this->print_choose_category_message($categoryandcontext);
617             return false;
618         }
620         if (!$category = $DB->get_record('question_categories',
621                 array('id' => $categoryid, 'contextid' => $contextid))) {
622             echo $OUTPUT->box_start('generalbox questionbank');
623             echo $OUTPUT->notification('Category not found!');
624             echo $OUTPUT->box_end();
625             return false;
626         }
628         return $category;
629     }
631     /**
632      * prints category information
633      * @param \stdClass $category the category row from the database.
634      * @deprecated since Moodle 2.7 MDL-40313.
635      * @see \core_question\bank\search\condition
636      * @todo MDL-41978 This will be deleted in Moodle 2.8
637      */
638     protected function print_category_info($category) {
639         $formatoptions = new \stdClass();
640         $formatoptions->noclean = true;
641         $formatoptions->overflowdiv = true;
642         echo '<div class="boxaligncenter">';
643         echo format_text($category->info, $category->infoformat, $formatoptions, $this->course->id);
644         echo "</div>\n";
645     }
647     /**
648      * Prints a form to choose categories
649      * @deprecated since Moodle 2.7 MDL-40313.
650      * @see \core_question\bank\search\condition
651      * @todo MDL-41978 This will be deleted in Moodle 2.8
652      */
653     protected function display_category_form($contexts, $pageurl, $current) {
654         global $OUTPUT;
656         debugging('display_category_form() is deprecated, please use ' .
657                 '\core_question\bank\search\condition instead.', DEBUG_DEVELOPER);
658         // Get all the existing categories now.
659         echo '<div class="choosecategory">';
660         $catmenu = question_category_options($contexts, false, 0, true);
662         $select = new \single_select($this->baseurl, 'category', $catmenu, $current, null, 'catmenu');
663         $select->set_label(get_string('selectacategory', 'question'));
664         echo $OUTPUT->render($select);
665         echo "</div>\n";
666     }
668     /**
669      * Display the options form.
670      * @param bool $recurse no longer used.
671      * @param bool $showhidden no longer used.
672      * @param bool $showquestiontext whether to show the question text.
673      * @deprecated since Moodle 2.7 MDL-40313.
674      * @see display_options_form
675      * @todo MDL-41978 This will be deleted in Moodle 2.8
676      * @see \core_question\bank\search\condition
677      */
678     protected function display_options($recurse, $showhidden, $showquestiontext) {
679         debugging('display_options() is deprecated, please use display_options_form instead.', DEBUG_DEVELOPER);
680         $this->display_options_form($showquestiontext);
681     }
683     /**
684      * Print a single option checkbox.
685      * @deprecated since Moodle 2.7 MDL-40313.
686      * @see \core_question\bank\search\condition
687      * @see html_writer::checkbox
688      * @todo MDL-41978 This will be deleted in Moodle 2.8
689      */
690     protected function display_category_form_checkbox($name, $value, $label) {
691         debugging('display_category_form_checkbox() is deprecated, ' .
692                 'please use \core_question\bank\search\condition instead.', DEBUG_DEVELOPER);
693         echo '<div><input type="hidden" id="' . $name . '_off" name="' . $name . '" value="0" />';
694         echo '<input type="checkbox" id="' . $name . '_on" name="' . $name . '" value="1"';
695         if ($value) {
696             echo ' checked="checked"';
697         }
698         echo ' onchange="getElementById(\'displayoptions\').submit(); return true;" />';
699         echo '<label for="' . $name . '_on">' . $label . '</label>';
700         echo "</div>\n";
701     }
703     /**
704      * Display the form with options for which questions are displayed and how they are displayed.
705      * @param bool $showquestiontext Display the text of the question within the list.
706      * @param string $scriptpath path to the script displaying this page.
707      * @param bool $showtextoption whether to include the 'Show question text' checkbox.
708      */
709     protected function display_options_form($showquestiontext, $scriptpath = '/question/edit.php',
710             $showtextoption = true) {
711         global $PAGE;
713         echo \html_writer::start_tag('form', array('method' => 'get',
714                 'action' => new \moodle_url($scriptpath), 'id' => 'displayoptions'));
715         echo \html_writer::start_div();
717         $excludes = array('recurse', 'showhidden', 'qbshowtext');
718         // If the URL contains any tags then we need to prevent them
719         // being added to the form as hidden elements because the tags
720         // are managed separately.
721         if ($this->baseurl->param('qtagids[0]')) {
722             $index = 0;
723             while ($this->baseurl->param("qtagids[{$index}]")) {
724                 $excludes[] = "qtagids[{$index}]";
725                 $index++;
726             }
727         }
728         echo \html_writer::input_hidden_params($this->baseurl, $excludes);
730         foreach ($this->searchconditions as $searchcondition) {
731             echo $searchcondition->display_options();
732         }
733         if ($showtextoption) {
734             $this->display_showtext_checkbox($showquestiontext);
735         }
736         $this->display_advanced_search_form();
737         $go = \html_writer::empty_tag('input', array('type' => 'submit', 'value' => get_string('go')));
738         echo \html_writer::tag('noscript', \html_writer::div($go), array('class' => 'inline'));
739         echo \html_writer::end_div();
740         echo \html_writer::end_tag('form');
741         $PAGE->requires->yui_module('moodle-question-searchform', 'M.question.searchform.init');
742     }
744     /**
745      * Print the "advanced" UI elements for the form to select which questions. Hidden by default.
746      */
747     protected function display_advanced_search_form() {
748         print_collapsible_region_start('', 'advancedsearch', get_string('advancedsearchoptions', 'question'),
749                                                'question_bank_advanced_search');
750         foreach ($this->searchconditions as $searchcondition) {
751             echo $searchcondition->display_options_adv();
752         }
753         print_collapsible_region_end();
754     }
756     /**
757      * Display the checkbox UI for toggling the display of the question text in the list.
758      * @param bool $showquestiontext the current or default value for whether to display the text.
759      */
760     protected function display_showtext_checkbox($showquestiontext) {
761         echo '<div>';
762         echo \html_writer::empty_tag('input', array('type' => 'hidden', 'name' => 'qbshowtext',
763                                                'value' => 0, 'id' => 'qbshowtext_off'));
764         echo \html_writer::checkbox('qbshowtext', '1', $showquestiontext, ' ' . get_string('showquestiontext', 'question'),
765                                        array('id' => 'qbshowtext_on', 'class' => 'searchoptions mr-1'));
766         echo "</div>\n";
767     }
769     /**
770      * Display the header element for the question bank.
771      */
772     protected function display_question_bank_header() {
773         global $OUTPUT;
774         echo $OUTPUT->heading(get_string('questionbank', 'question'), 2);
775     }
777     protected function create_new_question_form($category, $canadd) {
778         echo '<div class="createnewquestion">';
779         if ($canadd) {
780             create_new_question_button($category->id, $this->editquestionurl->params(),
781                     get_string('createnewquestion', 'question'));
782         } else {
783             print_string('nopermissionadd', 'question');
784         }
785         echo '</div>';
786     }
788     /**
789      * Prints the table of questions in a category with interactions
790      *
791      * @param array      $contexts    Not used!
792      * @param \moodle_url $pageurl     The URL to reload this page.
793      * @param string     $categoryandcontext 'categoryID,contextID'.
794      * @param \stdClass  $cm          Not used!
795      * @param int        $recurse     Whether to include subcategories.
796      * @param int        $page        The number of the page to be displayed
797      * @param int        $perpage     Number of questions to show per page
798      * @param bool       $showhidden  Not used! This is now controlled in a different way.
799      * @param bool       $showquestiontext Not used! This is now controlled in a different way.
800      * @param array      $addcontexts contexts where the user is allowed to add new questions.
801      */
802     protected function display_question_list($contexts, $pageurl, $categoryandcontext,
803             $cm = null, $recurse=1, $page=0, $perpage=100, $showhidden=false,
804             $showquestiontext = false, $addcontexts = array()) {
805         global $OUTPUT;
807         // This function can be moderately slow with large question counts and may time out.
808         // We probably do not want to raise it to unlimited, so randomly picking 5 minutes.
809         // Note: We do not call this in the loop because quiz ob_ captures this function (see raise() PHP doc).
810         \core_php_time_limit::raise(300);
812         $category = $this->get_current_category($categoryandcontext);
814         list($categoryid, $contextid) = explode(',', $categoryandcontext);
815         $catcontext = \context::instance_by_id($contextid);
817         $canadd = has_capability('moodle/question:add', $catcontext);
819         $this->create_new_question_form($category, $canadd);
821         $this->build_query();
822         $totalnumber = $this->get_question_count();
823         if ($totalnumber == 0) {
824             return;
825         }
826         $questionsrs = $this->load_page_questions($page, $perpage);
827         $questions = [];
828         foreach ($questionsrs as $question) {
829             $questions[$question->id] = $question;
830         }
831         $questionsrs->close();
832         foreach ($this->requiredcolumns as $name => $column) {
833             $column->load_additional_data($questions);
834         }
836         echo '<div class="categorypagingbarcontainer">';
837         $pageingurl = new \moodle_url('edit.php', $pageurl->params());
838         $pagingbar = new \paging_bar($totalnumber, $page, $perpage, $pageingurl);
839         $pagingbar->pagevar = 'qpage';
840         echo $OUTPUT->render($pagingbar);
841         echo '</div>';
843         echo '<form method="post" action="edit.php">';
844         echo '<fieldset class="invisiblefieldset" style="display: block;">';
845         echo '<input type="hidden" name="sesskey" value="'.sesskey().'" />';
846         echo \html_writer::input_hidden_params($this->baseurl);
848         echo '<div class="categoryquestionscontainer" id="questionscontainer">';
849         $this->start_table();
850         $rowcount = 0;
851         foreach ($questions as $question) {
852             $this->print_table_row($question, $rowcount);
853             $rowcount += 1;
854         }
855         $this->end_table();
856         echo "</div>\n";
858         echo '<div class="categorypagingbarcontainer pagingbottom">';
859         echo $OUTPUT->render($pagingbar);
860         if ($totalnumber > DEFAULT_QUESTIONS_PER_PAGE) {
861             if ($perpage == DEFAULT_QUESTIONS_PER_PAGE) {
862                 $url = new \moodle_url('edit.php', array_merge($pageurl->params(),
863                         array('qperpage' => MAXIMUM_QUESTIONS_PER_PAGE)));
864                 if ($totalnumber > MAXIMUM_QUESTIONS_PER_PAGE) {
865                     $showall = '<a href="'.$url.'">'.get_string('showperpage', 'moodle', MAXIMUM_QUESTIONS_PER_PAGE).'</a>';
866                 } else {
867                     $showall = '<a href="'.$url.'">'.get_string('showall', 'moodle', $totalnumber).'</a>';
868                 }
869             } else {
870                 $url = new \moodle_url('edit.php', array_merge($pageurl->params(),
871                                               array('qperpage' => DEFAULT_QUESTIONS_PER_PAGE)));
872                 $showall = '<a href="'.$url.'">'.get_string('showperpage', 'moodle', DEFAULT_QUESTIONS_PER_PAGE).'</a>';
873             }
874             echo "<div class='paging'>{$showall}</div>";
875         }
876         echo '</div>';
878         $this->display_bottom_controls($totalnumber, $recurse, $category, $catcontext, $addcontexts);
880         echo '</fieldset>';
881         echo "</form>\n";
882     }
884     /**
885      * Display the controls at the bottom of the list of questions.
886      * @param int      $totalnumber Total number of questions that might be shown (if it was not for paging).
887      * @param bool     $recurse     Whether to include subcategories.
888      * @param \stdClass $category    The question_category row from the database.
889      * @param \context  $catcontext  The context of the category being displayed.
890      * @param array    $addcontexts contexts where the user is allowed to add new questions.
891      */
892     protected function display_bottom_controls($totalnumber, $recurse, $category, \context $catcontext, array $addcontexts) {
893         $caneditall = has_capability('moodle/question:editall', $catcontext);
894         $canuseall = has_capability('moodle/question:useall', $catcontext);
895         $canmoveall = has_capability('moodle/question:moveall', $catcontext);
897         echo '<div class="modulespecificbuttonscontainer">';
898         if ($caneditall || $canmoveall || $canuseall) {
899             echo '<strong>&nbsp;'.get_string('withselected', 'question').':</strong><br />';
901             // Print delete and move selected question.
902             if ($caneditall) {
903                 echo \html_writer::empty_tag('input', [
904                     'type' => 'submit',
905                     'class' => 'btn btn-secondary mr-1',
906                     'name' => 'deleteselected',
907                     'value' => get_string('delete'),
908                     'data-action' => 'toggle',
909                     'data-togglegroup' => 'qbank',
910                     'data-toggle' => 'action',
911                     'disabled' => true,
912                 ]);
913             }
915             if ($canmoveall && count($addcontexts)) {
916                 echo \html_writer::empty_tag('input', [
917                     'type' => 'submit',
918                     'class' => 'btn btn-secondary mr-1',
919                     'name' => 'move',
920                     'value' => get_string('moveto', 'question'),
921                     'data-action' => 'toggle',
922                     'data-togglegroup' => 'qbank',
923                     'data-toggle' => 'action',
924                     'disabled' => true,
925                 ]);
926                 question_category_select_menu($addcontexts, false, 0, "{$category->id},{$category->contextid}");
927             }
928         }
929         echo "</div>\n";
930     }
932     protected function start_table() {
933         echo '<table id="categoryquestions">' . "\n";
934         echo "<thead>\n";
935         $this->print_table_headers();
936         echo "</thead>\n";
937         echo "<tbody>\n";
938     }
940     protected function end_table() {
941         echo "</tbody>\n";
942         echo "</table>\n";
943     }
945     protected function print_table_headers() {
946         echo "<tr>\n";
947         foreach ($this->visiblecolumns as $column) {
948             $column->display_header();
949         }
950         echo "</tr>\n";
951     }
953     protected function get_row_classes($question, $rowcount) {
954         $classes = array();
955         if ($question->hidden) {
956             $classes[] = 'dimmed_text';
957         }
958         if ($question->id == $this->lastchangedid) {
959             $classes[] = 'highlight text-dark';
960         }
961         $classes[] = 'r' . ($rowcount % 2);
962         return $classes;
963     }
965     protected function print_table_row($question, $rowcount) {
966         $rowclasses = implode(' ', $this->get_row_classes($question, $rowcount));
967         if ($rowclasses) {
968             echo '<tr class="' . $rowclasses . '">' . "\n";
969         } else {
970             echo "<tr>\n";
971         }
972         foreach ($this->visiblecolumns as $column) {
973             $column->display($question, $rowclasses);
974         }
975         echo "</tr>\n";
976         foreach ($this->extrarows as $row) {
977             $row->display($question, $rowclasses);
978         }
979     }
981     public function process_actions() {
982         global $DB;
983         // Now, check for commands on this page and modify variables as necessary.
984         if (optional_param('move', false, PARAM_BOOL) and confirm_sesskey()) {
985             // Move selected questions to new category.
986             $category = required_param('category', PARAM_SEQUENCE);
987             list($tocategoryid, $contextid) = explode(',', $category);
988             if (! $tocategory = $DB->get_record('question_categories', array('id' => $tocategoryid, 'contextid' => $contextid))) {
989                 print_error('cannotfindcate', 'question');
990             }
991             $tocontext = \context::instance_by_id($contextid);
992             require_capability('moodle/question:add', $tocontext);
993             $rawdata = (array) data_submitted();
994             $questionids = array();
995             foreach ($rawdata as $key => $value) {  // Parse input for question ids.
996                 if (preg_match('!^q([0-9]+)$!', $key, $matches)) {
997                     $key = $matches[1];
998                     $questionids[] = $key;
999                 }
1000             }
1001             if ($questionids) {
1002                 list($usql, $params) = $DB->get_in_or_equal($questionids);
1003                 $questions = $DB->get_records_sql("
1004                         SELECT q.*, c.contextid
1005                         FROM {question} q
1006                         JOIN {question_categories} c ON c.id = q.category
1007                         WHERE q.id {$usql}", $params);
1008                 foreach ($questions as $question) {
1009                     question_require_capability_on($question, 'move');
1010                 }
1011                 question_move_questions_to_category($questionids, $tocategory->id);
1012                 redirect($this->baseurl->out(false,
1013                         array('category' => "{$tocategoryid},{$contextid}")));
1014             }
1015         }
1017         if (optional_param('deleteselected', false, PARAM_BOOL)) { // Delete selected questions from the category.
1018             // If teacher has already confirmed the action.
1019             if (($confirm = optional_param('confirm', '', PARAM_ALPHANUM)) and confirm_sesskey()) {
1020                 $deleteselected = required_param('deleteselected', PARAM_RAW);
1021                 if ($confirm == md5($deleteselected)) {
1022                     if ($questionlist = explode(',', $deleteselected)) {
1023                         // For each question either hide it if it is in use or delete it.
1024                         foreach ($questionlist as $questionid) {
1025                             $questionid = (int)$questionid;
1026                             question_require_capability_on($questionid, 'edit');
1027                             if (questions_in_use(array($questionid))) {
1028                                 $DB->set_field('question', 'hidden', 1, array('id' => $questionid));
1029                             } else {
1030                                 question_delete_question($questionid);
1031                             }
1032                         }
1033                     }
1034                     redirect($this->baseurl);
1035                 } else {
1036                     print_error('invalidconfirm', 'question');
1037                 }
1038             }
1039         }
1041         // Unhide a question.
1042         if (($unhide = optional_param('unhide', '', PARAM_INT)) and confirm_sesskey()) {
1043             question_require_capability_on($unhide, 'edit');
1044             $DB->set_field('question', 'hidden', 0, array('id' => $unhide));
1046             // Purge these questions from the cache.
1047             \question_bank::notify_question_edited($unhide);
1049             redirect($this->baseurl);
1050         }
1051     }
1053     public function process_actions_needing_ui() {
1054         global $DB, $OUTPUT;
1055         if (optional_param('deleteselected', false, PARAM_BOOL)) {
1056             // Make a list of all the questions that are selected.
1057             $rawquestions = $_REQUEST; // This code is called by both POST forms and GET links, so cannot use data_submitted.
1058             $questionlist = '';  // comma separated list of ids of questions to be deleted
1059             $questionnames = ''; // string with names of questions separated by <br /> with
1060                                  // an asterix in front of those that are in use
1061             $inuse = false;      // set to true if at least one of the questions is in use
1062             foreach ($rawquestions as $key => $value) {    // Parse input for question ids.
1063                 if (preg_match('!^q([0-9]+)$!', $key, $matches)) {
1064                     $key = $matches[1];
1065                     $questionlist .= $key.',';
1066                     question_require_capability_on((int)$key, 'edit');
1067                     if (questions_in_use(array($key))) {
1068                         $questionnames .= '* ';
1069                         $inuse = true;
1070                     }
1071                     $questionnames .= $DB->get_field('question', 'name', array('id' => $key)) . '<br />';
1072                 }
1073             }
1074             if (!$questionlist) { // No questions were selected.
1075                 redirect($this->baseurl);
1076             }
1077             $questionlist = rtrim($questionlist, ',');
1079             // Add an explanation about questions in use.
1080             if ($inuse) {
1081                 $questionnames .= '<br />'.get_string('questionsinuse', 'question');
1082             }
1083             $baseurl = new \moodle_url('edit.php', $this->baseurl->params());
1084             $deleteurl = new \moodle_url($baseurl, array('deleteselected' => $questionlist, 'confirm' => md5($questionlist),
1085                                                  'sesskey' => sesskey()));
1087             $continue = new \single_button($deleteurl, get_string('delete'), 'post');
1088             echo $OUTPUT->confirm(get_string('deletequestionscheck', 'question', $questionnames), $continue, $baseurl);
1090             return true;
1091         }
1093         return false;
1094     }
1096     /**
1097      * Add another search control to this view.
1098      * @param condition $searchcondition the condition to add.
1099      */
1100     public function add_searchcondition($searchcondition) {
1101         $this->searchconditions[] = $searchcondition;
1102     }