Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
/**
18
 * Class to print a view of the question bank.
19
 *
20
 * @package   core_question
21
 * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
22
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
 
25
namespace core_question\local\bank;
26
 
27
defined('MOODLE_INTERNAL') || die();
28
 
29
require_once($CFG->dirroot . '/question/editlib.php');
30
 
31
use coding_exception;
32
use core\plugininfo\qbank;
33
use core\output\datafilter;
34
use core_plugin_manager;
35
use core_question\local\bank\condition;
36
use core_question\local\statistics\statistics_bulk_loader;
37
use core_question\output\question_bank_filter_ui;
38
use core_question\local\bank\column_manager_base;
39
use qbank_deletequestion\hidden_condition;
40
use qbank_editquestion\editquestion_helper;
41
use qbank_managecategories\category_condition;
42
 
43
/**
44
 * This class prints a view of the question bank.
45
 *
46
 * including
47
 *  + Some controls to allow users to to select what is displayed.
48
 *  + A list of questions as a table.
49
 *  + Further controls to do things with the questions.
50
 *
51
 * This class gives a basic view, and provides plenty of hooks where subclasses
52
 * can override parts of the display.
53
 *
54
 * The list of questions presented as a table is generated by creating a list of
55
 * core_question\bank\column objects, one for each 'column' to be displayed. These
56
 * manage
57
 *  + outputting the contents of that column, given a $question object, but also
58
 *  + generating the right fragments of SQL to ensure the necessary data is present,
59
 *    and sorted in the right order.
60
 *  + outputting table headers.
61
 *
62
 * @copyright 2009 Tim Hunt
63
 * @author    2021 Safat Shahin <safatshahin@catalyst-au.net>
64
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
65
 */
