MDL-66999 theme_boost: @extend remove extends for questions
[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
17f229fa 18/**
701ae1eb 19 * Class to print a view of the question bank.
17f229fa 20 *
701ae1eb
TH
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
17f229fa
RM
24 */
25
701ae1eb
TH
26namespace core_question\bank;
27defined('MOODLE_INTERNAL') || die();
28
29use core_question\bank\search\condition;
30
17f229fa
RM
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 *
701ae1eb
TH
49 * @copyright 2009 Tim Hunt
50 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
17f229fa
RM
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',
c11c8892 180 'question_name_idnumber_tags_column', 'edit_menu_column',
701ae1eb
TH
181 'edit_action_column', 'copy_action_column', 'tags_action_column',
182 'preview_action_column', 'delete_action_column', 'export_xml_action_column',
c11c8892 183 'creator_name_column', 'modifier_name_column');
17f229fa
RM
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 }
190
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 }
203
204
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 }
224
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 }
233
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 = '') {
701ae1eb
TH
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 }
248
249 // Now split columns into real columns and rows.
17f229fa
RM
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 }
263
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 }
271
272 /**
273 * @return int The number of columns in the table.
274 */
275 public function get_column_count() {
276 return count($this->visiblecolumns);
277 }
278
279 public function get_courseid() {
280 return $this->course->id;
281 }
282
283 protected function init_sort() {
284 $this->init_sort_from_params();
285 if (empty($this->sort)) {
286 $this->sort = $this->default_sort();
287 }
288 }
289
290 /**
291 * Deal with a sort name of the form columnname, or colname_subsort by
d30c6cdc 292 * breaking it up, validating the bits that are present, and returning them.
17f229fa 293 * If there is no subsort, then $subsort is returned as ''.
d30c6cdc
TH
294 *
295 * @param string $sort the sort parameter to process.
17f229fa
RM
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 }
323
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.
d30c6cdc 340 list($colname) = $this->parse_subsort($sort);
17f229fa
RM
341 $this->requiredcolumns[$colname] = $this->get_column_type($colname);
342 $this->sort[$sort] = $order;
343 }
344 }
345
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 }
358
359 protected function default_sort() {
0aa17f26
JP
360 return array(
361 'core_question\bank\question_type_column' => 1,
362 'core_question\bank\question_name_idnumber_tags_column-name' => 1
363 );
17f229fa
RM
364 }
365
366 /**
d30c6cdc 367 * @param string $sort a column or column_subsort name.
17f229fa
RM
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 }
379
380 /**
381 * Get a URL to redisplay the page with a new sort for the question bank.
d30c6cdc 382 *
17f229fa
RM
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 }
405
406 /**
407 * Create the SQL query to retrieve the indicated questions
d30c6cdc
TH
408 *
409 * @param \stdClass $category no longer used.
17f229fa
RM
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 }
422
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() {
17f229fa
RM
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);
442
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 }
449
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 }
467
468 protected function get_question_count() {
469 global $DB;
470 return $DB->count_records_sql($this->countsql, $this->sqlparams);
471 }
472
d30c6cdc
TH
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 */
17f229fa
RM
480 protected function load_page_questions($page, $perpage) {
481 global $DB;
482 $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, $page * $perpage, $perpage);
61cc1e64 483 if (empty($questions)) {
a938e409 484 $questions->close();
61cc1e64 485 // No questions on this page. Reset to page 0.
17f229fa
RM
486 $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, 0, $perpage);
487 }
488 return $questions;
489 }
490
491 public function base_url() {
492 return $this->baseurl;
493 }
494
701ae1eb
TH
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 }
504
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 */
17f229fa 511 public function edit_question_url($questionid) {
701ae1eb
TH
512 return $this->edit_question_moodle_url($questionid)->out();
513 }
514
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]);
17f229fa
RM
523 }
524
525 /**
526 * Get the URL for duplicating a given question.
527 * @param int $questionid the question id.
d30c6cdc 528 * @return string the URL, HTML-escaped.
17f229fa
RM
529 */
530 public function copy_question_url($questionid) {
701ae1eb 531 return $this->copy_question_moodle_url($questionid)->out();
17f229fa
RM
532 }
533
534 /**
535 * Get the context we are displaying the question bank for.
d30c6cdc 536 * @return \context context object.
17f229fa
RM
537 */
538 public function get_most_specific_context() {
539 return $this->contexts->lowest();
540 }
541
542 /**
543 * Get the URL to preview a question.
d30c6cdc
TH
544 * @param \stdClass $questiondata the data defining the question.
545 * @return \moodle_url the URL.
17f229fa
RM
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 }
551
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
d30c6cdc
TH
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.
17f229fa
RM
571 */
572 public function display($tabname, $page, $perpage, $cat,
fff03332 573 $recurse, $showhidden, $showquestiontext, $tagids = []) {
2cfd8d16 574 global $PAGE, $CFG;
17f229fa
RM
575
576 if ($this->process_actions_needing_ui()) {
577 return;
578 }
579 $editcontexts = $this->contexts->having_one_edit_tab_cap($tabname);
d30c6cdc 580 list(, $contextid) = explode(',', $cat);
fff03332 581 $catcontext = \context::instance_by_id($contextid);
ad54e635 582 $thiscontext = $this->get_most_specific_context();
17f229fa 583 // Category selection form.
f33dc783 584 $this->display_question_bank_header();
2cfd8d16
SL
585
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 }
592
17f229fa
RM
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);
597
598 // Continues with list of questions.
f33dc783 599 $this->display_question_list($editcontexts,
17f229fa
RM
600 $this->baseurl, $cat, $this->cm,
601 null, $page, $perpage, $showhidden, $showquestiontext,
602 $this->contexts->having_cap('moodle/question:add'));
fd5e2ead 603
17f229fa
RM
604 }
605
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 }
611
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 }
619
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 }
627
628 return $category;
629 }
630
631 /**
632 * prints category information
d30c6cdc 633 * @param \stdClass $category the category row from the database.
17f229fa
RM
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 }
646
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;
655
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);
661
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 }
667
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);
d30c6cdc 680 $this->display_options_form($showquestiontext);
17f229fa
RM
681 }
682
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 }
702
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.
d30c6cdc 706 * @param string $scriptpath path to the script displaying this page.
84deaaf8 707 * @param bool $showtextoption whether to include the 'Show question text' checkbox.
17f229fa 708 */
84deaaf8
TH
709 protected function display_options_form($showquestiontext, $scriptpath = '/question/edit.php',
710 $showtextoption = true) {
17f229fa
RM
711 global $PAGE;
712
84deaaf8
TH
713 echo \html_writer::start_tag('form', array('method' => 'get',
714 'action' => new \moodle_url($scriptpath), 'id' => 'displayoptions'));
715 echo \html_writer::start_div();
fff03332
RW
716
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);
17f229fa
RM
729
730 foreach ($this->searchconditions as $searchcondition) {
d30c6cdc 731 echo $searchcondition->display_options();
17f229fa 732 }
84deaaf8
TH
733 if ($showtextoption) {
734 $this->display_showtext_checkbox($showquestiontext);
735 }
17f229fa 736 $this->display_advanced_search_form();
84deaaf8
TH
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');
17f229fa 741 $PAGE->requires->yui_module('moodle-question-searchform', 'M.question.searchform.init');
17f229fa
RM
742 }
743
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) {
d30c6cdc 751 echo $searchcondition->display_options_adv();
17f229fa
RM
752 }
753 print_collapsible_region_end();
754 }
755
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'));
f68616d7
LB
764 echo \html_writer::checkbox('qbshowtext', '1', $showquestiontext, ' ' . get_string('showquestiontext', 'question'),
765 array('id' => 'qbshowtext_on', 'class' => 'searchoptions mr-1'));
17f229fa
RM
766 echo "</div>\n";
767 }
768
f33dc783
RW
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 }
776
17f229fa 777 protected function create_new_question_form($category, $canadd) {
17f229fa
RM
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 }
787
788 /**
789 * Prints the table of questions in a category with interactions
790 *
791 * @param array $contexts Not used!
d30c6cdc 792 * @param \moodle_url $pageurl The URL to reload this page.
17f229fa 793 * @param string $categoryandcontext 'categoryID,contextID'.
d30c6cdc
TH
794 * @param \stdClass $cm Not used!
795 * @param int $recurse Whether to include subcategories.
17f229fa
RM
796 * @param int $page The number of the page to be displayed
797 * @param int $perpage Number of questions to show per page
d30c6cdc
TH
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.
17f229fa
RM
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()) {
d30c6cdc 805 global $OUTPUT;
17f229fa 806
c7092fe4
EM
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);
811
17f229fa
RM
812 $category = $this->get_current_category($categoryandcontext);
813
17f229fa
RM
814 list($categoryid, $contextid) = explode(',', $categoryandcontext);
815 $catcontext = \context::instance_by_id($contextid);
816
817 $canadd = has_capability('moodle/question:add', $catcontext);
17f229fa
RM
818
819 $this->create_new_question_form($category, $canadd);
820
821 $this->build_query();
822 $totalnumber = $this->get_question_count();
823 if ($totalnumber == 0) {
824 return;
825 }
61cc1e64
TH
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 }
17f229fa
RM
835
836 echo '<div class="categorypagingbarcontainer">';
d30c6cdc 837 $pageingurl = new \moodle_url('edit.php', $pageurl->params());
17f229fa
RM
838 $pagingbar = new \paging_bar($totalnumber, $page, $perpage, $pageingurl);
839 $pagingbar->pagevar = 'qpage';
840 echo $OUTPUT->render($pagingbar);
841 echo '</div>';
842
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);
847
fd5e2ead 848 echo '<div class="categoryquestionscontainer" id="questionscontainer">';
17f229fa
RM
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";
857
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) {
a8524f33
EM
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 }
17f229fa
RM
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 }
f4fe3968 874 echo "<div class='paging'>{$showall}</div>";
17f229fa
RM
875 }
876 echo '</div>';
877
e1a2d0d9
CC
878 $this->display_bottom_controls($totalnumber, $recurse, $category, $catcontext, $addcontexts);
879
880 echo '</fieldset>';
881 echo "</form>\n";
882 }
883
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.
d30c6cdc
TH
888 * @param \stdClass $category The question_category row from the database.
889 * @param \context $catcontext The context of the category being displayed.
e1a2d0d9
CC
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);
896
17f229fa
RM
897 echo '<div class="modulespecificbuttonscontainer">';
898 if ($caneditall || $canmoveall || $canuseall) {
899 echo '<strong>&nbsp;'.get_string('withselected', 'question').':</strong><br />';
900
17f229fa
RM
901 // Print delete and move selected question.
902 if ($caneditall) {
cbd8641a
JP
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 ]);
17f229fa
RM
913 }
914
915 if ($canmoveall && count($addcontexts)) {
cbd8641a
JP
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 ]);
f4fe3968 926 question_category_select_menu($addcontexts, false, 0, "{$category->id},{$category->contextid}");
17f229fa 927 }
17f229fa
RM
928 }
929 echo "</div>\n";
17f229fa
RM
930 }
931
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 }
939
940 protected function end_table() {
941 echo "</tbody>\n";
942 echo "</table>\n";
943 }
944
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 }
952
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) {
aa100b76 959 $classes[] = 'highlight text-dark';
17f229fa
RM
960 }
961 $classes[] = 'r' . ($rowcount % 2);
962 return $classes;
963 }
964
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 }
980
981 public function process_actions() {
d30c6cdc 982 global $DB;
17f229fa
RM
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);
17f229fa
RM
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
f4fe3968 1007 WHERE q.id {$usql}", $params);
17f229fa
RM
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,
f4fe3968 1013 array('category' => "{$tocategoryid},{$contextid}")));
17f229fa
RM
1014 }
1015 }
1016
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 }
1040
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));
1045
1046 // Purge these questions from the cache.
b25e15b2 1047 \question_bank::notify_question_edited($unhide);
17f229fa
RM
1048
1049 redirect($this->baseurl);
1050 }
1051 }
1052
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.',';
2cf7bde8 1066 question_require_capability_on((int)$key, 'edit');
17f229fa
RM
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, ',');
1078
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()));
1086
65377780
AD
1087 $continue = new \single_button($deleteurl, get_string('delete'), 'post');
1088 echo $OUTPUT->confirm(get_string('deletequestionscheck', 'question', $questionnames), $continue, $baseurl);
17f229fa
RM
1089
1090 return true;
1091 }
d30c6cdc
TH
1092
1093 return false;
17f229fa
RM
1094 }
1095
1096 /**
1097 * Add another search control to this view.
d30c6cdc 1098 * @param condition $searchcondition the condition to add.
17f229fa
RM
1099 */
1100 public function add_searchcondition($searchcondition) {
1101 $this->searchconditions[] = $searchcondition;
1102 }
1103}