MDL-66553 question: Behat fixes
[moodle.git] / question / classes / bank / view.php
CommitLineData
17f229fa
RM
1<?php
2// This file is part of Moodle - http://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16
17
18namespace core_question\bank;
19
d30c6cdc
TH
20use core_question\bank\search\condition;
21
17f229fa
RM
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 */
30
31
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 */
52class view {
53 const MAX_SORTS = 3;
54
d30c6cdc
TH
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 */
17f229fa 59 protected $baseurl;
d30c6cdc
TH
60
61 /**
62 * @var \moodle_url used as a basis for URLs that edit a question.
63 */
17f229fa 64 protected $editquestionurl;
d30c6cdc
TH
65
66 /**
67 * @var \question_edit_contexts
68 */
17f229fa 69 protected $contexts;
d30c6cdc
TH
70
71 /**
72 * @var object|\cm_info|null if we are in a module context, the cm.
73 */
17f229fa 74 protected $cm;
d30c6cdc
TH
75
76 /**
77 * @var object the course we are within.
78 */
17f229fa 79 protected $course;
d30c6cdc
TH
80
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;
86
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 */
17f229fa 91 protected $visiblecolumns;
d30c6cdc
TH
92
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 */
17f229fa 98 protected $extrarows;
d30c6cdc
TH
99
100 /**
101 * @var array list of column class names for which columns to sort on.
102 */
17f229fa 103 protected $sort;
d30c6cdc
TH
104
105 /**
106 * @var int|null id of the a question to highlight in the list (if present).
107 */
17f229fa 108 protected $lastchangedid;
d30c6cdc
TH
109
110 /**
111 * @var string SQL to count the number of questions matching the current
112 * search conditions.
113 */
17f229fa 114 protected $countsql;
d30c6cdc
TH
115
116 /**
117 * @var string SQL to actually load the question data to display.
118 */
17f229fa 119 protected $loadsql;
d30c6cdc
TH
120
121 /**
122 * @var array params used by $countsql and $loadsql (which currently must be the same).
123 */
17f229fa 124 protected $sqlparams;
d30c6cdc
TH
125
126 /**
127 * @var condition[] search conditions.
128 */
17f229fa
RM
129 protected $searchconditions = array();
130
131 /**
132 * Constructor
b116f8b9
TH
133 * @param \question_edit_contexts $contexts
134 * @param \moodle_url $pageurl
17f229fa
RM
135 * @param object $course course settings
136 * @param object $cm (optional) activity settings.
137 */
138 public function __construct($contexts, $pageurl, $course, $cm = null) {
17f229fa
RM
139 $this->contexts = $contexts;
140 $this->baseurl = $pageurl;
141 $this->course = $course;
142 $this->cm = $cm;
143
17f229fa
RM
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 }
153
154 $this->lastchangedid = optional_param('lastchanged', 0, PARAM_INT);
155
156 $this->init_columns($this->wanted_columns(), $this->heading_column());
157 $this->init_sort();
d30c6cdc 158 $this->init_search_conditions();
17f229fa
RM
159 }
160
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 }
174
175 protected function wanted_columns() {
176 global $CFG;
177
178 if (empty($CFG->questionbankcolumns)) {
179 $questionbankcolumns = array('checkbox_column', 'question_type_column',
61cc1e64
TH
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');
17f229fa
RM
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 }
189
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 }
202
203
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 }
223
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 }
232
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 }
253
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 }
261
262 /**
263 * @return int The number of columns in the table.
264 */
265 public function get_column_count() {
266 return count($this->visiblecolumns);
267 }
268
269 public function get_courseid() {
270 return $this->course->id;
271 }
272
273 protected function init_sort() {
274 $this->init_sort_from_params();
275 if (empty($this->sort)) {
276 $this->sort = $this->default_sort();
277 }
278 }
279
280 /**
281 * Deal with a sort name of the form columnname, or colname_subsort by
d30c6cdc 282 * breaking it up, validating the bits that are present, and returning them.
17f229fa 283 * If there is no subsort, then $subsort is returned as ''.
d30c6cdc
TH
284 *
285 * @param string $sort the sort parameter to process.
17f229fa
RM
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 }
313
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.
d30c6cdc 330 list($colname) = $this->parse_subsort($sort);
17f229fa
RM
331 $this->requiredcolumns[$colname] = $this->get_column_type($colname);
332 $this->sort[$sort] = $order;
333 }
334 }
335
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 }
348
349 protected function default_sort() {
0aa17f26
JP
350 return array(
351 'core_question\bank\question_type_column' => 1,
352 'core_question\bank\question_name_idnumber_tags_column-name' => 1
353 );
17f229fa
RM
354 }
355
356 /**
d30c6cdc 357 * @param string $sort a column or column_subsort name.
17f229fa
RM
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 }
369
370 /**
371 * Get a URL to redisplay the page with a new sort for the question bank.
d30c6cdc 372 *
17f229fa
RM
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 }
395
396 /**
397 * Create the SQL query to retrieve the indicated questions
d30c6cdc
TH
398 *
399 * @param \stdClass $category no longer used.
17f229fa
RM
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 }
412
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() {
17f229fa
RM
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);
432
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 }
439
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 }
457
458 protected function get_question_count() {
459 global $DB;
460 return $DB->count_records_sql($this->countsql, $this->sqlparams);
461 }
462
d30c6cdc
TH
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 */
17f229fa
RM
470 protected function load_page_questions($page, $perpage) {
471 global $DB;
472 $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, $page * $perpage, $perpage);
61cc1e64 473 if (empty($questions)) {
a938e409 474 $questions->close();
61cc1e64 475 // No questions on this page. Reset to page 0.
17f229fa
RM
476 $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, 0, $perpage);
477 }
478 return $questions;
479 }
480
481 public function base_url() {
482 return $this->baseurl;
483 }
484
485 public function edit_question_url($questionid) {
486 return $this->editquestionurl->out(true, array('id' => $questionid));
487 }
488
489 /**
490 * Get the URL for duplicating a given question.
491 * @param int $questionid the question id.
d30c6cdc 492 * @return string the URL, HTML-escaped.
17f229fa
RM
493 */
494 public function copy_question_url($questionid) {
495 return $this->editquestionurl->out(true, array('id' => $questionid, 'makecopy' => 1));
496 }
497
498 /**
499 * Get the context we are displaying the question bank for.
d30c6cdc 500 * @return \context context object.
17f229fa
RM
501 */
502 public function get_most_specific_context() {
503 return $this->contexts->lowest();
504 }
505
506 /**
507 * Get the URL to preview a question.
d30c6cdc
TH
508 * @param \stdClass $questiondata the data defining the question.
509 * @return \moodle_url the URL.
17f229fa
RM
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 }
515
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
d30c6cdc
TH
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.
17f229fa
RM
535 */
536 public function display($tabname, $page, $perpage, $cat,
fff03332 537 $recurse, $showhidden, $showquestiontext, $tagids = []) {
2cfd8d16 538 global $PAGE, $CFG;
17f229fa
RM
539
540 if ($this->process_actions_needing_ui()) {
541 return;
542 }
543 $editcontexts = $this->contexts->having_one_edit_tab_cap($tabname);
d30c6cdc 544 list(, $contextid) = explode(',', $cat);
fff03332 545 $catcontext = \context::instance_by_id($contextid);
ad54e635 546 $thiscontext = $this->get_most_specific_context();
17f229fa 547 // Category selection form.
f33dc783 548 $this->display_question_bank_header();
2cfd8d16
SL
549
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 }
556
17f229fa
RM
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);
561
562 // Continues with list of questions.
f33dc783 563 $this->display_question_list($editcontexts,
17f229fa
RM
564 $this->baseurl, $cat, $this->cm,
565 null, $page, $perpage, $showhidden, $showquestiontext,
566 $this->contexts->having_cap('moodle/question:add'));
fd5e2ead 567
17f229fa
RM
568 }
569
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 }
575
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 }
583
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 }
591
592 return $category;
593 }
594
595 /**
596 * prints category information
d30c6cdc 597 * @param \stdClass $category the category row from the database.
17f229fa
RM
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 }
610
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;
619
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);
625
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 }
631
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);
d30c6cdc 644 $this->display_options_form($showquestiontext);
17f229fa
RM
645 }
646
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 }
666
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.
d30c6cdc 670 * @param string $scriptpath path to the script displaying this page.
84deaaf8 671 * @param bool $showtextoption whether to include the 'Show question text' checkbox.
17f229fa 672 */
84deaaf8
TH
673 protected function display_options_form($showquestiontext, $scriptpath = '/question/edit.php',
674 $showtextoption = true) {
17f229fa
RM
675 global $PAGE;
676
84deaaf8
TH
677 echo \html_writer::start_tag('form', array('method' => 'get',
678 'action' => new \moodle_url($scriptpath), 'id' => 'displayoptions'));
679 echo \html_writer::start_div();
fff03332
RW
680
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);
17f229fa
RM
693
694 foreach ($this->searchconditions as $searchcondition) {
d30c6cdc 695 echo $searchcondition->display_options();
17f229fa 696 }
84deaaf8
TH
697 if ($showtextoption) {
698 $this->display_showtext_checkbox($showquestiontext);
699 }
17f229fa 700 $this->display_advanced_search_form();
84deaaf8
TH
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');
17f229fa 705 $PAGE->requires->yui_module('moodle-question-searchform', 'M.question.searchform.init');
17f229fa
RM
706 }
707
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) {
d30c6cdc 715 echo $searchcondition->display_options_adv();
17f229fa
RM
716 }
717 print_collapsible_region_end();
718 }
719
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 }
732
f33dc783
RW
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 }
740
17f229fa 741 protected function create_new_question_form($category, $canadd) {
17f229fa
RM
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 }
751
752 /**
753 * Prints the table of questions in a category with interactions
754 *
755 * @param array $contexts Not used!
d30c6cdc 756 * @param \moodle_url $pageurl The URL to reload this page.
17f229fa 757 * @param string $categoryandcontext 'categoryID,contextID'.
d30c6cdc
TH
758 * @param \stdClass $cm Not used!
759 * @param int $recurse Whether to include subcategories.
17f229fa
RM
760 * @param int $page The number of the page to be displayed
761 * @param int $perpage Number of questions to show per page
d30c6cdc
TH
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.
17f229fa
RM
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()) {
d30c6cdc 769 global $OUTPUT;
17f229fa 770
c7092fe4
EM
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);
775
17f229fa
RM
776 $category = $this->get_current_category($categoryandcontext);
777
17f229fa
RM
778 list($categoryid, $contextid) = explode(',', $categoryandcontext);
779 $catcontext = \context::instance_by_id($contextid);
780
781 $canadd = has_capability('moodle/question:add', $catcontext);
17f229fa
RM
782
783 $this->create_new_question_form($category, $canadd);
784
785 $this->build_query();
786 $totalnumber = $this->get_question_count();
787 if ($totalnumber == 0) {
788 return;
789 }
61cc1e64
TH
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 }
17f229fa
RM
799
800 echo '<div class="categorypagingbarcontainer">';
d30c6cdc 801 $pageingurl = new \moodle_url('edit.php', $pageurl->params());
17f229fa
RM
802 $pagingbar = new \paging_bar($totalnumber, $page, $perpage, $pageingurl);
803 $pagingbar->pagevar = 'qpage';
804 echo $OUTPUT->render($pagingbar);
805 echo '</div>';
806
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);
811
fd5e2ead 812 echo '<div class="categoryquestionscontainer" id="questionscontainer">';
17f229fa
RM
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";
821
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) {
a8524f33
EM
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 }
17f229fa
RM
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 }
f4fe3968 838 echo "<div class='paging'>{$showall}</div>";
17f229fa
RM
839 }
840 echo '</div>';
841
e1a2d0d9
CC
842 $this->display_bottom_controls($totalnumber, $recurse, $category, $catcontext, $addcontexts);
843
844 echo '</fieldset>';
845 echo "</form>\n";
846 }
847
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.
d30c6cdc
TH
852 * @param \stdClass $category The question_category row from the database.
853 * @param \context $catcontext The context of the category being displayed.
e1a2d0d9
CC
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);
860
17f229fa
RM
861 echo '<div class="modulespecificbuttonscontainer">';
862 if ($caneditall || $canmoveall || $canuseall) {
863 echo '<strong>&nbsp;'.get_string('withselected', 'question').':</strong><br />';
864
17f229fa
RM
865 // Print delete and move selected question.
866 if ($caneditall) {
cbd8641a
JP
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 ]);
17f229fa
RM
877 }
878
879 if ($canmoveall && count($addcontexts)) {
cbd8641a
JP
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 ]);
f4fe3968 890 question_category_select_menu($addcontexts, false, 0, "{$category->id},{$category->contextid}");
17f229fa 891 }
17f229fa
RM
892 }
893 echo "</div>\n";
17f229fa
RM
894 }
895
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 }
903
904 protected function end_table() {
905 echo "</tbody>\n";
906 echo "</table>\n";
907 }
908
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 }
916
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 }
928
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 }
944
945 public function process_actions() {
d30c6cdc 946 global $DB;
17f229fa
RM
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);
17f229fa
RM
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
f4fe3968 971 WHERE q.id {$usql}", $params);
17f229fa
RM
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,
f4fe3968 977 array('category' => "{$tocategoryid},{$contextid}")));
17f229fa
RM
978 }
979 }
980
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 }
1004
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));
1009
1010 // Purge these questions from the cache.
b25e15b2 1011 \question_bank::notify_question_edited($unhide);
17f229fa
RM
1012
1013 redirect($this->baseurl);
1014 }
1015 }
1016
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.',';
2cf7bde8 1030 question_require_capability_on((int)$key, 'edit');
17f229fa
RM
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, ',');
1042
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()));
1050
65377780
AD
1051 $continue = new \single_button($deleteurl, get_string('delete'), 'post');
1052 echo $OUTPUT->confirm(get_string('deletequestionscheck', 'question', $questionnames), $continue, $baseurl);
17f229fa
RM
1053
1054 return true;
1055 }
d30c6cdc
TH
1056
1057 return false;
17f229fa
RM
1058 }
1059
1060 /**
1061 * Add another search control to this view.
d30c6cdc 1062 * @param condition $searchcondition the condition to add.
17f229fa
RM
1063 */
1064 public function add_searchcondition($searchcondition) {
1065 $this->searchconditions[] = $searchcondition;
1066 }
1067}