66
class view {
67
 
68
    /**
69
     * Maximum number of sorts allowed.
70
     */
71
    const MAX_SORTS = 3;
72
 
73
    /**
74
     * @var \moodle_url base URL for the current page. Used as the
75
     * basis for making URLs for actions that reload the page.
76
     */
77
    protected $baseurl;
78
 
79
    /**
80
     * @var \moodle_url used as a basis for URLs that edit a question.
81
     */
82
    protected $editquestionurl;
83
 
84
    /**
85
     * @var \core_question\local\bank\question_edit_contexts
86
     */
87
    public $contexts;
88
 
89
    /**
90
     * @var object|\cm_info|null if we are in a module context, the cm.
91
     */
92
    public $cm;
93
 
94
    /**
95
     * @var object the course we are within.
96
     */
97
    public $course;
98
 
99
    /**
100
     * @var column_base[] these are all the 'columns' that are
101
     * part of the display. Array keys are the class name.
102
     */
103
    protected $requiredcolumns;
104
 
105
    /**
106
     * @var question_action_base[] these are all the actions that can be displayed in a question's action menu.
107
     *
108
     * Array keys are the class name.
109
     */
110
    protected $questionactions;
111
 
112
    /**
113
     * @var column_base[] these are the 'columns' that are
114
     * actually displayed as a column, in order. Array keys are the class name.
115
     */
116
    protected $visiblecolumns;
117
 
118
    /**
119
     * @var column_base[] these are the 'columns' that are
120
     * common to the question bank.
121
     */
122
    protected $corequestionbankcolumns;
123
 
124
    /**
125
     * @var column_base[] these are the 'columns' that are
126
     * actually displayed as an additional row (e.g. question text), in order.
127
     * Array keys are the class name.
128
     */
129
    protected $extrarows;
130
 
131
    /**
132
     * @var array list of column class names for which columns to sort on.
133
     */
134
    protected $sort;
135
 
136
    /**
137
     * @var int page size to use (when we are not showing all questions).
138
     */
139
    protected $pagesize = DEFAULT_QUESTIONS_PER_PAGE;
140
 
141
    /**
142
     * @var int|null id of the a question to highlight in the list (if present).
143
     */
144
    protected $lastchangedid;
145
 
146
    /**
147
     * @var string SQL to count the number of questions matching the current
148
     * search conditions.
149
     */
150
    protected $countsql;
151
 
152
    /**
153
     * @var string SQL to actually load the question data to display.
154
     */
155
    protected $loadsql;
156
 
157
    /**
158
     * @var array params used by $countsql and $loadsql (which currently must be the same).
159
     */
160
    protected $sqlparams;
161
 
162
    /**
163
     * @var ?array Stores all the average statistics that this question bank view needs.
164
     *
165
     * This field gets initialised in {@see display_question_list()}. It is a two dimensional
166
     * $this->loadedstatistics[$questionid][$fieldname] = $average value of that statistics for that question.
167
     * Column classes in qbank plugins can access these values using {@see get_aggregate_statistic()}.
168
     */
169
    protected $loadedstatistics = null;
170
 
171
    /**
172
     * @var condition[] search conditions.
173
     */
174
    protected $searchconditions = [];
175
 
176
    /**
177
     * @var string url of the new question page.
178
     */
179
    public $returnurl;
180
 
181
    /**
182
     * @var array $bulkactions to identify the bulk actions for the api.
183
     */
184
    public $bulkactions = [];
185
 
186
    /**
187
     * @var int|null Number of questions.
188
     */
189
    protected $totalcount = null;
190
 
191
    /**
192
     * @var array Parameters for the page URL.
193
     */
194
    protected $pagevars = [];
195
 
196
    /**
197
     * @var plugin_features_base[] $plugins Plugin feature objects for all enabled qbank plugins.
198
     */
199
    protected $plugins = [];
200
 
201
    /**
202
     * @var string $component the component the api is used from.
203
     */
204
    public $component = 'core_question';
205
 
206
    /**
207
     * @var string $callback name of the callback for the api call via filter js.
208
     */
209
    public $callback = 'question_data';
210
 
211
    /**
212
     * @var array $extraparams extra parameters for the extended apis.
213
     */
214
    public $extraparams = [];
215
 
216
    /**
217
     * @var column_manager_base $columnmanager The column manager, can be overridden by plugins.
218
     */
219
    protected $columnmanager;
220
 
221
    /**
222
     * Constructor for view.
223
     *
224
     * @param \core_question\local\bank\question_edit_contexts $contexts
225
     * @param \moodle_url $pageurl
226
     * @param object $course course settings
227
     * @param null $cm (optional) activity settings.
228
     * @param array $params the parameters required to initialize the api.
229
     * @param array $extraparams any extra parameters required by a particular view class.
230
     */
231
    public function __construct($contexts, $pageurl, $course, $cm = null, $params = [], $extraparams = []) {
232
        $this->contexts = $contexts;
233
        $this->baseurl = $pageurl;
234
        $this->course = $course;
235
        $this->cm = $cm;
236
        $this->extraparams = $extraparams;
237
 
238
        // Default filter condition.
239
        if (!isset($params['filter']) && isset($params['cat'])) {
240
            $params['filter']  = [];
241
            [$categoryid, $contextid] = category_condition::validate_category_param($params['cat']);
242
            if (!is_null($categoryid)) {
243
                $category = category_condition::get_category_record($categoryid, $contextid);
244
                $params['filter']['category'] = [
245
                    'jointype' => category_condition::JOINTYPE_DEFAULT,
246
                    'values' => [$category->id],
247
                    'filteroptions' => ['includesubcategories' => false],
248
                ];
249
            }
250
            $params['filter']['hidden'] = [
251
                'jointype' => hidden_condition::JOINTYPE_DEFAULT,
252
                'values' => [0],
253
            ];
254
            $params['jointype'] = datafilter::JOINTYPE_ALL;
255
        }
256
        if (!empty($params['filter'])) {
257
            $params['filter'] = filter_condition_manager::unpack_filteroptions_param($params['filter']);
258
        }
259
        if (isset($params['filter']['jointype'])) {
260
            $params['jointype'] = $params['filter']['jointype'];
261
            unset($params['filter']['jointype']);
262
        }
263
 
264
        // Create the url of the new question page to forward to.
265
        $this->returnurl = $pageurl->out_as_local_url(false);
266
        $this->editquestionurl = new \moodle_url('/question/bank/editquestion/question.php', ['returnurl' => $this->returnurl]);
267
        if ($this->cm !== null) {
268
            $this->editquestionurl->param('cmid', $this->cm->id);
269
        } else {
270
            $this->editquestionurl->param('courseid', $this->course->id);
271
        }
272
 
273
        $this->lastchangedid = clean_param($pageurl->param('lastchanged'), PARAM_INT);
274
 
275
        $this->init_plugins();
276
        $this->init_column_manager();
277
        // Possibly the heading part can be removed.
278
        $this->set_pagevars($params);
279
        $this->init_columns($this->wanted_columns(), $this->heading_column());
280
        $this->init_question_actions();
281
        $this->init_sort();
282
        $this->init_bulk_actions();
283
    }
284
 
285
    /**
286
     * Get an array of plugin features objects for all enabled qbank plugins.
287
     *
288
     * @return void
289
     */
290
    protected function init_plugins(): void {
291
        $plugins = \core_component::get_plugin_list_with_class('qbank', 'plugin_feature', 'plugin_feature.php');
292
        foreach ($plugins as $componentname => $pluginclass) {
293
            if (!\core\plugininfo\qbank::is_plugin_enabled($componentname)) {
294
                continue;
295
            }
296
            $this->plugins[$componentname] = new $pluginclass();
297
        }
298
        // Sort plugin list by component name.
299
        ksort($this->plugins);
300
    }
301
 
302
    /**
303
     * Allow qbank plugins to override the column manager.
304
     *
305
     * If multiple qbank plugins define a column manager, this will pick the first one sorted alphabetically.
306
     *
307
     * @return void
308
     */
309
    protected function init_column_manager(): void {
310
        $this->columnmanager = new column_manager_base();
311
        foreach ($this->plugins as $plugin) {
312
            if ($columnmanager = $plugin->get_column_manager()) {
313
                $this->columnmanager = $columnmanager;
314
                break;
315
            }
316
        }
317
    }
318
 
319
    /**
320
     * Initialize bulk actions.
321
     */
322
    protected function init_bulk_actions(): void {
323
        foreach ($this->plugins as $componentname => $plugin) {
324
            $bulkactions = $plugin->get_bulk_actions();
325
            if (!is_array($bulkactions)) {
326
                debugging("The method {$componentname}::get_bulk_actions() must return an " .
327
                    "array of bulk actions instead of a single bulk action. " .
328
                    "Please update your implementation of get_bulk_actions() to return an array. " .
329
                    "Check out the qbank_bulkmove plugin for a working example.", DEBUG_DEVELOPER);
330
                $bulkactions = [$bulkactions];
331
            }
332
 
333
            foreach ($bulkactions as $bulkactionobject) {
334
                $this->bulkactions[$bulkactionobject->get_key()] = [
335
                    'title' => $bulkactionobject->get_bulk_action_title(),
336
                    'url' => $bulkactionobject->get_bulk_action_url(),
337
                    'capabilities' => $bulkactionobject->get_bulk_action_capabilities()
338
                ];
339
            }
340
        }
341
    }
342
 
343
    /**
344
     * Initialize search conditions from plugins
345
     * local_*_get_question_bank_search_conditions() must return an array of
346
     * \core_question\bank\search\condition objects.
347
     *
348
     * @deprecated Since Moodle 4.3
349
     * @todo Final deprecation on Moodle 4.7 MDL-78090
350
     */
351
    protected function init_search_conditions(): void {
352
        debugging(
353
            'Function init_search_conditions() has been deprecated, please create a qbank plugin' .
354
                'and implement a filter object instead.',
355
            DEBUG_DEVELOPER
356
        );
357
        $searchplugins = get_plugin_list_with_function('local', 'get_question_bank_search_conditions');
358
        foreach ($searchplugins as $component => $function) {
359
            foreach ($function($this) as $searchobject) {
360
                $this->add_searchcondition($searchobject);
361
            }
362
        }
363
    }
364
 
365
    /**
366
     * Initialise list of menu actions for enabled question bank plugins.
367
     *
368
     * Menu action objects are stored in $this->menuactions, keyed by class name.
369
     *
370
     * @return void
371
     */
372
    protected function init_question_actions(): void {
373
        $this->questionactions = [];
374
        foreach ($this->plugins as $plugin) {
375
            $menuactions = $plugin->get_question_actions($this);
376
            foreach ($menuactions as $menuaction) {
377
                $this->questionactions[$menuaction::class] = $menuaction;
378
                if ($menuaction->get_menu_position() === question_action_base::MENU_POSITION_NOT_SET) {
379
                    debugging('Question bank actions must define the get_menu_position method. ' .
380
                        $menuaction::class . ' does not.', DEBUG_DEVELOPER);
381
                }
382
            }
383
        }
384
 
385
        // Sort according to each action's desired position.
386
        // Note, we are relying on the sort to be stable for
387
        // equal values of get_menu_position.
388
        uasort(
389
            $this->questionactions,
390
            function (question_action_base $a, question_action_base $b) {
391
                return $a->get_menu_position() <=> $b->get_menu_position();
392
            },
393
        );
394
    }
395
 
396
    /**
397
     * Get class for each question bank columns.
398
     *
399
     * @return array
400
     */
401
    protected function get_question_bank_plugins(): array {
402
        $questionbankclasscolumns = [];
403
        $newpluginclasscolumns = [];
404
        $corequestionbankcolumns = [
405
            'core_question\local\bank\checkbox_column' . column_base::ID_SEPARATOR . 'checkbox_column',
406
            'core_question\local\bank\edit_menu_column' . column_base::ID_SEPARATOR . 'edit_menu_column',
407
        ];
408
 
409
        foreach ($corequestionbankcolumns as $columnid) {
410
            [$columnclass, $columnname] = explode(column_base::ID_SEPARATOR, $columnid, 2);
411
            if (class_exists($columnclass)) {
412
                $questionbankclasscolumns[$columnid] = $columnclass::from_column_name($this, $columnname);
413
            }
414
        }
415
 
416
        foreach ($this->plugins as $plugin) {
417
            $plugincolumnobjects = $plugin->get_question_columns($this);
418
            foreach ($plugincolumnobjects as $columnobject) {
419
                $columnid = $columnobject->get_column_id();
420
                foreach ($corequestionbankcolumns as $corequestionbankcolumn) {
421
                    // Check if it has custom preference selector to view/hide.
422
                    if ($columnobject->has_preference()) {
423
                        if (!$columnobject->get_preference()) {
424
                            continue;
425
                        }
426
                    }
427
                    if ($corequestionbankcolumn === $columnid) {
428
                        $questionbankclasscolumns[$columnid] = $columnobject;
429
                    } else {
430
                        // Any community plugin for column/action.
431
                        $newpluginclasscolumns[$columnid] = $columnobject;
432
                    }
433
                }
434
            }
435
        }
436
 
437
        // New plugins added at the end of the array, will change in sorting feature.
438
        foreach ($newpluginclasscolumns as $key => $newpluginclasscolumn) {
439
            $questionbankclasscolumns[$key] = $newpluginclasscolumn;
440
        }
441
 
442
        $questionbankclasscolumns = $this->columnmanager->get_sorted_columns($questionbankclasscolumns);
443
        $questionbankclasscolumns = $this->columnmanager->set_columns_visibility($questionbankclasscolumns);
444
 
445
        // Mitigate the error in case of any regression.
446
        foreach ($questionbankclasscolumns as $shortname => $questionbankclasscolumn) {
447
            if (!is_object($questionbankclasscolumn) || !$questionbankclasscolumn->isvisible) {
448
                unset($questionbankclasscolumns[$shortname]);
449
            }
450
        }
451
 
452
        return $questionbankclasscolumns;
453
    }
454
 
455
    /**
456
     * Loads all the available columns.
457
     *
458
     * @return array
459
     */
460
    protected function wanted_columns(): array {
461
        $this->requiredcolumns = [];
462
        $questionbankcolumns = $this->get_question_bank_plugins();
463
        foreach ($questionbankcolumns as $classobject) {
464
            if (empty($classobject) || !($classobject instanceof \core_question\local\bank\column_base)) {
465
                continue;
466
            }
467
            $this->requiredcolumns[$classobject->get_column_name()] = $classobject;
468
        }
469
 
470
        return $this->requiredcolumns;
471
    }
472
 
473
 
474
    /**
475
     * Check a column object from its name and get the object for sort.
476
     *
477
     * @param string $columnname
478
     */
479
    protected function get_column_type($columnname) {
480
        if (empty($this->requiredcolumns[$columnname])) {
481
            $this->requiredcolumns[$columnname] = new $columnname($this);
482
        }
483
    }
484
 
485
    /**
486
     * Specify the column heading
487
     *
488
     * @return string Column name for the heading
489
     */
490
    protected function heading_column(): string {
491
        return 'qbank_viewquestionname\viewquestionname_column_helper';
492
    }
493
 
494
    /**
495
     * Initializing table columns
496
     *
497
     * @param array $wanted Collection of column names
498
     * @param string $heading The name of column that is set as heading
499
     */
500
    protected function init_columns($wanted, $heading = ''): void {
501
        // Now split columns into real columns and rows.
502
        $this->visiblecolumns = [];
503
        $this->extrarows = [];
504
        foreach ($wanted as $column) {
505
            if ($column->is_extra_row()) {
506
                $this->extrarows[$column->get_column_name()] = $column;
507
            } else {
508
                // Only add columns which are visible.
509
                if ($column->isvisible) {
510
                    $this->visiblecolumns[$column->get_column_name()] = $column;
511
                }
512
            }
513
        }
514
 
515
        if (array_key_exists($heading, $this->requiredcolumns)) {
516
            $this->requiredcolumns[$heading]->set_as_heading();
517
        }
518
    }
519
 
520
    /**
521
     * Checks if the column included in the output.
522
     *
523
     * @param string $colname a column internal name.
524
     * @return bool is this column included in the output?
525
     */
526
    public function has_column($colname): bool {
527
        return isset($this->visiblecolumns[$colname]);
528
    }
529
 
530
    /**
531
     * Get the count of the columns.
532
     *
533
     * @return int The number of columns in the table.
534
     */
535
    public function get_column_count(): int {
536
        return count($this->visiblecolumns);
537
    }
538
 
539
    /**
540
     * Get course id.
541
     * @return mixed
542
     */
543
    public function get_courseid() {
544
        return $this->course->id;
545
    }
546
 
547
    /**
548
     * Initialise sorting.
549
     */
550
    protected function init_sort(): void {
551
        $this->sort = [];
552
        $sorts = optional_param_array('sortdata', [], PARAM_INT);
553
        if (empty($sorts)) {
554
            $sorts = $this->get_pagevars('sortdata');
555
        }
556
        if (empty($sorts)) {
557
            $sorts = $this->default_sort();
558
        }
559
        $sorts = array_slice($sorts, 0, self::MAX_SORTS);
560
        foreach ($sorts as $sortname => $sortorder) {
561
            // Deal with subsorts.
562
            [$colname] = $this->parse_subsort($sortname);
563
            $this->get_column_type($colname);
564
        }
565
        $this->sort = $sorts;
566
    }
567
 
568
    /**
569
     * Deal with a sort name of the form columnname, or colname_subsort by
570
     * breaking it up, validating the bits that are present, and returning them.
571
     * If there is no subsort, then $subsort is returned as ''.
572
     *
573
     * @param string $sort the sort parameter to process.
574
     * @return array [$colname, $subsort].
575
     */
576
    protected function parse_subsort($sort): array {
577
        // Do the parsing.
578
        if (strpos($sort, '-') !== false) {
579
            list($colname, $subsort) = explode('-', $sort, 2);
580
        } else {
581
            $colname = $sort;
582
            $subsort = '';
583
        }
584
        $colname = str_replace('__', '\\', $colname);
585
        // Validate the column name.
586
        $this->get_column_type($colname);
587
        $column = $this->requiredcolumns[$colname];
588
        if (!isset($column) || !$column->is_sortable()) {
589
            $this->baseurl->remove_params('sortdata');
590
            throw new \moodle_exception('unknownsortcolumn', '', $this->baseurl->out(), $colname);
591
        }
592
        // Validate the subsort, if present.
593
        if ($subsort) {
594
            $subsorts = $column->is_sortable();
595
            if (!is_array($subsorts) || !isset($subsorts[$subsort])) {
596
                throw new \moodle_exception('unknownsortcolumn', '', $this->baseurl->out(), $sort);
597
            }
598
        }
599
        return [$colname, $subsort];
600
    }
601
 
602
    /**
603
     * Sort to parameters.
604
     *
605
     * @param array $sorts
606
     * @return array
607
     */
608
    protected function sort_to_params($sorts): array {
609
        $params = [];
610
        foreach ($sorts as $sortname => $sortorder) {
611
            $params['sortdata[' . $sortname . ']'] = $sortorder;
612
        }
613
        return $params;
614
    }
615
 
616
    /**
617
     * Default sort for question data.
618
     * @return int[]
619
     */
620
    protected function default_sort(): array {
621
        $defaultsort = [];
622
        if (class_exists('\\qbank_viewquestiontype\\question_type_column')) {
623
            $defaultsort['qbank_viewquestiontype__question_type_column'] = SORT_ASC;
624
        }
625
        if (class_exists('\\qbank_viewquestionname\\question_name_idnumber_tags_column')) {
626
            $defaultsort['qbank_viewquestionname__question_name_idnumber_tags_column-name'] = SORT_ASC;
627
        }
628
 
629
        return $defaultsort;
630
    }
631
 
632
    /**
633
     * Gets the primary sort order according to the default sort.
634
     *
635
     * @param string $sortname a column or column_subsort name.
636
     * @return int the current sort order for this column -1, 0, 1
637
     */
638
    public function get_primary_sort_order($sortname): int {
639
        $order = reset($this->sort);
640
        $primarysort = key($this->sort);
641
        if ($sortname == $primarysort) {
642
            return $order;
643
        }
644
 
645
        return 0;
646
    }
647
 
648
    /**
649
     * Get a URL to redisplay the page with a new sort for the question bank.
650
     *
651
     * @param string $sortname the column, or column_subsort to sort on.
652
     * @param bool $newsortreverse whether to sort in reverse order.
653
     * @return string The new URL.
654
     */
655
    public function new_sort_url($sortname, $newsortreverse): string {
656
        // Tricky code to add the new sort at the start, removing it from where it was before, if it was present.
657
        $newsort = array_reverse($this->sort);
658
        if (isset($newsort[$sortname])) {
659
            unset($newsort[$sortname]);
660
        }
661
        $newsort[$sortname] = $newsortreverse ? SORT_DESC : SORT_ASC;
662
        $newsort = array_reverse($newsort);
663
        if (count($newsort) > self::MAX_SORTS) {
664
            $newsort = array_slice($newsort, 0, self::MAX_SORTS, true);
665
        }
666
        return $this->baseurl->out(true, $this->sort_to_params($newsort));
667
    }
668
 
669
    /**
670
     * Return an array 'table_alias' => 'JOIN clause' to bring in any data that
671
     * the core view requires.
672
     *
673
     * @return string[] 'table_alias' => 'JOIN clause'
674
     */
675
    protected function get_required_joins(): array {
676
        return [
677
            'qv' => 'JOIN {question_versions} qv ON qv.questionid = q.id',
678
            'qbe' => 'JOIN {question_bank_entries} qbe on qbe.id = qv.questionbankentryid',
679
            'qc' => 'JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid',
680
        ];
681
    }
682
 
683
    /**
684
     * Return an array of fields for any data that the core view requires.
685
     *
686
     * Use table alias 'q' for the question table, or one of the ones from get_required_joins.
687
     * Every field requested must specify a table prefix.
688
     *
689
     * @return string[] fields required.
690
     */
691
    protected function get_required_fields(): array {
692
        return [
693
            'q.id',
694
            'q.qtype',
695
            'q.createdby',
696
            'qc.id as categoryid',
697
            'qc.contextid',
698
            'qv.status',
699
            'qv.version',
700
            'qv.id as versionid',
701
            'qbe.id as questionbankentryid',
702
        ];
703
    }
704
 
705
    /**
706
     * Gather query requirements from view component objects.
707
     *
708
     * This will take the required fields and joins for this view, and combine them with those for all active view components.
709
     * Fields will be de-duplicated in multiple components require the same field.
710
     * Joins will be de-duplicated if the alias and join clause match exactly.
711
     *
712
     * @throws \coding_exception If two components attempt to use the same alias for different joins.
713
     * @param view_component[] $viewcomponents List of component objects included in the current view
714
     * @return array [$fields, $joins] SQL fields and joins to add to the query.
715
     */
716
    protected function get_component_requirements(array $viewcomponents): array {
717
        $fields = $this->get_required_fields();
718
        $joins = $this->get_required_joins();
719
        if (!empty($viewcomponents)) {
720
            foreach ($viewcomponents as $viewcomponent) {
721
                $extrajoins = $viewcomponent->get_extra_joins();
722
                foreach ($extrajoins as $prefix => $join) {
723
                    if (isset($joins[$prefix]) && $joins[$prefix] != $join) {
724
                        throw new \coding_exception('Join ' . $join . ' conflicts with previous join ' . $joins[$prefix]);
725
                    }
726
                    $joins[$prefix] = $join;
727
                }
728
                $fields = array_merge($fields, $viewcomponent->get_required_fields());
729
            }
730
        }
731
        return [array_unique($fields), $joins];
732
    }
733
 
734
    /**
735
     * Create the SQL query to retrieve the indicated questions, based on
736
     * \core_question\bank\search\condition filters.
737
     */
738
    protected function build_query(): void {
739
        // Get the required tables and fields.
740
        [$fields, $joins] = $this->get_component_requirements(array_merge($this->requiredcolumns, $this->questionactions));
741
 
742
        // Build the order by clause.
743
        $sorts = [];
744
        foreach ($this->sort as $sortname => $sortorder) {
745
            [$colname, $subsort] = $this->parse_subsort($sortname);
746
            $sorts[] = $this->requiredcolumns[$colname]->sort_expression($sortorder == SORT_DESC, $subsort);
747
        }
748
 
749
        // Build the where clause.
750
        $latestversion = 'qv.version = (SELECT MAX(v.version)
751
                                          FROM {question_versions} v
752
                                          JOIN {question_bank_entries} be
753
                                            ON be.id = v.questionbankentryid
754
                                         WHERE be.id = qbe.id)';
755
        $this->sqlparams = [];
756
        $conditions = [];
757
        foreach ($this->searchconditions as $searchcondition) {
758
            if ($searchcondition->where()) {
759
                $conditions[] = '((' . $searchcondition->where() .'))';
760
            }
761
            if ($searchcondition->params()) {
762
                $this->sqlparams = array_merge($this->sqlparams, $searchcondition->params());
763
            }
764
        }
765
        // Get higher level filter condition.
766
        $jointype = isset($this->pagevars['jointype']) ? (int)$this->pagevars['jointype'] : condition::JOINTYPE_DEFAULT;
767
        $nonecondition = ($jointype === datafilter::JOINTYPE_NONE) ? ' NOT ' : '';
768
        $separator = ($jointype === datafilter::JOINTYPE_ALL) ? ' AND ' : ' OR ';
769
        // Build the SQL.
770
        $sql = ' FROM {question} q ' . implode(' ', $joins);
771
        $sql .= ' WHERE q.parent = 0 AND ' . $latestversion;
772
        if (!empty($conditions)) {
773
            $sql .= ' AND ' . $nonecondition . ' ( ';
774
            $sql .= implode($separator, $conditions);
775
            $sql .= ' ) ';
776
        }
777
        $this->countsql = 'SELECT count(1)' . $sql;
778
        $this->loadsql = 'SELECT ' . implode(', ', $fields) . $sql . ' ORDER BY ' . implode(', ', $sorts);
779
    }
