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