780
 
781
    /**
782
     * Get the number of questions.
783
     *
784
     * @return int
785
     */
786
    public function get_question_count(): int {
787
        global $DB;
788
        if (is_null($this->totalcount)) {
789
            $this->totalcount = $DB->count_records_sql($this->countsql, $this->sqlparams);
790
        }
791
        return $this->totalcount;
792
    }
793
 
794
    /**
795
     * Load the questions we need to display.
796
     *
797
     * @return \moodle_recordset questionid => data about each question.
798
     */
799
    protected function load_page_questions(): \moodle_recordset {
800
        global $DB;
801
        $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams,
802
            (int)$this->pagevars['qpage'] * (int)$this->pagevars['qperpage'], $this->pagevars['qperpage']);
803
        if (empty($questions)) {
804
            $questions->close();
805
            // No questions on this page. Reset to page 0.
806
            $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, 0, $this->pagevars['qperpage']);
807
        }
808
        return $questions;
809
    }
810
 
811
    /**
812
     * Returns the base url.
813
     *
814
     * @return \moodle_url
815
     */
816
    public function base_url(): \moodle_url {
817
        return $this->baseurl;
818
    }
819
 
820
    /**
821
     * Get the URL for editing a question as a moodle url.
822
     *
823
     * @param int $questionid the question id.
824
     * @return \moodle_url the URL, HTML-escaped.
825
     */
826
    public function edit_question_moodle_url($questionid) {
827
        return new \moodle_url($this->editquestionurl, ['id' => $questionid]);
828
    }
829
 
830
    /**
831
     * Get the URL for editing a question as a HTML-escaped string.
832
     *
833
     * @param int $questionid the question id.
834
     * @return string the URL, HTML-escaped.
835
     */
836
    public function edit_question_url($questionid) {
837
        return $this->edit_question_moodle_url($questionid)->out();
838
    }
839
 
840
    /**
841
     * Get the URL for duplicating a question as a moodle url.
842
     *
843
     * @param int $questionid the question id.
844
     * @return \moodle_url the URL.
845
     */
846
    public function copy_question_moodle_url($questionid) {
847
        return new \moodle_url($this->editquestionurl, ['id' => $questionid, 'makecopy' => 1]);
848
    }
849
 
850
    /**
851
     * Get the URL for duplicating a given question.
852
     * @param int $questionid the question id.
853
     * @return string the URL, HTML-escaped.
854
     */
855
    public function copy_question_url($questionid) {
856
        return $this->copy_question_moodle_url($questionid)->out();
857
    }
858
 
859
    /**
860
     * Get the context we are displaying the question bank for.
861
     * @return \context context object.
862
     */
863
    public function get_most_specific_context(): \context {
864
        return $this->contexts->lowest();
865
    }
866
 
867
    /**
868
     * @deprecated since Moodle 4.0
869
     */
870
    public function preview_question_url() {
871
        throw new coding_exception(__FUNCTION__ . '() has been removed.');
872
    }
873
 
874
    /**
875
     * Get fields from the pagevars array.
876
     *
877
     * If a field is specified, that particlar pagevars field will be returned. Otherwise the entire array will be returned.
878
     *
879
     * If a field is specified but it does not exist, null will be returned.
880
     *
881
     * @param ?string $field
882
     * @return mixed
883
     */
884
    public function get_pagevars(?string $field = null): mixed {
885
        if (is_null($field)) {
886
            return $this->pagevars;
887
        } else {
888
            return $this->pagevars[$field] ?? null;
889
        }
890
    }
891
 
892
    /**
893
     * Set the pagevars property with the provided array.
894
     *
895
     * @param array $pagevars
896
     */
897
    public function set_pagevars(array $pagevars): void {
898
        $this->pagevars = $pagevars;
899
    }
900
 
901
    /**
902
     * Shows the question bank interface.
903
     */
904
    public function display(): void {
905
        $editcontexts = $this->contexts->having_one_edit_tab_cap('questions');
906
 
907
        echo \html_writer::start_div('questionbankwindow boxwidthwide boxaligncenter', [
908
            'data-component' => 'core_question',
909
            'data-callback' => 'display_question_bank',
910
            'data-contextid' => $editcontexts[array_key_last($editcontexts)]->id,
911
        ]);
912
 
913
        // Show the filters and search options.
914
        $this->wanted_filters();
915
        // Continues with list of questions.
916
        $this->display_question_list();
917
        echo \html_writer::end_div();
918
 
919
    }
920
 
921
    /**
922
     * The filters for the question bank.
923
     */
924
    public function wanted_filters(): void {
925
        global $OUTPUT;
926
        [, $contextid] = explode(',', $this->pagevars['cat']);
927
        $catcontext = \context::instance_by_id($contextid);
928
        // Category selection form.
929
        $this->display_question_bank_header();
930
        // Add search conditions.
931
        $this->add_standard_search_conditions();
932
        // Render the question bank filters.
933
        $additionalparams = [
934
            'perpage' => $this->pagevars['qperpage'],
935
        ];
936
        $filter = new question_bank_filter_ui($catcontext, $this->searchconditions, $additionalparams, $this->component,
937
                $this->callback, static::class, 'qbank-table', $this->cm?->id, $this->pagevars,
938
                $this->extraparams);
939
        echo $OUTPUT->render($filter);
940
    }
941
 
942
    /**
943
     * Print the text if category id not available.
944
     *
945
     * @deprecated since Moodle 4.3 MDL-72321
946
     * @todo Final deprecation on Moodle 4.7 MDL-78090
947
     */
948
    protected function print_choose_category_message(): void {
949
        debugging(
950
            'Function print_choose_category_message() is deprecated, all the features for this method is currently ' .
951
                'handled by the qbank filter api, please have a look at ' .
952
                'question/bank/managecategories/classes/category_confition.php for more information.',
953
            DEBUG_DEVELOPER
954
        );
955
        echo \html_writer::start_tag('p', ['style' => "\"text-align:center;\""]);
956
        echo \html_writer::tag('b', get_string('selectcategoryabove', 'question'));
957
        echo \html_writer::end_tag('p');
958
    }
959
 
960
    /**
961
     * Gets current selected category.
962
     * @param string $categoryandcontext
963
     * @return false|mixed|\stdClass
964
     *
965
     * @deprecated since Moodle 4.3 MDL-72321
966
     * @todo Final deprecation on Moodle 4.7 MDL-78090
967
     */
968
    protected function get_current_category($categoryandcontext) {
969
        debugging(
970
            'Function get_current_category() is deprecated, all the features for this method is currently handled by ' .
971
            'the qbank filter api, please have a look at question/bank/managecategories/classes/category_confition.php ' .
972
            'for more information.',
973
            DEBUG_DEVELOPER
974
        );
975
        global $DB, $OUTPUT;
976
        list($categoryid, $contextid) = explode(',', $categoryandcontext);
977
        if (!$categoryid) {
978
            $this->print_choose_category_message();
979
            return false;
980
        }
981
 
982
        if (!$category = $DB->get_record('question_categories',
983
            ['id' => $categoryid, 'contextid' => $contextid])) {
984
            echo $OUTPUT->box_start('generalbox questionbank');
985
            echo $OUTPUT->notification('Category not found!');
986
            echo $OUTPUT->box_end();
987
            return false;
988
        }
989
 
990
        return $category;
991
    }
992
 
993
    /**
994
     * Display the form with options for which questions are displayed and how they are displayed.
995
     *
996
     * @param bool $showquestiontext Display the text of the question within the list.
997
     * @deprecated since Moodle 4.3 MDL-72321
998
     * @todo Final deprecation on Moodle 4.7 MDL-78090
999
     */
1000
    protected function display_options_form($showquestiontext): void {
1001
        debugging(
1002
            'Function display_options_form() is deprecated, this method has been replaced with mustaches in filters, ' .
1003
                'please use filtering objects',
1004
            DEBUG_DEVELOPER
1005
        );
1006
        global $PAGE;
1007
 
1008
        // The html will be refactored in the filter feature implementation.
1009
        echo \html_writer::start_tag('form', ['method' => 'get',
1010
            'action' => new \moodle_url($this->baseurl), 'id' => 'displayoptions']);
1011
        echo \html_writer::start_div();
1012
 
1013
        $excludes = ['recurse', 'showhidden', 'qbshowtext'];
1014
        // If the URL contains any tags then we need to prevent them
1015
        // being added to the form as hidden elements because the tags
1016
        // are managed separately.
1017
        if ($this->baseurl->param('qtagids[0]')) {
1018
            $index = 0;
1019
            while ($this->baseurl->param("qtagids[{$index}]")) {
1020
                $excludes[] = "qtagids[{$index}]";
1021
                $index++;
1022
            }
1023
        }
1024
        echo \html_writer::input_hidden_params($this->baseurl, $excludes);
1025
 
1026
        $advancedsearch = [];
1027
 
1028
        foreach ($this->searchconditions as $searchcondition) {
1029
            if ($searchcondition->display_options_adv()) {
1030
                $advancedsearch[] = $searchcondition;
1031
            }
1032
        }
1033
        if (!empty($advancedsearch)) {
1034
            $this->display_advanced_search_form($advancedsearch);
1035
        }
1036
 
1037
        $go = \html_writer::empty_tag('input', ['type' => 'submit', 'value' => get_string('go')]);
1038
        echo \html_writer::tag('noscript', \html_writer::div($go), ['class' => 'inline']);
1039
        echo \html_writer::end_div();
1040
        echo \html_writer::end_tag('form');
1041
        $PAGE->requires->yui_module('moodle-question-searchform', 'M.question.searchform.init');
1042
    }
1043
 
1044
    /**
1045
     * Print the "advanced" UI elements for the form to select which questions. Hidden by default.
1046
     *
1047
     * @param array $advancedsearch
1048
     * @deprecated since Moodle 4.3 MDL-72321
1049
     * @todo Final deprecation on Moodle 4.7 MDL-78090
1050
     */
1051
    protected function display_advanced_search_form($advancedsearch): void {
1052
        debugging(
1053
            'Function display_advanced_search_form() is deprecated, this method has been replaced with mustaches in ' .
1054
            'filters, please use filtering objects',
1055
            DEBUG_DEVELOPER
1056
        );
1057
        print_collapsible_region_start('', 'advancedsearch',
1058
            get_string('advancedsearchoptions', 'question'),
1059
            'question_bank_advanced_search');
1060
        foreach ($advancedsearch as $searchcondition) {
1061
            echo $searchcondition->display_options_adv();
1062
        }
1063
        print_collapsible_region_end();
1064
    }
1065
 
1066
    /**
1067
     * Display the checkbox UI for toggling the display of the question text in the list.
1068
     * @param bool $showquestiontext the current or default value for whether to display the text.
1069
     * @deprecated since Moodle 4.3 MDL-72321
1070
     * @todo Final deprecation on Moodle 4.7 MDL-78090
1071
     */
1072
    protected function display_showtext_checkbox($showquestiontext): void {
1073
        debugging('Function display_showtext_checkbox() is deprecated, please use filtering objects', DEBUG_DEVELOPER);
1074
        global $PAGE;
1075
        $displaydata = [
1076
            'checked' => $showquestiontext
1077
        ];
1078
        if (class_exists('qbank_viewquestiontext\\question_text_row')) {
1079
            if (\core\plugininfo\qbank::is_plugin_enabled('qbank_viewquestiontext')) {
1080
                echo $PAGE->get_renderer('core_question', 'bank')->render_showtext_checkbox($displaydata);
1081
            }
1082
        }
1083
    }
1084
 
1085
    /**
1086
     * Display the header element for the question bank.
1087
     */
1088
    protected function display_question_bank_header(): void {
1089
        global $OUTPUT;
1090
        echo $OUTPUT->heading(get_string('questionbank', 'question'), 2);
1091
    }
1092
 
1093
    /**
1094
     * Does the current view allow adding new questions?
1095
     *
1096
     * @return bool True if the view supports adding new questions.
1097
     */
1098
    public function allow_add_questions(): bool {
1099
        return true;
1100
    }
1101
 
1102
    /**
1103
     * Output the question bank controls for each plugin.
1104
     *
1105
     * Controls will be output in the order defined by the array keys returned from
1106
     * {@see plugin_features_base::get_question_bank_controls}. If more than one plugin defines a control in the same position,
1107
     * they will placed after one another based on the alphabetical order of the plugins.
1108
     *
1109
     * @param \core\context $context The current context, for permissions checks.
1110
     * @param int $categoryid The current question category.
1111
     */
1112
    protected function get_plugin_controls(\core\context $context, int $categoryid): string {
1113
        global $OUTPUT;
1114
        $orderedcontrols = [];
1115
        foreach ($this->plugins as $plugin) {
1116
            $plugincontrols = $plugin->get_question_bank_controls($this, $context, $categoryid);
1117
            foreach ($plugincontrols as $position => $plugincontrol) {
1118
                if (!array_key_exists($position, $orderedcontrols)) {
1119
                    $orderedcontrols[$position] = [];
1120
                }
1121
                $orderedcontrols[$position][] = $plugincontrol;
1122
            }
1123
        }
1124
        ksort($orderedcontrols);
1125
        $output = '';
1126
        foreach ($orderedcontrols as $controls) {
1127
            foreach ($controls as $control) {
1128
                $output .= $OUTPUT->render($control);
1129
            }
1130
        }
1131
        return $OUTPUT->render_from_template('core_question/question_bank_controls', ['controls' => $output]);
1132
    }
1133
 
1134
    /**
1135
     * Prints the table of questions in a category with interactions
1136
     */
1137
    public function display_question_list(): void {
1138
        // This function can be moderately slow with large question counts and may time out.
1139
        // We probably do not want to raise it to unlimited, so randomly picking 5 minutes.
1140
        // Note: We do not call this in the loop because quiz ob_ captures this function (see raise() PHP doc).
1141
        \core_php_time_limit::raise(300);
1142
 
1143
        [$categoryid, $contextid] = category_condition::validate_category_param($this->pagevars['cat']);
1144
        $catcontext = \context::instance_by_id($contextid);
1145
 
1146
        echo \html_writer::start_tag(
1147
            'div',
1148
            [
1149
                'id' => 'questionscontainer',
1150
                'data-component' => $this->component,
1151
                'data-callback' => $this->callback,
1152
                'data-contextid' => $this->get_most_specific_context()->id,
1153
            ]
1154
        );
1155
        echo $this->get_plugin_controls($catcontext, $categoryid);
1156
 
1157
        $this->build_query();
1158
        $questionsrs = $this->load_page_questions();
1159
        $totalquestions = $this->get_question_count();
1160
        $questions = [];
1161
        foreach ($questionsrs as $question) {
1162
            if (!empty($question->id)) {
1163
                $questions[$question->id] = $question;
1164
            }
1165
        }
1166
        $questionsrs->close();
1167
 
1168
        // This html will be refactored in the bulk actions implementation.
1169
        echo \html_writer::start_tag('form', ['action' => $this->baseurl, 'method' => 'post', 'id' => 'questionsubmit']);
1170
        echo \html_writer::start_tag('fieldset', ['class' => 'invisiblefieldset', 'style' => "display: block;"]);
1171
        echo \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey()]);
1172
        echo \html_writer::input_hidden_params($this->baseurl);
1173
 
1174
        $filtercondition = json_encode($this->get_pagevars());
1175
        // Embeded filterconditon into the div.
1176
        echo \html_writer::start_tag('div',
1177
            ['class' => 'categoryquestionscontainer', 'data-filtercondition' => $filtercondition]);
1178
        if ($totalquestions > 0) {
1179
            // Bulk load any required statistics.
1180
            $this->load_required_statistics($questions);
1181
 
1182
            // Bulk load any extra data that any column requires.
1183
            foreach ($this->requiredcolumns as $column) {
1184
                $column->load_additional_data($questions);
1185
            }
1186
            $this->display_questions($questions, $this->pagevars['qpage'], $this->pagevars['qperpage']);
1187
        }
1188
        echo \html_writer::end_tag('div');
1189
 
1190
        $this->display_bottom_controls($catcontext);
1191
 
1192
        echo \html_writer::end_tag('fieldset');
1193
        echo \html_writer::end_tag('form');
1194
        echo \html_writer::end_tag('div');
1195
    }
1196
 
1197
    /**
1198
     * Work out the list of all the required statistics fields for this question bank view.
1199
     *
1200
     * This gathers all the required fields from all columns, so they can all be loaded at once.
1201
     *
1202
     * @return string[] the names of all the required fields for this question bank view.
1203
     */
1204
    protected function determine_required_statistics(): array {
1205
        $requiredfields = [];
1206
        foreach ($this->requiredcolumns as $column) {
1207
            $requiredfields = array_merge($requiredfields, $column->get_required_statistics_fields());
1208
        }
1209
 
1210
        return array_unique($requiredfields);
1211
    }
1212
 
1213
    /**
1214
     * Load the aggregate statistics that all the columns require.
1215
     *
1216
     * @param \stdClass[] $questions the questions that will be displayed indexed by question id.
1217
     */
1218
    protected function load_required_statistics(array $questions): void {
1219
        $requiredstatistics = $this->determine_required_statistics();
1220
        $this->loadedstatistics = statistics_bulk_loader::load_aggregate_statistics(
1221
                array_keys($questions), $requiredstatistics);
1222
    }
1223
 
1224
    /**
1225
     * Get the aggregated value of a particular statistic for a particular question.
1226
     *
1227
     * You can only get values for the questions on the current page of the question bank view,
1228
     * and only if you declared the need for this statistic in the get_required_statistics_fields()
1229
     * method of your question bank column.
1230
     *
1231
     * @param int $questionid the id of a question
1232
     * @param string $fieldname the name of a statistics field, e.g. 'facility'.
1233
     * @return float|null the average (across all users) of this statistic for this question.
1234
     *      Null if the value is not available right now.
1235
     */
1236
    public function get_aggregate_statistic(int $questionid, string $fieldname): ?float {
1237
        if (!array_key_exists($questionid, $this->loadedstatistics)) {
1238
            throw new \coding_exception('Question ' . $questionid . ' is not on the current page of ' .
1239
                    'this question bank view, so its statistics are not available.');
1240
        }
1241
 
1242
        // Must be array_key_exists, not isset, because we care about null values.
1243
        if (!array_key_exists($fieldname, $this->loadedstatistics[$questionid])) {
1244
            throw new \coding_exception('Statistics field ' . $fieldname . ' was not requested by any ' .
1245
                    'question bank column in this view, so it is not available.');
1246
        }
1247
 
1248
        return $this->loadedstatistics[$questionid][$fieldname];
1249
    }
1250
 
1251
    /**
1252
     * Display the top pagination bar.
1253
     *
1254
     * @param object $pagination
1255
     * @deprecated since Moodle 4.3
1256
     * @todo Final deprecation on Moodle 4.7 MDL-78091
1257
     */
1258
    public function display_top_pagnation($pagination): void {
1259
        debugging(
1260
            'Function display_top_pagnation() is deprecated, please use display_questions() for ajax based pagination.',
1261
            DEBUG_DEVELOPER
1262
        );
1263
        global $PAGE;
1264
        $displaydata = [
1265
            'pagination' => $pagination
1266
        ];
1267
        echo $PAGE->get_renderer('core_question', 'bank')->render_question_pagination($displaydata);
1268
    }
1269
 
1270
    /**
1271
     * Display bottom pagination bar.
1272
     *
1273
     * @param string $pagination
1274
     * @param int $totalnumber
1275
     * @param int $perpage
1276
     * @param \moodle_url $pageurl
1277
     * @deprecated since Moodle 4.3
1278
     * @todo Final deprecation on Moodle 4.7 MDL-78091
1279
     */
1280
    public function display_bottom_pagination($pagination, $totalnumber, $perpage, $pageurl): void {
1281
        debugging(
1282
            'Function display_bottom_pagination() is deprecated, please use display_questions() for ajax based pagination.',
1283
            DEBUG_DEVELOPER
1284
        );
1285
        global $PAGE;
1286
        $displaydata = array (
1287
            'extraclasses' => 'pagingbottom',
1288
            'pagination' => $pagination,
1289
            'biggertotal' => true,
1290
        );
1291
        if ($totalnumber > $this->pagesize) {
1292
            $displaydata['showall'] = true;
1293
            if ($perpage == $this->pagesize) {
1294
                $url = new \moodle_url($pageurl, array_merge($pageurl->params(),
1295
                    ['qpage' => 0, 'qperpage' => MAXIMUM_QUESTIONS_PER_PAGE]));
1296
                if ($totalnumber > MAXIMUM_QUESTIONS_PER_PAGE) {
1297
                    $displaydata['totalnumber'] = MAXIMUM_QUESTIONS_PER_PAGE;
1298
                } else {
1299
                    $displaydata['biggertotal'] = false;
1300
                    $displaydata['totalnumber'] = $totalnumber;
1301
                }
1302
            } else {
1303
                $url = new \moodle_url($pageurl, array_merge($pageurl->params(),
1304
                    ['qperpage' => $this->pagesize]));
1305
                $displaydata['totalnumber'] = $this->pagesize;
1306
            }
1307
            $displaydata['showallurl'] = $url;
1308
        }
1309
        echo $PAGE->get_renderer('core_question', 'bank')->render_question_pagination($displaydata);
1310
    }
1311
 
1312
    /**
1313
     * Display the controls at the bottom of the list of questions.
1314
     *
1315
     * @param \context $catcontext The context of the category being displayed.
1316
     */
1317
    protected function display_bottom_controls(\context $catcontext): void {
1318
        $caneditall = has_capability('moodle/question:editall', $catcontext);
1319
        $canuseall = has_capability('moodle/question:useall', $catcontext);
1320
        $canmoveall = has_capability('moodle/question:moveall', $catcontext);
1321
        if ($caneditall || $canmoveall || $canuseall) {
1322
            global $PAGE;
1323
            $bulkactiondatas = [];
1324
            $params = $this->base_url()->params();
1325
            $returnurl = new \moodle_url($this->base_url(), ['filter' => json_encode($this->pagevars['filter'])]);
1326
            $params['returnurl'] = $returnurl;
1327
            foreach ($this->bulkactions as $key => $action) {
1328
                // Check capabilities.
1329
                $capcount = 0;
1330
                foreach ($action['capabilities'] as $capability) {
1331
                    if (has_capability($capability, $catcontext)) {
1332
                        $capcount ++;
1333
                    }
1334
                }
1335
                // At least one cap need to be there.
1336
                if ($capcount === 0) {
1337
                    unset($this->bulkactions[$key]);
1338
                    continue;
1339
                }
1340
                $actiondata = new \stdClass();
1341
                $actiondata->actionname = $action['title'];
1342
                $actiondata->actionkey = $key;
1343
                $actiondata->actionurl = new \moodle_url($action['url'], $params);
1344
                $bulkactiondata[] = $actiondata;
1345
 
1346
                $bulkactiondatas ['bulkactionitems'] = $bulkactiondata;
1347
            }
1348
            // We dont need to show this section if none of the plugins are enabled.
1349
            if (!empty($bulkactiondatas)) {
1350
                echo $PAGE->get_renderer('core_question', 'bank')->render_bulk_actions_ui($bulkactiondatas);
1351
            }
1352
        }
1353
    }
1354
 
1355
    /**
1356
     * Display the questions.
1357
     *
1358
     * @param array $questions
1359
     */
1360
    public function display_questions($questions, $page = 0, $perpage = DEFAULT_QUESTIONS_PER_PAGE): void {
1361
        global $OUTPUT;
1362
        if (!isset($this->pagevars['filter']['category'])) {
1363
            // We must have a category filter selected.
1364
            echo $OUTPUT->render_from_template('qbank_managecategories/choose_category', []);
1365
            return;
1366
        }
1367
        // Pagination.
1368
        $pageingurl = new \moodle_url($this->base_url());
11 efrain 1369
        // TODO MDL-82312: it really should not be necessary to set filter here, and not like this.
1370
        // This should be handled in baseurl, but it isn't so we do this so Moodle basically works for now.
1371
        $pageingurl->param('filter', json_encode($this->pagevars['filter']));
1 efrain 1372
        $pagingbar = new \paging_bar($this->totalcount, $page, $perpage, $pageingurl);
1373
        $pagingbar->pagevar = 'qpage';
1374
        echo $OUTPUT->render($pagingbar);
1375
 
1376
        // Table of questions.
1377
        echo \html_writer::start_tag('div',
1378
            ['class' => 'question_table', 'id' => 'question_table']);
1379
        $this->print_table($questions);
1380
        echo \html_writer::end_tag('div');
1381
        echo $OUTPUT->render($pagingbar);
1382
    }
1383
 
1384
    /**
1385
     * Load the questions according to the search conditions.
1386
     *
1387
     * @return array
1388
     */
1389
    public function load_questions() {
1390
        $this->build_query();
1391
        $questionsrs = $this->load_page_questions();
1392
        $questions = [];
1393
        foreach ($questionsrs as $question) {
1394
            if (!empty($question->id)) {
1395
                $questions[$question->id] = $question;
1396
            }
1397
        }
1398
        $questionsrs->close();
1399
        foreach ($this->requiredcolumns as $name => $column) {
1400
            $column->load_additional_data($questions);
1401
        }
1402
        return $questions;
1403
    }
1404
 
1405
    /**
1406
     * Prints the actual table with question.
1407
     *
1408
     * @param array $questions
1409
     */
1410
    protected function print_table($questions): void {
1411
        // Start of the table.
1412
        echo \html_writer::start_tag('table', [
1413
            'id' => 'categoryquestions',
1414
            'class' => 'question-bank-table generaltable',
1415
            'data-defaultsort' => json_encode($this->sort),
1416
        ]);
1417
 
1418
        // Prints the table header.
1419
        echo \html_writer::start_tag('thead');
1420
        echo \html_writer::start_tag('tr', ['class' => 'qbank-column-list']);
1421
        $this->print_table_headers();
1422
        echo \html_writer::end_tag('tr');
1423
        echo \html_writer::end_tag('thead');
1424
 
1425
        // Prints the table row or content.
1426
        echo \html_writer::start_tag('tbody');
1427
        $rowcount = 0;
1428
        foreach ($questions as $question) {
1429
            $this->print_table_row($question, $rowcount);
1430
            $rowcount += 1;
1431
        }
1432
        echo \html_writer::end_tag('tbody');
1433
 
1434
        // End of the table.
1435
        echo \html_writer::end_tag('table');
1436
    }
1437
 
1438
    /**
1439
     * Start of the table html.
1440
     *
1441
     * @see print_table()
1442
     * @deprecated since Moodle 4.3 MDL-72321
1443
     * @todo Final deprecation on Moodle 4.7 MDL-78090
1444
     */
1445
    protected function start_table() {
1446
        debugging('Function start_table() is deprecated, please use print_table() instead.', DEBUG_DEVELOPER);
1447
        echo '<table id="categoryquestions" class="table table-responsive">' . "\n";
1448
        echo "<thead>\n";
1449
        $this->print_table_headers();
1450
        echo "</thead>\n";
1451
        echo "<tbody>\n";
1452
    }
1453
 
1454
    /**
1455
     * End of the table html.
1456
     *
1457
     * @see print_table()
1458
     * @deprecated since Moodle 4.3 MDL-72321
1459
     * @todo Final deprecation on Moodle 4.7 MDL-78090
1460
     */
1461
    protected function end_table() {
1462
        debugging('Function end_table() is deprecated, please use print_table() instead.', DEBUG_DEVELOPER);
1463
        echo "</tbody>\n";
1464
        echo "</table>\n";
1465
    }
1466
 
1467
    /**
1468
     * Print table headers from child classes.
1469
     */
1470
    protected function print_table_headers(): void {
1471
        $columnactions = $this->columnmanager->get_column_actions($this);
1472
        foreach ($this->visiblecolumns as $column) {
1473
            $width = $this->columnmanager->get_column_width($column);
1474
            $column->display_header($columnactions, $width);
1475
        }
1476
    }
1477
 
1478
    /**
1479
     * Gets the classes for the row.
1480
     *
1481
     * @param \stdClass $question
1482
     * @param int $rowcount
1483
     * @return array
1484
     */
1485
    protected function get_row_classes($question, $rowcount): array {
1486
        $classes = [];
1487
        if ($question->status === question_version_status::QUESTION_STATUS_HIDDEN) {
1488
            $classes[] = 'dimmed_text';
1489
        }
1490
        if ($question->id == $this->lastchangedid) {
1491
            $classes[] = 'highlight text-dark';
1492
        }
1493
        $classes[] = 'r' . ($rowcount % 2);
1494
        return $classes;
1495
    }
1496
 
1497
    /**
1498
     * Prints the table row from child classes.
1499
     *
1500
     * @param \stdClass $question
1501
     * @param int $rowcount
1502
     */
1503
    public function print_table_row($question, $rowcount): void {
1504
        $rowclasses = implode(' ', $this->get_row_classes($question, $rowcount));
1505
        $attributes = [];
1506
        if ($rowclasses) {
1507
            $attributes['class'] = $rowclasses;
1508
        }
1509
        echo \html_writer::start_tag('tr', $attributes);
1510
        foreach ($this->visiblecolumns as $column) {
1511
            $column->display($question, $rowclasses);
1512
        }
1513
        echo \html_writer::end_tag('tr');
1514
        foreach ($this->extrarows as $row) {
1515
            $row->display($question, $rowclasses);
1516
        }
1517
    }
1518
 
1519
    /**
1520
     * @deprecated since Moodle 4.0
1521
     */
1522
    public function process_actions(): void {
1523
        throw new coding_exception(__FUNCTION__ . '() has been removed.');
1524
    }
1525
 
1526
    /**
1527
     * @deprecated since Moodle 4.0
1528
     */
1529
    public function process_actions_needing_ui() {
1530
        throw new coding_exception(__FUNCTION__ . '() has been removed.');
1531
    }
1532
 
1533
    /**
1534
     * Add another search control to this view.
1535
     * @param condition $searchcondition the condition to add.
1536
     * @param string|null $fieldname
1537
     */
1538
    public function add_searchcondition(condition $searchcondition, ?string $fieldname = null): void {
1539
        if (is_null($fieldname)) {
1540
            $this->searchconditions[] = $searchcondition;
1541
        } else {
1542
            $this->searchconditions[$fieldname] = $searchcondition;
1543
        }
1544
    }
1545
 
1546
    /**
1547
     * Add standard search conditions.
1548
     * Params must be set into this object before calling this function.
1549
     */
1550
    public function add_standard_search_conditions(): void {
1551
        foreach ($this->plugins as $componentname => $plugin) {
1552
            if (\core\plugininfo\qbank::is_plugin_enabled($componentname)) {
1553
                $pluginentrypointobject = new $plugin();
1554
                $pluginobjects = $pluginentrypointobject->get_question_filters($this);
1555
                foreach ($pluginobjects as $pluginobject) {
1556
                    $this->add_searchcondition($pluginobject, $pluginobject->get_condition_key());
1557
                }
1558
            }
1559
        }
1560
    }
1561
 
1562
    /**
1563
     * Gets visible columns.
1564
     * @return array Visible columns.
1565
     */
1566
    public function get_visiblecolumns(): array {
1567
        return $this->visiblecolumns;
1568
    }
1569
 
1570
    /**
1571
     * Is this view showing separate versions of a question?
1572
     *
1573
     * @return bool
1574
     */
1575
    public function is_listing_specific_versions(): bool {
1576
        return false;
1577
    }
1578
 
1579
    /**
1580
     * Return array of menu actions.
1581
     *
1582
     * @return question_action_base[]
1583
     */
1584
    public function get_question_actions(): array {
1585
        return $this->questionactions;
1586
    }
1587
 
1588
    /**
1589
     * Display the questions table for the fragment/ajax.
1590
     *
1591
     * @return string HTML for the question table
1592
     */
1593
    public function display_questions_table(): string {
1594
        $this->add_standard_search_conditions();
1595
        $questions = $this->load_questions();
1596
        $totalquestions = $this->get_question_count();
1597
        $questionhtml = '';
1598
        if ($totalquestions > 0) {
1599
            $this->load_required_statistics($questions);
1600
            ob_start();
1601
            $this->display_questions($questions, $this->pagevars['qpage'], $this->pagevars['qperpage']);
1602
            $questionhtml = ob_get_clean();
1603
        }
1604
        return $questionhtml;
1605
    }
1606
}