From 86bdf8796607c3afd40125fd992f12af5bcfa780 Mon Sep 17 00:00:00 2001 From: safatshahin Date: Thu, 24 Jun 2021 15:58:09 +1000 Subject: [PATCH] MDL-71516 core_question: Class movement for moodle autoloading Movement of classes to classes/local/bank and additional todo addition to all the existing ones to align with future tracker for class renaming. This commit does not contain any class renaming of the old or deprecated classes. MDL-72004 will be used to rename, remove or deprecate old classes after integrating all the qbank plugins. --- question/classes/bank/action_column_base.php | 1 + question/classes/bank/checkbox_column.php | 1 + question/classes/bank/column_base.php | 35 +- question/classes/bank/copy_action_column.php | 1 + question/classes/bank/creator_name_column.php | 1 + .../classes/bank/delete_action_column.php | 1 + question/classes/bank/edit_action_column.php | 1 + question/classes/bank/edit_menu_column.php | 1 + .../classes/bank/export_xml_action_column.php | 1 + .../classes/bank/menu_action_column_base.php | 1 + question/classes/bank/menuable_action.php | 1 + .../classes/bank/modifier_name_column.php | 1 + .../classes/bank/preview_action_column.php | 1 + .../classes/bank/question_name_column.php | 1 + .../question_name_idnumber_tags_column.php | 1 + question/classes/bank/question_text_row.php | 1 + .../classes/bank/question_type_column.php | 1 + .../classes/bank/random_question_loader.php | 1 + question/classes/bank/row_base.php | 1 + question/classes/bank/tags_action_column.php | 1 + question/classes/bank/view.php | 1 + .../classes/local/bank/action_column_base.php | 60 + .../classes/local/bank/checkbox_column.php | 77 ++ question/classes/local/bank/column_base.php | 369 ++++++ .../classes/local/bank/edit_menu_column.php | 101 ++ .../local/bank/menu_action_column_base.php | 70 ++ .../classes/local/bank/menuable_action.php | 56 + .../local/bank/random_question_loader.php | 322 +++++ question/classes/local/bank/row_base.php | 52 + question/classes/local/bank/view.php | 1103 +++++++++++++++++ question/upgrade.txt | 7 + 31 files changed, 2266 insertions(+), 6 deletions(-) create mode 100644 question/classes/local/bank/action_column_base.php create mode 100644 question/classes/local/bank/checkbox_column.php create mode 100644 question/classes/local/bank/column_base.php create mode 100644 question/classes/local/bank/edit_menu_column.php create mode 100644 question/classes/local/bank/menu_action_column_base.php create mode 100644 question/classes/local/bank/menuable_action.php create mode 100644 question/classes/local/bank/random_question_loader.php create mode 100644 question/classes/local/bank/row_base.php create mode 100644 question/classes/local/bank/view.php diff --git a/question/classes/bank/action_column_base.php b/question/classes/bank/action_column_base.php index 2c34e41dedb..b5043c874c7 100644 --- a/question/classes/bank/action_column_base.php +++ b/question/classes/bank/action_column_base.php @@ -31,6 +31,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to core_question/local/bank */ abstract class action_column_base extends column_base { diff --git a/question/classes/bank/checkbox_column.php b/question/classes/bank/checkbox_column.php index 9b2f628b4fe..18e59510055 100644 --- a/question/classes/bank/checkbox_column.php +++ b/question/classes/bank/checkbox_column.php @@ -32,6 +32,7 @@ use core\output\checkbox_toggleall; * * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to core_question/local/bank */ class checkbox_column extends column_base { diff --git a/question/classes/bank/column_base.php b/question/classes/bank/column_base.php index 8bacb784a38..3a7fe62f1ac 100644 --- a/question/classes/bank/column_base.php +++ b/question/classes/bank/column_base.php @@ -15,7 +15,7 @@ // along with Moodle. If not, see . /** - * Base class for representing a column in a {@link question_bank_view}. + * Base class for representing a column in a {@see question_bank_view}. * * @package core_question * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} @@ -27,12 +27,14 @@ defined('MOODLE_INTERNAL') || die(); /** - * Base class for representing a column in a {@link question_bank_view}. + * Base class for representing a column in a {@see question_bank_view}. * * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to core_question/local/bank */ abstract class column_base { + /** * @var view $qbank the question bank view we are helping to render. */ @@ -42,10 +44,10 @@ abstract class column_base { protected $isheading = false; /** - * Constructor. - * @param view $qbank the question bank view we are helping to render. + * Constructor for the question bank view we are helping to render. + * @param view $qbank */ - public function __construct(view $qbank) { + public function __construct($qbank) { $this->qbank = $qbank; $this->init(); } @@ -68,6 +70,27 @@ abstract class column_base { return false; } + /** + * Check if the row has an extra preference to view/hide. + */ + public function has_preference(): bool { + return false; + } + + /** + * Get if the preference key of the row. + */ + public function get_preference_key(): string { + return ''; + } + + /** + * Get if the preference of the row. + */ + public function get_preference(): bool { + return false; + } + /** * Output the column header cell. */ @@ -272,7 +295,7 @@ abstract class column_base { /** * Load the tags for each question. * - * Helper that can be used from {@link load_additional_data()}; + * Helper that can be used from {@see load_additional_data()}; * * @param array $questions */ diff --git a/question/classes/bank/copy_action_column.php b/question/classes/bank/copy_action_column.php index ef5e6f00409..769b81ac26a 100644 --- a/question/classes/bank/copy_action_column.php +++ b/question/classes/bank/copy_action_column.php @@ -31,6 +31,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2013 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to the plugin */ class copy_action_column extends menu_action_column_base { /** @var string avoids repeated calls to get_string('duplicate'). */ diff --git a/question/classes/bank/creator_name_column.php b/question/classes/bank/creator_name_column.php index c6d5b263ba1..4db687f6936 100644 --- a/question/classes/bank/creator_name_column.php +++ b/question/classes/bank/creator_name_column.php @@ -31,6 +31,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to the plugin */ class creator_name_column extends column_base { public function get_name() { diff --git a/question/classes/bank/delete_action_column.php b/question/classes/bank/delete_action_column.php index 3e5419b1139..f969dcc84f9 100644 --- a/question/classes/bank/delete_action_column.php +++ b/question/classes/bank/delete_action_column.php @@ -31,6 +31,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to the plugin */ class delete_action_column extends menu_action_column_base { protected $strdelete; diff --git a/question/classes/bank/edit_action_column.php b/question/classes/bank/edit_action_column.php index b41b487281c..582ce80ae49 100644 --- a/question/classes/bank/edit_action_column.php +++ b/question/classes/bank/edit_action_column.php @@ -31,6 +31,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to the plugin */ class edit_action_column extends menu_action_column_base { protected $stredit; diff --git a/question/classes/bank/edit_menu_column.php b/question/classes/bank/edit_menu_column.php index 072abe1f2b6..687d0b8546e 100644 --- a/question/classes/bank/edit_menu_column.php +++ b/question/classes/bank/edit_menu_column.php @@ -36,6 +36,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2019 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to core_question/local/bank */ class edit_menu_column extends column_base { /** diff --git a/question/classes/bank/export_xml_action_column.php b/question/classes/bank/export_xml_action_column.php index 08a9dc55ca9..6807a994d08 100644 --- a/question/classes/bank/export_xml_action_column.php +++ b/question/classes/bank/export_xml_action_column.php @@ -31,6 +31,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2019 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to the plugin */ class export_xml_action_column extends menu_action_column_base { /** @var string avoids repeated calls to get_string('duplicate'). */ diff --git a/question/classes/bank/menu_action_column_base.php b/question/classes/bank/menu_action_column_base.php index 5a0b52da58b..c730bd7dfdc 100644 --- a/question/classes/bank/menu_action_column_base.php +++ b/question/classes/bank/menu_action_column_base.php @@ -36,6 +36,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2019 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to core_question/local/bank */ abstract class menu_action_column_base extends action_column_base implements menuable_action { diff --git a/question/classes/bank/menuable_action.php b/question/classes/bank/menuable_action.php index 0a0e76f2fff..2a4278cc157 100644 --- a/question/classes/bank/menuable_action.php +++ b/question/classes/bank/menuable_action.php @@ -43,6 +43,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2019 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to core_question/local/bank */ interface menuable_action { diff --git a/question/classes/bank/modifier_name_column.php b/question/classes/bank/modifier_name_column.php index 35d8617c120..3b7621ff287 100644 --- a/question/classes/bank/modifier_name_column.php +++ b/question/classes/bank/modifier_name_column.php @@ -31,6 +31,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to the plugin */ class modifier_name_column extends column_base { public function get_name() { diff --git a/question/classes/bank/preview_action_column.php b/question/classes/bank/preview_action_column.php index e10e7b02441..54469e0e2ed 100644 --- a/question/classes/bank/preview_action_column.php +++ b/question/classes/bank/preview_action_column.php @@ -31,6 +31,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to the plugin */ class preview_action_column extends action_column_base implements menuable_action { /** diff --git a/question/classes/bank/question_name_column.php b/question/classes/bank/question_name_column.php index 528a5ca236d..e98d580f14b 100644 --- a/question/classes/bank/question_name_column.php +++ b/question/classes/bank/question_name_column.php @@ -31,6 +31,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to the plugin */ class question_name_column extends column_base { protected $checkboxespresent = null; diff --git a/question/classes/bank/question_name_idnumber_tags_column.php b/question/classes/bank/question_name_idnumber_tags_column.php index 85cea05cda3..d026a0dc40b 100644 --- a/question/classes/bank/question_name_idnumber_tags_column.php +++ b/question/classes/bank/question_name_idnumber_tags_column.php @@ -31,6 +31,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2019 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to the plugin */ class question_name_idnumber_tags_column extends question_name_column { public function get_name() { diff --git a/question/classes/bank/question_text_row.php b/question/classes/bank/question_text_row.php index b05b0a7c099..626083999a0 100644 --- a/question/classes/bank/question_text_row.php +++ b/question/classes/bank/question_text_row.php @@ -31,6 +31,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to the plugin */ class question_text_row extends row_base { protected $formatoptions; diff --git a/question/classes/bank/question_type_column.php b/question/classes/bank/question_type_column.php index 064ce95ecbc..cf2f18ecf43 100644 --- a/question/classes/bank/question_type_column.php +++ b/question/classes/bank/question_type_column.php @@ -31,6 +31,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to the plugin */ class question_type_column extends column_base { public function get_name() { diff --git a/question/classes/bank/random_question_loader.php b/question/classes/bank/random_question_loader.php index 4fcbcfb6b50..9dac3ac9128 100644 --- a/question/classes/bank/random_question_loader.php +++ b/question/classes/bank/random_question_loader.php @@ -41,6 +41,7 @@ namespace core_question\bank; * * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to core_question/local/bank */ class random_question_loader { /** @var \qubaid_condition which usages to consider previous attempts from. */ diff --git a/question/classes/bank/row_base.php b/question/classes/bank/row_base.php index 457899ed51c..7f9363ca40b 100644 --- a/question/classes/bank/row_base.php +++ b/question/classes/bank/row_base.php @@ -31,6 +31,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to core_question/local/bank */ abstract class row_base extends column_base { public function is_extra_row() { diff --git a/question/classes/bank/tags_action_column.php b/question/classes/bank/tags_action_column.php index 6899d2d2ac6..fb44f62d8d3 100644 --- a/question/classes/bank/tags_action_column.php +++ b/question/classes/bank/tags_action_column.php @@ -30,6 +30,7 @@ defined('MOODLE_INTERNAL') || die(); * * @copyright 2018 Simey Lameze * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to the plugin */ class tags_action_column extends action_column_base implements menuable_action { /** diff --git a/question/classes/bank/view.php b/question/classes/bank/view.php index de74be8c03e..711fba5607f 100644 --- a/question/classes/bank/view.php +++ b/question/classes/bank/view.php @@ -48,6 +48,7 @@ use core_question\bank\search\condition; * * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-72004 delete the class and add it to lib/db/renameclasses.php pointing to core_question/local/bank */ class view { const MAX_SORTS = 3; diff --git a/question/classes/local/bank/action_column_base.php b/question/classes/local/bank/action_column_base.php new file mode 100644 index 00000000000..64430d88726 --- /dev/null +++ b/question/classes/local/bank/action_column_base.php @@ -0,0 +1,60 @@ +. + +/** + * A base class for actions that are an icon that lets you manipulate the question in some way. + * + * @package core_question + * @copyright 2009 Tim Hunt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_question\local\bank; +defined('MOODLE_INTERNAL') || die(); + + +/** + * A base class for actions that are an icon that lets you manipulate the question in some way. + * + * @copyright 2009 Tim Hunt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class action_column_base extends column_base { + + protected function get_title() { + return ' '; + } + + public function get_extra_classes() { + return array('iconcol'); + } + + protected function print_icon($icon, $title, $url) { + global $OUTPUT; + echo '' . $OUTPUT->pix_icon($icon, $title) . ''; + } + + public function get_extra_joins() { + return array('qc' => 'JOIN {question_categories} qc ON qc.id = q.category'); + } + + public function get_required_fields() { + // Createdby is required for permission checks. + // Qtype so we can easily avoid applying actions to question types that + // are no longer installed. + return ['q.id', 'q.qtype', 'q.createdby', 'qc.contextid']; + } +} diff --git a/question/classes/local/bank/checkbox_column.php b/question/classes/local/bank/checkbox_column.php new file mode 100644 index 00000000000..88f4142756b --- /dev/null +++ b/question/classes/local/bank/checkbox_column.php @@ -0,0 +1,77 @@ +. + +/** + * A column with a checkbox for each question with name q{questionid}. + * + * @package core_question + * @copyright 2009 Tim Hunt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core_question\local\bank; +defined('MOODLE_INTERNAL') || die(); + +use core\output\checkbox_toggleall; + + +/** + * A column with a checkbox for each question with name q{questionid}. + * + * @copyright 2009 Tim Hunt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class checkbox_column extends column_base { + + public function get_name() { + return 'checkbox'; + } + + protected function get_title() { + global $OUTPUT; + + $mastercheckbox = new checkbox_toggleall('qbank', true, [ + 'id' => 'qbheadercheckbox', + 'name' => 'qbheadercheckbox', + 'value' => '1', + 'label' => get_string('selectall'), + 'labelclasses' => 'accesshide', + ]); + + return $OUTPUT->render($mastercheckbox); + } + + protected function get_title_tip() { + return get_string('selectquestionsforbulk', 'question'); + } + + protected function display_content($question, $rowclasses) { + global $OUTPUT; + + $checkbox = new checkbox_toggleall('qbank', false, [ + 'id' => "checkq{$question->id}", + 'name' => "q{$question->id}", + 'value' => '1', + 'label' => get_string('select'), + 'labelclasses' => 'accesshide', + ]); + + echo $OUTPUT->render($checkbox); + } + + public function get_required_fields() { + return array('q.id'); + } +} diff --git a/question/classes/local/bank/column_base.php b/question/classes/local/bank/column_base.php new file mode 100644 index 00000000000..883ddc37b31 --- /dev/null +++ b/question/classes/local/bank/column_base.php @@ -0,0 +1,369 @@ +. + +/** + * Base class for representing a column in a {@see question_bank_view}. + * + * @package core_question + * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_question\local\bank; +defined('MOODLE_INTERNAL') || die(); + + +/** + * Base class for representing a column in a {@see question_bank_view}. + * + * @copyright 2009 Tim Hunt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @todo MDL-71516 delete the class and add it to lib/db/renameclasses.php pointing to core_question/local/bank + */ +abstract class column_base { + + /** + * @var view $qbank the question bank view we are helping to render. + */ + protected $qbank; + + /** @var bool determine whether the column is td or th. */ + protected $isheading = false; + + /** + * Constructor for the question bank view we are helping to render. + * @param view $qbank + */ + public function __construct($qbank) { + $this->qbank = $qbank; + $this->init(); + } + + /** + * A chance for subclasses to initialise themselves, for example to load lang strings, + * without having to override the constructor. + */ + protected function init() { + } + + /** + * Set the column as heading + */ + public function set_as_heading() { + $this->isheading = true; + } + + public function is_extra_row() { + return false; + } + + /** + * Check if the row has an extra preference to view/hide. + */ + public function has_preference(): bool { + return false; + } + + /** + * Get if the preference key of the row. + */ + public function get_preference_key(): string { + return ''; + } + + /** + * Get if the preference of the row. + */ + public function get_preference(): bool { + return false; + } + + /** + * Output the column header cell. + */ + public function display_header() { + echo ''; + $sortable = $this->is_sortable(); + $name = get_class($this); + $title = $this->get_title(); + $tip = $this->get_title_tip(); + if (is_array($sortable)) { + if ($title) { + echo '
' . $title . '
'; + } + $links = array(); + foreach ($sortable as $subsort => $details) { + $links[] = $this->make_sort_link($name . '-' . $subsort, + $details['title'], isset($details['tip']) ? $details['tip'] : '', !empty($details['reverse'])); + } + echo '
' . implode(' / ', $links) . '
'; + } else if ($sortable) { + echo $this->make_sort_link($name, $title, $tip); + } else { + if ($tip) { + echo ''; + } + echo $title; + if ($tip) { + echo ''; + } + } + echo "\n"; + } + + /** + * Title for this column. Not used if is_sortable returns an array. + */ + protected abstract function get_title(); + + /** + * @return string a fuller version of the name. Use this when get_title() returns + * something very short, and you want a longer version as a tool tip. + */ + protected function get_title_tip() { + return ''; + } + + /** + * Get a link that changes the sort order, and indicates the current sort state. + * @param string $sort the column to sort on. + * @param string $title the link text. + * @param string $tip the link tool-tip text. If empty, defaults to title. + * @param bool $defaultreverse whether the default sort order for this column is descending, rather than ascending. + * @return string HTML fragment. + */ + protected function make_sort_link($sort, $title, $tip, $defaultreverse = false) { + $currentsort = $this->qbank->get_primary_sort_order($sort); + $newsortreverse = $defaultreverse; + if ($currentsort) { + $newsortreverse = $currentsort > 0; + } + if (!$tip) { + $tip = $title; + } + if ($newsortreverse) { + $tip = get_string('sortbyxreverse', '', $tip); + } else { + $tip = get_string('sortbyx', '', $tip); + } + $link = ''; + $link .= $title; + if ($currentsort) { + $link .= $this->get_sort_icon($currentsort < 0); + } + $link .= ''; + return $link; + } + + /** + * Get an icon representing the corrent sort state. + * @param bool $reverse sort is descending, not ascending. + * @return string HTML image tag. + */ + protected function get_sort_icon($reverse) { + global $OUTPUT; + if ($reverse) { + return $OUTPUT->pix_icon('t/sort_desc', get_string('desc'), '', array('class' => 'iconsort')); + } else { + return $OUTPUT->pix_icon('t/sort_asc', get_string('asc'), '', array('class' => 'iconsort')); + } + } + + /** + * Output this column. + * @param object $question the row from the $question table, augmented with extra information. + * @param string $rowclasses CSS class names that should be applied to this row of output. + */ + public function display($question, $rowclasses) { + $this->display_start($question, $rowclasses); + $this->display_content($question, $rowclasses); + $this->display_end($question, $rowclasses); + } + + /** + * Output the opening column tag. If it is set as heading, it will use tag instead of + * + * @param \stdClass $question + * @param string $rowclasses + */ + protected function display_start($question, $rowclasses) { + $tag = 'td'; + $attr = array('class' => $this->get_classes()); + if ($this->isheading) { + $tag = 'th'; + $attr['scope'] = 'row'; + } + echo \html_writer::start_tag($tag, $attr); + } + + /** + * @return string the CSS classes to apply to every cell in this column. + */ + protected function get_classes() { + $classes = $this->get_extra_classes(); + $classes[] = $this->get_name(); + return implode(' ', $classes); + } + + /** + * Get the internal name for this column. Used as a CSS class name, + * and to store information about the current sort. Must match PARAM_ALPHA. + * + * @return string column name. + */ + public abstract function get_name(); + + /** + * @return array any extra class names you would like applied to every cell in this column. + */ + public function get_extra_classes() { + return array(); + } + + /** + * Output the contents of this column. + * @param object $question the row from the $question table, augmented with extra information. + * @param string $rowclasses CSS class names that should be applied to this row of output. + */ + protected abstract function display_content($question, $rowclasses); + + /** + * Output the closing column tag + * + * @param object $question + * @param string $rowclasses + */ + protected function display_end($question, $rowclasses) { + $tag = 'td'; + if ($this->isheading) { + $tag = 'th'; + } + echo \html_writer::end_tag($tag); + } + + /** + * Return an array 'table_alias' => 'JOIN clause' to bring in any data that + * this column required. + * + * The return values for all the columns will be checked. It is OK if two + * columns join in the same table with the same alias and identical JOIN clauses. + * If to columns try to use the same alias with different joins, you get an error. + * The only table included by default is the question table, which is aliased to 'q'. + * + * It is importnat that your join simply adds additional data (or NULLs) to the + * existing rows of the query. It must not cause additional rows. + * + * @return array 'table_alias' => 'JOIN clause' + */ + public function get_extra_joins() { + return array(); + } + + /** + * @return array fields required. use table alias 'q' for the question table, or one of the + * ones from get_extra_joins. Every field requested must specify a table prefix. + */ + public function get_required_fields() { + return array(); + } + + /** + * If this column needs extra data (e.g. tags) then load that here. + * + * The extra data should be added to the question object in the array. + * Probably a good idea to check that another column has not already + * loaded the data you want. + * + * @param \stdClass[] $questions the questions that will be displayed. + */ + public function load_additional_data(array $questions) { + } + + /** + * Load the tags for each question. + * + * Helper that can be used from {@see load_additional_data()}; + * + * @param array $questions + */ + public function load_question_tags(array $questions) { + $firstquestion = reset($questions); + if (isset($firstquestion->tags)) { + // Looks like tags are already loaded, so don't do it again. + return; + } + + // Load the tags. + $tagdata = \core_tag_tag::get_items_tags('core_question', 'question', + array_keys($questions)); + + // Add them to the question objects. + foreach ($tagdata as $questionid => $tags) { + $questions[$questionid]->tags = $tags; + } + } + + /** + * Can this column be sorted on? You can return either: + * + false for no (the default), + * + a field name, if sorting this column corresponds to sorting on that datbase field. + * + an array of subnames to sort on as follows + * return array( + * 'firstname' => array('field' => 'uc.firstname', 'title' => get_string('firstname')), + * 'lastname' => array('field' => 'uc.lastname', 'title' => get_string('lastname')), + * ); + * As well as field, and field, you can also add 'revers' => 1 if you want the default sort + * order to be DESC. + * @return mixed as above. + */ + public function is_sortable() { + return false; + } + + /** + * Helper method for building sort clauses. + * @param bool $reverse whether the normal direction should be reversed. + * @return string 'ASC' or 'DESC' + */ + protected function sortorder($reverse) { + if ($reverse) { + return ' DESC'; + } else { + return ' ASC'; + } + } + + /** + * @param bool $reverse Whether to sort in the reverse of the default sort order. + * @param string $subsort if is_sortable returns an array of subnames, then this will be + * one of those. Otherwise will be empty. + * @return string some SQL to go in the order by clause. + */ + public function sort_expression($reverse, $subsort) { + $sortable = $this->is_sortable(); + if (is_array($sortable)) { + if (array_key_exists($subsort, $sortable)) { + return $sortable[$subsort]['field'] . $this->sortorder($reverse); + } else { + throw new \coding_exception('Unexpected $subsort type: ' . $subsort); + } + } else if ($sortable) { + return $sortable . $this->sortorder($reverse); + } else { + throw new \coding_exception('sort_expression called on a non-sortable column.'); + } + } +} diff --git a/question/classes/local/bank/edit_menu_column.php b/question/classes/local/bank/edit_menu_column.php new file mode 100644 index 00000000000..23333237ae9 --- /dev/null +++ b/question/classes/local/bank/edit_menu_column.php @@ -0,0 +1,101 @@ +. + +/** + * A question bank column which gathers together all the actions into a menu. + * + * @package core_question + * @copyright 2019 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_question\local\bank; +defined('MOODLE_INTERNAL') || die(); + + +/** + * A question bank column which gathers together all the actions into a menu. + * + * This question bank column, if added to the question bank, will + * replace all of the other columns which implement the + * {@link menuable_action} interface and replace them with a single + * column containing an Edit menu. + * + * @copyright 2019 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class edit_menu_column extends column_base { + /** + * @var menuable_action[] + */ + protected $actions; + + /** + * Set up the list of actions that should be shown in the menu. + * + * This takes a list of column object (the list from a question + * bank view). It extracts all the ones that should go in the menu + * and stores them for later use. Then it returns the remaining columns. + * + * @param column_base[] $allcolumns a set of columns. + * @return column_base[] the non-action columns from the set. + */ + public function claim_menuable_columns($allcolumns) { + $remainingcolumns = []; + foreach ($allcolumns as $key => $column) { + if ($column instanceof menuable_action) { + $this->actions[$key] = $column; + } else { + $remainingcolumns[$key] = $column; + } + } + return $remainingcolumns; + } + + protected function get_title() { + return get_string('actions'); + } + + public function get_name() { + return 'editmenu'; + } + + protected function display_content($question, $rowclasses) { + global $OUTPUT; + + $menu = new \action_menu(); + $menu->set_menu_trigger(get_string('edit')); + $menu->set_alignment(\action_menu::TL, \action_menu::BL); + foreach ($this->actions as $actioncolumn) { + $action = $actioncolumn->get_action_menu_link($question); + if ($action) { + $menu->add($action); + } + } + + $qtypeactions = \question_bank::get_qtype($question->qtype, false) + ->get_extra_question_bank_actions($question); + foreach ($qtypeactions as $action) { + $menu->add($action); + } + + echo $OUTPUT->render($menu); + } + + public function get_required_fields() { + return ['q.qtype']; + } +} diff --git a/question/classes/local/bank/menu_action_column_base.php b/question/classes/local/bank/menu_action_column_base.php new file mode 100644 index 00000000000..e471a5d43e4 --- /dev/null +++ b/question/classes/local/bank/menu_action_column_base.php @@ -0,0 +1,70 @@ +. + +/** + * Base class to make it easier to implement actions that are menuable_actions. + * + * @package core_question + * @copyright 2019 Tim Hunt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_question\local\bank; +defined('MOODLE_INTERNAL') || die(); + + +/** + * Base class to make it easier to implement actions that are menuable_actions. + * + * Use this class if your action is simple (defined by just a URL, label and icon). + * If your action is not simple enough to fit into the pattern that this + * class implements, then you will have to implement the menuable_action + * interface yourself. + * + * @copyright 2019 Tim Hunt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class menu_action_column_base extends action_column_base implements menuable_action { + + /** + * Get the information required to display this action either as a menu item or a separate action column. + * + * If this action cannot apply to this question (e.g. because the user does not have + * permission, then return [null, null, null]. + * + * @param \stdClass $question the row from the $question table, augmented with extra information. + * @return array with three elements. + * $url - the URL to perform the action. + * $icon - the icon for this action. E.g. 't/delete'. + * $label - text label to display in the UI (either in the menu, or as a tool-tip on the icon) + */ + abstract protected function get_url_icon_and_label(\stdClass $question): array; + + protected function display_content($question, $rowclasses) { + [$url, $icon, $label] = $this->get_url_icon_and_label($question); + if ($url) { + $this->print_icon($icon, $label, $url); + } + } + + public function get_action_menu_link(\stdClass $question): ?\action_menu_link { + [$url, $icon, $label] = $this->get_url_icon_and_label($question); + if (!$url) { + return null; + } + return new \action_menu_link_secondary($url, new \pix_icon($icon, ''), $label); + } +} diff --git a/question/classes/local/bank/menuable_action.php b/question/classes/local/bank/menuable_action.php new file mode 100644 index 00000000000..6015b7f3461 --- /dev/null +++ b/question/classes/local/bank/menuable_action.php @@ -0,0 +1,56 @@ +. + +/** + * Interface to indicate that a question bank column can go in the action menu. + * + * @package core_question + * @copyright 2019 Tim Hunt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_question\local\bank; +defined('MOODLE_INTERNAL') || die(); + + +/** + * Interface to indicate that a question bank column can go in the action menu. + * + * If a question bank column implements this interface, and if the {@link edit_menu_column} + * is present in the question bank view, then the 'column' will be shown as an entry in the + * edit menu instead of as a separate column. + * + * Probably most columns that want to implement this will be subclasses of + * {@link action_column_base}, and most such columns should probably implement + * this interface. + * + * If your column is a simple action, you can probably save duplicated code by + * using the base class action_column_menuable as an easy way to implement both + * action_column_base and this interface. + * + * @copyright 2019 Tim Hunt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface menuable_action { + + /** + * Return the appropriate action menu link, or null if it does not apply to this question. + * + * @param \stdClass $question data about the question being displayed in this row. + * @return \action_menu_link|null the action, if applicable to this question. + */ + public function get_action_menu_link(\stdClass $question): ?\action_menu_link; +} diff --git a/question/classes/local/bank/random_question_loader.php b/question/classes/local/bank/random_question_loader.php new file mode 100644 index 00000000000..0b96bd6314b --- /dev/null +++ b/question/classes/local/bank/random_question_loader.php @@ -0,0 +1,322 @@ +. + +/** + * A class for efficiently finds questions at random from the question bank. + * + * @package core_question + * @copyright 2015 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_question\local\bank; + + +/** + * This class efficiently finds questions at random from the question bank. + * + * You can ask for questions at random one at a time. Each time you ask, you + * pass a category id, and whether to pick from that category and all subcategories + * or just that category. + * + * The number of teams each question has been used is tracked, and we will always + * return a question from among those elegible that has been used the fewest times. + * So, if there are questions that have not been used yet in the category asked for, + * one of those will be returned. However, within one instantiation of this class, + * we will never return a given question more than once, and we will never return + * questions passed into the constructor as $usedquestions. + * + * @copyright 2015 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class random_question_loader { + /** @var \qubaid_condition which usages to consider previous attempts from. */ + protected $qubaids; + + /** @var array qtypes that cannot be used by random questions. */ + protected $excludedqtypes; + + /** @var array categoryid & include subcategories => num previous uses => questionid => 1. */ + protected $availablequestionscache = array(); + + /** + * @var array questionid => num recent uses. Questions that have been used, + * but that is not yet recorded in the DB. + */ + protected $recentlyusedquestions; + + /** + * Constructor. + * @param \qubaid_condition $qubaids the usages to consider when counting previous uses of each question. + * @param array $usedquestions questionid => number of times used count. If we should allow for + * further existing uses of a question in addition to the ones in $qubaids. + */ + public function __construct(\qubaid_condition $qubaids, array $usedquestions = array()) { + $this->qubaids = $qubaids; + $this->recentlyusedquestions = $usedquestions; + + foreach (\question_bank::get_all_qtypes() as $qtype) { + if (!$qtype->is_usable_by_random()) { + $this->excludedqtypes[] = $qtype->name(); + } + } + } + + /** + * Pick a question at random from the given category, from among those with the fewest uses. + * If an array of tag ids are specified, then only the questions that are tagged with ALL those tags will be selected. + * + * It is up the the caller to verify that the cateogry exists. An unknown category + * behaves like an empty one. + * + * @param int $categoryid the id of a category in the question bank. + * @param bool $includesubcategories wether to pick a question from exactly + * that category, or that category and subcategories. + * @param array $tagids An array of tag ids. A question has to be tagged with all the provided tagids (if any) + * in order to be eligible for being picked. + * @return int|null the id of the question picked, or null if there aren't any. + */ + public function get_next_question_id($categoryid, $includesubcategories, $tagids = []) { + $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); + + $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); + if (empty($this->availablequestionscache[$categorykey])) { + return null; + } + + reset($this->availablequestionscache[$categorykey]); + $lowestcount = key($this->availablequestionscache[$categorykey]); + reset($this->availablequestionscache[$categorykey][$lowestcount]); + $questionid = key($this->availablequestionscache[$categorykey][$lowestcount]); + $this->use_question($questionid); + return $questionid; + } + + /** + * Get the key into {@link $availablequestionscache} for this combination of options. + * @param int $categoryid the id of a category in the question bank. + * @param bool $includesubcategories wether to pick a question from exactly + * that category, or that category and subcategories. + * @param array $tagids an array of tag ids. + * @return string the cache key. + */ + protected function get_category_key($categoryid, $includesubcategories, $tagids = []) { + if ($includesubcategories) { + $key = $categoryid . '|1'; + } else { + $key = $categoryid . '|0'; + } + + if (!empty($tagids)) { + $key .= '|' . implode('|', $tagids); + } + + return $key; + } + + /** + * Populate {@link $availablequestionscache} for this combination of options. + * @param int $categoryid The id of a category in the question bank. + * @param bool $includesubcategories Whether to pick a question from exactly + * that category, or that category and subcategories. + * @param array $tagids An array of tag ids. If an array is provided, then + * only the questions that are tagged with ALL the provided tagids will be loaded. + */ + protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids = []) { + global $DB; + + $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); + + if (isset($this->availablequestionscache[$categorykey])) { + // Data is already in the cache, nothing to do. + return; + } + + // Load the available questions from the question bank. + if ($includesubcategories) { + $categoryids = question_categorylist($categoryid); + } else { + $categoryids = array($categoryid); + } + + list($extraconditions, $extraparams) = $DB->get_in_or_equal($this->excludedqtypes, + SQL_PARAMS_NAMED, 'excludedqtype', false); + + $questionidsandcounts = \question_bank::get_finder()->get_questions_from_categories_and_tags_with_usage_counts( + $categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams, $tagids); + if (!$questionidsandcounts) { + // No questions in this category. + $this->availablequestionscache[$categorykey] = array(); + return; + } + + // Put all the questions with each value of $prevusecount in separate arrays. + $idsbyusecount = array(); + foreach ($questionidsandcounts as $questionid => $prevusecount) { + if (isset($this->recentlyusedquestions[$questionid])) { + // Recently used questions are never returned. + continue; + } + $idsbyusecount[$prevusecount][] = $questionid; + } + + // Now put that data into our cache. For each count, we need to shuffle + // questionids, and make those the keys of an array. + $this->availablequestionscache[$categorykey] = array(); + foreach ($idsbyusecount as $prevusecount => $questionids) { + shuffle($questionids); + $this->availablequestionscache[$categorykey][$prevusecount] = array_combine( + $questionids, array_fill(0, count($questionids), 1)); + } + ksort($this->availablequestionscache[$categorykey]); + } + + /** + * Update the internal data structures to indicate that a given question has + * been used one more time. + * + * @param int $questionid the question that is being used. + */ + protected function use_question($questionid) { + if (isset($this->recentlyusedquestions[$questionid])) { + $this->recentlyusedquestions[$questionid] += 1; + } else { + $this->recentlyusedquestions[$questionid] = 1; + } + + foreach ($this->availablequestionscache as $categorykey => $questionsforcategory) { + foreach ($questionsforcategory as $numuses => $questionids) { + if (!isset($questionids[$questionid])) { + continue; + } + unset($this->availablequestionscache[$categorykey][$numuses][$questionid]); + if (empty($this->availablequestionscache[$categorykey][$numuses])) { + unset($this->availablequestionscache[$categorykey][$numuses]); + } + } + } + } + + /** + * Get the list of available question ids for the given criteria. + * + * @param int $categoryid The id of a category in the question bank. + * @param bool $includesubcategories Whether to pick a question from exactly + * that category, or that category and subcategories. + * @param array $tagids An array of tag ids. If an array is provided, then + * only the questions that are tagged with ALL the provided tagids will be loaded. + * @return int[] The list of question ids + */ + protected function get_question_ids($categoryid, $includesubcategories, $tagids = []) { + $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); + $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); + $cachedvalues = $this->availablequestionscache[$categorykey]; + $questionids = []; + + foreach ($cachedvalues as $usecount => $ids) { + $questionids = array_merge($questionids, array_keys($ids)); + } + + return $questionids; + } + + /** + * Check whether a given question is available in a given category. If so, mark it used. + * If an optional list of tag ids are provided, then the question must be tagged with + * ALL of the provided tags to be considered as available. + * + * @param int $categoryid the id of a category in the question bank. + * @param bool $includesubcategories wether to pick a question from exactly + * that category, or that category and subcategories. + * @param int $questionid the question that is being used. + * @param array $tagids An array of tag ids. Only the questions that are tagged with all the provided tagids can be available. + * @return bool whether the question is available in the requested category. + */ + public function is_question_available($categoryid, $includesubcategories, $questionid, $tagids = []) { + $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); + $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); + + foreach ($this->availablequestionscache[$categorykey] as $questionids) { + if (isset($questionids[$questionid])) { + $this->use_question($questionid); + return true; + } + } + + return false; + } + + /** + * Get the list of available questions for the given criteria. + * + * @param int $categoryid The id of a category in the question bank. + * @param bool $includesubcategories Whether to pick a question from exactly + * that category, or that category and subcategories. + * @param array $tagids An array of tag ids. If an array is provided, then + * only the questions that are tagged with ALL the provided tagids will be loaded. + * @param int $limit Maximum number of results to return. + * @param int $offset Number of items to skip from the begging of the result set. + * @param string[] $fields The fields to return for each question. + * @return \stdClass[] The list of question records + */ + public function get_questions( + $categoryid, + $includesubcategories, + $tagids = [], + $limit = 100, + $offset = 0, + $fields = [] + ) { + global $DB; + + $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids); + if (empty($questionids)) { + return []; + } + + if (empty($fields)) { + // Return all fields. + $fieldsstring = '*'; + } else { + $fieldsstring = implode(',', $fields); + } + + return $DB->get_records_list( + 'question', + 'id', + $questionids, + 'id', + $fieldsstring, + $offset, + $limit + ); + } + + /** + * Count the number of available questions for the given criteria. + * + * @param int $categoryid The id of a category in the question bank. + * @param bool $includesubcategories Whether to pick a question from exactly + * that category, or that category and subcategories. + * @param array $tagids An array of tag ids. If an array is provided, then + * only the questions that are tagged with ALL the provided tagids will be loaded. + * @return int The number of questions matching the criteria. + */ + public function count_questions($categoryid, $includesubcategories, $tagids = []) { + $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids); + return count($questionids); + } +} diff --git a/question/classes/local/bank/row_base.php b/question/classes/local/bank/row_base.php new file mode 100644 index 00000000000..6768968cc95 --- /dev/null +++ b/question/classes/local/bank/row_base.php @@ -0,0 +1,52 @@ +. + +/** + * Base class for 'columns' that are actually displayed as a row following the main question row. + * + * @package core_question + * @copyright 2009 Tim Hunt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_question\local\bank; +defined('MOODLE_INTERNAL') || die(); + + +/** + * Base class for 'columns' that are actually displayed as a row following the main question row. + * + * @copyright 2009 Tim Hunt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class row_base extends column_base { + public function is_extra_row() { + return true; + } + + protected function display_start($question, $rowclasses) { + if ($rowclasses) { + echo '' . "\n"; + } else { + echo "\n"; + } + echo ''; + } + + protected function display_end($question, $rowclasses) { + echo "\n"; + } +} diff --git a/question/classes/local/bank/view.php b/question/classes/local/bank/view.php new file mode 100644 index 00000000000..2970e9774f9 --- /dev/null +++ b/question/classes/local/bank/view.php @@ -0,0 +1,1103 @@ +. + + +/** + * Class to print a view of the question bank. + * + * @package core_question + * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_question\local\local\bank; +defined('MOODLE_INTERNAL') || die(); + +use core_question\bank\search\condition; + + +/** + * This class prints a view of the question bank, including + * + Some controls to allow users to to select what is displayed. + * + A list of questions as a table. + * + Further controls to do things with the questions. + * + * This class gives a basic view, and provides plenty of hooks where subclasses + * can override parts of the display. + * + * The list of questions presented as a table is generated by creating a list of + * core_question\bank\column objects, one for each 'column' to be displayed. These + * manage + * + outputting the contents of that column, given a $question object, but also + * + generating the right fragments of SQL to ensure the necessary data is present, + * and sorted in the right order. + * + outputting table headers. + * + * @copyright 2009 Tim Hunt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class view { + const MAX_SORTS = 3; + + /** + * @var \moodle_url base URL for the current page. Used as the + * basis for making URLs for actions that reload the page. + */ + protected $baseurl; + + /** + * @var \moodle_url used as a basis for URLs that edit a question. + */ + protected $editquestionurl; + + /** + * @var \question_edit_contexts + */ + protected $contexts; + + /** + * @var object|\cm_info|null if we are in a module context, the cm. + */ + protected $cm; + + /** + * @var object the course we are within. + */ + protected $course; + + /** + * @var \question_bank_column_base[] these are all the 'columns' that are + * part of the display. Array keys are the class name. + */ + protected $requiredcolumns; + + /** + * @var \question_bank_column_base[] these are the 'columns' that are + * actually displayed as a column, in order. Array keys are the class name. + */ + protected $visiblecolumns; + + /** + * @var \question_bank_column_base[] these are the 'columns' that are + * actually displayed as an additional row (e.g. question text), in order. + * Array keys are the class name. + */ + protected $extrarows; + + /** + * @var array list of column class names for which columns to sort on. + */ + protected $sort; + + /** + * @var int|null id of the a question to highlight in the list (if present). + */ + protected $lastchangedid; + + /** + * @var string SQL to count the number of questions matching the current + * search conditions. + */ + protected $countsql; + + /** + * @var string SQL to actually load the question data to display. + */ + protected $loadsql; + + /** + * @var array params used by $countsql and $loadsql (which currently must be the same). + */ + protected $sqlparams; + + /** + * @var condition[] search conditions. + */ + protected $searchconditions = array(); + + /** + * Constructor + * @param \question_edit_contexts $contexts + * @param \moodle_url $pageurl + * @param object $course course settings + * @param object $cm (optional) activity settings. + */ + public function __construct($contexts, $pageurl, $course, $cm = null) { + $this->contexts = $contexts; + $this->baseurl = $pageurl; + $this->course = $course; + $this->cm = $cm; + + // Create the url of the new question page to forward to. + $returnurl = $pageurl->out_as_local_url(false); + $this->editquestionurl = new \moodle_url('/question/question.php', + array('returnurl' => $returnurl)); + if ($cm !== null) { + $this->editquestionurl->param('cmid', $cm->id); + } else { + $this->editquestionurl->param('courseid', $this->course->id); + } + + $this->lastchangedid = optional_param('lastchanged', 0, PARAM_INT); + + $this->init_columns($this->wanted_columns(), $this->heading_column()); + $this->init_sort(); + $this->init_search_conditions(); + } + + /** + * Initialize search conditions from plugins + * local_*_get_question_bank_search_conditions() must return an array of + * \core_question\bank\search\condition objects. + */ + protected function init_search_conditions() { + $searchplugins = get_plugin_list_with_function('local', 'get_question_bank_search_conditions'); + foreach ($searchplugins as $component => $function) { + foreach ($function($this) as $searchobject) { + $this->add_searchcondition($searchobject); + } + } + } + + protected function wanted_columns() { + global $CFG; + + if (empty($CFG->questionbankcolumns)) { + $questionbankcolumns = array('checkbox_column', 'question_type_column', + 'question_name_idnumber_tags_column', 'edit_menu_column', + 'edit_action_column', 'copy_action_column', 'tags_action_column', + 'preview_action_column', 'delete_action_column', 'export_xml_action_column', + 'creator_name_column', 'modifier_name_column'); + } else { + $questionbankcolumns = explode(',', $CFG->questionbankcolumns); + } + if (question_get_display_preference('qbshowtext', 0, PARAM_BOOL, new \moodle_url(''))) { + $questionbankcolumns[] = 'question_text_row'; + } + + foreach ($questionbankcolumns as $fullname) { + if (! class_exists($fullname)) { + if (class_exists('core_question\\bank\\' . $fullname)) { + $fullname = 'core_question\\bank\\' . $fullname; + } else { + throw new \coding_exception("No such class exists: $fullname"); + } + } + $this->requiredcolumns[$fullname] = new $fullname($this); + } + return $this->requiredcolumns; + } + + + /** + * Get a column object from its name. + * + * @param string $columnname. + * @return \core_question\bank\column_base. + */ + protected function get_column_type($columnname) { + if (! class_exists($columnname)) { + if (class_exists('core_question\\bank\\' . $columnname)) { + $columnname = 'core_question\\bank\\' . $columnname; + } else { + throw new \coding_exception("No such class exists: $columnname"); + } + } + if (empty($this->requiredcolumns[$columnname])) { + $this->requiredcolumns[$columnname] = new $columnname($this); + } + return $this->requiredcolumns[$columnname]; + } + + /** + * Specify the column heading + * + * @return string Column name for the heading + */ + protected function heading_column() { + return 'question_bank_question_name_column'; + } + + /** + * Initializing table columns + * + * @param array $wanted Collection of column names + * @param string $heading The name of column that is set as heading + */ + protected function init_columns($wanted, $heading = '') { + // If we are using the edit menu column, allow it to absorb all the actions. + foreach ($wanted as $column) { + if ($column instanceof edit_menu_column) { + $wanted = $column->claim_menuable_columns($wanted); + break; + } + } + + // Now split columns into real columns and rows. + $this->visiblecolumns = array(); + $this->extrarows = array(); + foreach ($wanted as $column) { + if ($column->is_extra_row()) { + $this->extrarows[get_class($column)] = $column; + } else { + $this->visiblecolumns[get_class($column)] = $column; + } + } + if (array_key_exists($heading, $this->requiredcolumns)) { + $this->requiredcolumns[$heading]->set_as_heading(); + } + } + + /** + * @param string $colname a column internal name. + * @return bool is this column included in the output? + */ + public function has_column($colname) { + return isset($this->visiblecolumns[$colname]); + } + + /** + * @return int The number of columns in the table. + */ + public function get_column_count() { + return count($this->visiblecolumns); + } + + public function get_courseid() { + return $this->course->id; + } + + protected function init_sort() { + $this->init_sort_from_params(); + if (empty($this->sort)) { + $this->sort = $this->default_sort(); + } + } + + /** + * Deal with a sort name of the form columnname, or colname_subsort by + * breaking it up, validating the bits that are present, and returning them. + * If there is no subsort, then $subsort is returned as ''. + * + * @param string $sort the sort parameter to process. + * @return array array($colname, $subsort). + */ + protected function parse_subsort($sort) { + // Do the parsing. + if (strpos($sort, '-') !== false) { + list($colname, $subsort) = explode('-', $sort, 2); + } else { + $colname = $sort; + $subsort = ''; + } + // Validate the column name. + $column = $this->get_column_type($colname); + if (!isset($column) || !$column->is_sortable()) { + for ($i = 1; $i <= self::MAX_SORTS; $i++) { + $this->baseurl->remove_params('qbs' . $i); + } + throw new \moodle_exception('unknownsortcolumn', '', $link = $this->baseurl->out(), $colname); + } + // Validate the subsort, if present. + if ($subsort) { + $subsorts = $column->is_sortable(); + if (!is_array($subsorts) || !isset($subsorts[$subsort])) { + throw new \moodle_exception('unknownsortcolumn', '', $link = $this->baseurl->out(), $sort); + } + } + return array($colname, $subsort); + } + + protected function init_sort_from_params() { + $this->sort = array(); + for ($i = 1; $i <= self::MAX_SORTS; $i++) { + if (!$sort = optional_param('qbs' . $i, '', PARAM_TEXT)) { + break; + } + // Work out the appropriate order. + $order = 1; + if ($sort[0] == '-') { + $order = -1; + $sort = substr($sort, 1); + if (!$sort) { + break; + } + } + // Deal with subsorts. + list($colname) = $this->parse_subsort($sort); + $this->requiredcolumns[$colname] = $this->get_column_type($colname); + $this->sort[$sort] = $order; + } + } + + protected function sort_to_params($sorts) { + $params = array(); + $i = 0; + foreach ($sorts as $sort => $order) { + $i += 1; + if ($order < 0) { + $sort = '-' . $sort; + } + $params['qbs' . $i] = $sort; + } + return $params; + } + + protected function default_sort() { + return array( + 'core_question\bank\question_type_column' => 1, + 'core_question\bank\question_name_idnumber_tags_column-name' => 1 + ); + } + + /** + * @param string $sort a column or column_subsort name. + * @return int the current sort order for this column -1, 0, 1 + */ + public function get_primary_sort_order($sort) { + $order = reset($this->sort); + $primarysort = key($this->sort); + if ($sort == $primarysort) { + return $order; + } else { + return 0; + } + } + + /** + * Get a URL to redisplay the page with a new sort for the question bank. + * + * @param string $sort the column, or column_subsort to sort on. + * @param bool $newsortreverse whether to sort in reverse order. + * @return string The new URL. + */ + public function new_sort_url($sort, $newsortreverse) { + if ($newsortreverse) { + $order = -1; + } else { + $order = 1; + } + // Tricky code to add the new sort at the start, removing it from where it was before, if it was present. + $newsort = array_reverse($this->sort); + if (isset($newsort[$sort])) { + unset($newsort[$sort]); + } + $newsort[$sort] = $order; + $newsort = array_reverse($newsort); + if (count($newsort) > self::MAX_SORTS) { + $newsort = array_slice($newsort, 0, self::MAX_SORTS, true); + } + return $this->baseurl->out(true, $this->sort_to_params($newsort)); + } + + /** + * Create the SQL query to retrieve the indicated questions + * + * @param \stdClass $category no longer used. + * @param bool $recurse no longer used. + * @param bool $showhidden no longer used. + * @deprecated since Moodle 2.7 MDL-40313. + * @see build_query() + * @see \core_question\bank\search\condition + * @todo MDL-41978 This will be deleted in Moodle 2.8 + */ + protected function build_query_sql($category, $recurse, $showhidden) { + debugging('build_query_sql() is deprecated, please use \core_question\bank\view::build_query() and ' . + '\core_question\bank\search\condition classes instead.', DEBUG_DEVELOPER); + self::build_query(); + } + + /** + * Create the SQL query to retrieve the indicated questions, based on + * \core_question\bank\search\condition filters. + */ + protected function build_query() { + // Get the required tables and fields. + $joins = array(); + $fields = array('q.hidden', 'q.category'); + foreach ($this->requiredcolumns as $column) { + $extrajoins = $column->get_extra_joins(); + foreach ($extrajoins as $prefix => $join) { + if (isset($joins[$prefix]) && $joins[$prefix] != $join) { + throw new \coding_exception('Join ' . $join . ' conflicts with previous join ' . $joins[$prefix]); + } + $joins[$prefix] = $join; + } + $fields = array_merge($fields, $column->get_required_fields()); + } + $fields = array_unique($fields); + + // Build the order by clause. + $sorts = array(); + foreach ($this->sort as $sort => $order) { + list($colname, $subsort) = $this->parse_subsort($sort); + $sorts[] = $this->requiredcolumns[$colname]->sort_expression($order < 0, $subsort); + } + + // Build the where clause. + $tests = array('q.parent = 0'); + $this->sqlparams = array(); + foreach ($this->searchconditions as $searchcondition) { + if ($searchcondition->where()) { + $tests[] = '((' . $searchcondition->where() .'))'; + } + if ($searchcondition->params()) { + $this->sqlparams = array_merge($this->sqlparams, $searchcondition->params()); + } + } + // Build the SQL. + $sql = ' FROM {question} q ' . implode(' ', $joins); + $sql .= ' WHERE ' . implode(' AND ', $tests); + $this->countsql = 'SELECT count(1)' . $sql; + $this->loadsql = 'SELECT ' . implode(', ', $fields) . $sql . ' ORDER BY ' . implode(', ', $sorts); + } + + protected function get_question_count() { + global $DB; + return $DB->count_records_sql($this->countsql, $this->sqlparams); + } + + /** + * Load the questions we need to display. + * + * @param int $page page to display. + * @param int $perpage number of questions per page. + * @return \moodle_recordset questionid => data about each question. + */ + protected function load_page_questions($page, $perpage) { + global $DB; + $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, $page * $perpage, $perpage); + if (empty($questions)) { + $questions->close(); + // No questions on this page. Reset to page 0. + $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, 0, $perpage); + } + return $questions; + } + + public function base_url() { + return $this->baseurl; + } + + /** + * Get the URL for editing a question as a {@link \moodle_url}. + * + * @param int $questionid the question id. + * @return \moodle_url the URL, HTML-escaped. + */ + public function edit_question_moodle_url($questionid) { + return new \moodle_url($this->editquestionurl, ['id' => $questionid]); + } + + /** + * Get the URL for editing a question as a HTML-escaped string. + * + * @param int $questionid the question id. + * @return string the URL, HTML-escaped. + */ + public function edit_question_url($questionid) { + return $this->edit_question_moodle_url($questionid)->out(); + } + + /** + * Get the URL for duplicating a question as a {@link \moodle_url}. + * + * @param int $questionid the question id. + * @return \moodle_url the URL. + */ + public function copy_question_moodle_url($questionid) { + return new \moodle_url($this->editquestionurl, ['id' => $questionid, 'makecopy' => 1]); + } + + /** + * Get the URL for duplicating a given question. + * @param int $questionid the question id. + * @return string the URL, HTML-escaped. + */ + public function copy_question_url($questionid) { + return $this->copy_question_moodle_url($questionid)->out(); + } + + /** + * Get the context we are displaying the question bank for. + * @return \context context object. + */ + public function get_most_specific_context() { + return $this->contexts->lowest(); + } + + /** + * Get the URL to preview a question. + * @param \stdClass $questiondata the data defining the question. + * @return \moodle_url the URL. + */ + public function preview_question_url($questiondata) { + return question_preview_url($questiondata->id, null, null, null, null, + $this->get_most_specific_context()); + } + + /** + * Shows the question bank editing interface. + * + * The function also processes a number of actions: + * + * Actions affecting the question pool: + * move Moves a question to a different category + * deleteselected Deletes the selected questions from the category + * Other actions: + * category Chooses the category + * + * @param string $tabname question bank edit tab name, for permission checking. + * @param int $page the page number to show. + * @param int $perpage the number of questions per page to show. + * @param string $cat 'categoryid,contextid'. + * @param int $recurse Whether to include subcategories. + * @param bool $showhidden whether deleted questions should be displayed. + * @param bool $showquestiontext whether the text of each question should be shown in the list. Deprecated. + * @param array $tagids current list of selected tags. + */ + public function display($tabname, $page, $perpage, $cat, + $recurse, $showhidden, $showquestiontext, $tagids = []) { + global $PAGE, $CFG; + + if ($this->process_actions_needing_ui()) { + return; + } + $editcontexts = $this->contexts->having_one_edit_tab_cap($tabname); + list(, $contextid) = explode(',', $cat); + $catcontext = \context::instance_by_id($contextid); + $thiscontext = $this->get_most_specific_context(); + // Category selection form. + $this->display_question_bank_header(); + + // Display tag filter if usetags setting is enabled. + if ($CFG->usetags) { + array_unshift($this->searchconditions, + new \core_question\bank\search\tag_condition([$catcontext, $thiscontext], $tagids)); + $PAGE->requires->js_call_amd('core_question/edit_tags', 'init', ['#questionscontainer']); + } + + array_unshift($this->searchconditions, new \core_question\bank\search\hidden_condition(!$showhidden)); + array_unshift($this->searchconditions, new \core_question\bank\search\category_condition( + $cat, $recurse, $editcontexts, $this->baseurl, $this->course)); + $this->display_options_form($showquestiontext); + + // Continues with list of questions. + $this->display_question_list($editcontexts, + $this->baseurl, $cat, $this->cm, + null, $page, $perpage, $showhidden, $showquestiontext, + $this->contexts->having_cap('moodle/question:add')); + + } + + protected function print_choose_category_message($categoryandcontext) { + echo "

"; + print_string('selectcategoryabove', 'question'); + echo "

"; + } + + protected function get_current_category($categoryandcontext) { + global $DB, $OUTPUT; + list($categoryid, $contextid) = explode(',', $categoryandcontext); + if (!$categoryid) { + $this->print_choose_category_message($categoryandcontext); + return false; + } + + if (!$category = $DB->get_record('question_categories', + array('id' => $categoryid, 'contextid' => $contextid))) { + echo $OUTPUT->box_start('generalbox questionbank'); + echo $OUTPUT->notification('Category not found!'); + echo $OUTPUT->box_end(); + return false; + } + + return $category; + } + + /** + * prints category information + * @param \stdClass $category the category row from the database. + * @deprecated since Moodle 2.7 MDL-40313. + * @see \core_question\bank\search\condition + * @todo MDL-41978 This will be deleted in Moodle 2.8 + */ + protected function print_category_info($category) { + $formatoptions = new \stdClass(); + $formatoptions->noclean = true; + $formatoptions->overflowdiv = true; + echo '
'; + echo format_text($category->info, $category->infoformat, $formatoptions, $this->course->id); + echo "
\n"; + } + + /** + * Prints a form to choose categories + * @deprecated since Moodle 2.7 MDL-40313. + * @see \core_question\bank\search\condition + * @todo MDL-41978 This will be deleted in Moodle 2.8 + */ + protected function display_category_form($contexts, $pageurl, $current) { + global $OUTPUT; + + debugging('display_category_form() is deprecated, please use ' . + '\core_question\bank\search\condition instead.', DEBUG_DEVELOPER); + // Get all the existing categories now. + echo '
'; + $catmenu = question_category_options($contexts, false, 0, true); + + $select = new \single_select($this->baseurl, 'category', $catmenu, $current, null, 'catmenu'); + $select->set_label(get_string('selectacategory', 'question')); + echo $OUTPUT->render($select); + echo "
\n"; + } + + /** + * Display the options form. + * @param bool $recurse no longer used. + * @param bool $showhidden no longer used. + * @param bool $showquestiontext whether to show the question text. + * @deprecated since Moodle 2.7 MDL-40313. + * @see display_options_form + * @todo MDL-41978 This will be deleted in Moodle 2.8 + * @see \core_question\bank\search\condition + */ + protected function display_options($recurse, $showhidden, $showquestiontext) { + debugging('display_options() is deprecated, please use display_options_form instead.', DEBUG_DEVELOPER); + $this->display_options_form($showquestiontext); + } + + /** + * Print a single option checkbox. + * @deprecated since Moodle 2.7 MDL-40313. + * @see \core_question\bank\search\condition + * @see html_writer::checkbox + * @todo MDL-41978 This will be deleted in Moodle 2.8 + */ + protected function display_category_form_checkbox($name, $value, $label) { + debugging('display_category_form_checkbox() is deprecated, ' . + 'please use \core_question\bank\search\condition instead.', DEBUG_DEVELOPER); + echo '
'; + echo ''; + echo ''; + echo "
\n"; + } + + /** + * Display the form with options for which questions are displayed and how they are displayed. + * @param bool $showquestiontext Display the text of the question within the list. + * @param string $scriptpath path to the script displaying this page. + * @param bool $showtextoption whether to include the 'Show question text' checkbox. + */ + protected function display_options_form($showquestiontext, $scriptpath = '/question/edit.php', + $showtextoption = true) { + global $PAGE; + + echo \html_writer::start_tag('form', array('method' => 'get', + 'action' => new \moodle_url($scriptpath), 'id' => 'displayoptions')); + echo \html_writer::start_div(); + + $excludes = array('recurse', 'showhidden', 'qbshowtext'); + // If the URL contains any tags then we need to prevent them + // being added to the form as hidden elements because the tags + // are managed separately. + if ($this->baseurl->param('qtagids[0]')) { + $index = 0; + while ($this->baseurl->param("qtagids[{$index}]")) { + $excludes[] = "qtagids[{$index}]"; + $index++; + } + } + echo \html_writer::input_hidden_params($this->baseurl, $excludes); + + foreach ($this->searchconditions as $searchcondition) { + echo $searchcondition->display_options(); + } + if ($showtextoption) { + $this->display_showtext_checkbox($showquestiontext); + } + $this->display_advanced_search_form(); + $go = \html_writer::empty_tag('input', array('type' => 'submit', 'value' => get_string('go'))); + echo \html_writer::tag('noscript', \html_writer::div($go), array('class' => 'inline')); + echo \html_writer::end_div(); + echo \html_writer::end_tag('form'); + $PAGE->requires->yui_module('moodle-question-searchform', 'M.question.searchform.init'); + } + + /** + * Print the "advanced" UI elements for the form to select which questions. Hidden by default. + */ + protected function display_advanced_search_form() { + print_collapsible_region_start('', 'advancedsearch', get_string('advancedsearchoptions', 'question'), + 'question_bank_advanced_search'); + foreach ($this->searchconditions as $searchcondition) { + echo $searchcondition->display_options_adv(); + } + print_collapsible_region_end(); + } + + /** + * Display the checkbox UI for toggling the display of the question text in the list. + * @param bool $showquestiontext the current or default value for whether to display the text. + */ + protected function display_showtext_checkbox($showquestiontext) { + echo '
'; + echo \html_writer::empty_tag('input', array('type' => 'hidden', 'name' => 'qbshowtext', + 'value' => 0, 'id' => 'qbshowtext_off')); + echo \html_writer::checkbox('qbshowtext', '1', $showquestiontext, ' ' . get_string('showquestiontext', 'question'), + array('id' => 'qbshowtext_on', 'class' => 'searchoptions mr-1')); + echo "
\n"; + } + + /** + * Display the header element for the question bank. + */ + protected function display_question_bank_header() { + global $OUTPUT; + echo $OUTPUT->heading(get_string('questionbank', 'question'), 2); + } + + protected function create_new_question_form($category, $canadd) { + echo '
'; + if ($canadd) { + create_new_question_button($category->id, $this->editquestionurl->params(), + get_string('createnewquestion', 'question')); + } else { + print_string('nopermissionadd', 'question'); + } + echo '
'; + } + + /** + * Prints the table of questions in a category with interactions + * + * @param array $contexts Not used! + * @param \moodle_url $pageurl The URL to reload this page. + * @param string $categoryandcontext 'categoryID,contextID'. + * @param \stdClass $cm Not used! + * @param int $recurse Whether to include subcategories. + * @param int $page The number of the page to be displayed + * @param int $perpage Number of questions to show per page + * @param bool $showhidden Not used! This is now controlled in a different way. + * @param bool $showquestiontext Not used! This is now controlled in a different way. + * @param array $addcontexts contexts where the user is allowed to add new questions. + */ + protected function display_question_list($contexts, $pageurl, $categoryandcontext, + $cm = null, $recurse=1, $page=0, $perpage=100, $showhidden=false, + $showquestiontext = false, $addcontexts = array()) { + global $OUTPUT; + + // This function can be moderately slow with large question counts and may time out. + // We probably do not want to raise it to unlimited, so randomly picking 5 minutes. + // Note: We do not call this in the loop because quiz ob_ captures this function (see raise() PHP doc). + \core_php_time_limit::raise(300); + + $category = $this->get_current_category($categoryandcontext); + + list($categoryid, $contextid) = explode(',', $categoryandcontext); + $catcontext = \context::instance_by_id($contextid); + + $canadd = has_capability('moodle/question:add', $catcontext); + + $this->create_new_question_form($category, $canadd); + + $this->build_query(); + $totalnumber = $this->get_question_count(); + if ($totalnumber == 0) { + return; + } + $questionsrs = $this->load_page_questions($page, $perpage); + $questions = []; + foreach ($questionsrs as $question) { + $questions[$question->id] = $question; + } + $questionsrs->close(); + foreach ($this->requiredcolumns as $name => $column) { + $column->load_additional_data($questions); + } + + echo '
'; + $pageingurl = new \moodle_url('edit.php', $pageurl->params()); + $pagingbar = new \paging_bar($totalnumber, $page, $perpage, $pageingurl); + $pagingbar->pagevar = 'qpage'; + echo $OUTPUT->render($pagingbar); + echo '
'; + + echo '
'; + echo '
'; + echo ''; + echo \html_writer::input_hidden_params($this->baseurl); + + echo '
'; + $this->start_table(); + $rowcount = 0; + foreach ($questions as $question) { + $this->print_table_row($question, $rowcount); + $rowcount += 1; + } + $this->end_table(); + echo "
\n"; + + echo '
'; + echo $OUTPUT->render($pagingbar); + if ($totalnumber > DEFAULT_QUESTIONS_PER_PAGE) { + if ($perpage == DEFAULT_QUESTIONS_PER_PAGE) { + $url = new \moodle_url('edit.php', array_merge($pageurl->params(), + array('qpage' => 0, 'qperpage' => MAXIMUM_QUESTIONS_PER_PAGE))); + if ($totalnumber > MAXIMUM_QUESTIONS_PER_PAGE) { + $showall = ''.get_string('showperpage', 'moodle', MAXIMUM_QUESTIONS_PER_PAGE).''; + } else { + $showall = ''.get_string('showall', 'moodle', $totalnumber).''; + } + } else { + $url = new \moodle_url('edit.php', array_merge($pageurl->params(), + array('qperpage' => DEFAULT_QUESTIONS_PER_PAGE))); + $showall = ''.get_string('showperpage', 'moodle', DEFAULT_QUESTIONS_PER_PAGE).''; + } + echo "
{$showall}
"; + } + echo '
'; + + $this->display_bottom_controls($totalnumber, $recurse, $category, $catcontext, $addcontexts); + + echo '
'; + echo "
\n"; + } + + /** + * Display the controls at the bottom of the list of questions. + * @param int $totalnumber Total number of questions that might be shown (if it was not for paging). + * @param bool $recurse Whether to include subcategories. + * @param \stdClass $category The question_category row from the database. + * @param \context $catcontext The context of the category being displayed. + * @param array $addcontexts contexts where the user is allowed to add new questions. + */ + protected function display_bottom_controls($totalnumber, $recurse, $category, \context $catcontext, array $addcontexts) { + $caneditall = has_capability('moodle/question:editall', $catcontext); + $canuseall = has_capability('moodle/question:useall', $catcontext); + $canmoveall = has_capability('moodle/question:moveall', $catcontext); + + echo '
'; + if ($caneditall || $canmoveall || $canuseall) { + echo ' '.get_string('withselected', 'question').':
'; + + // Print delete and move selected question. + if ($caneditall) { + echo \html_writer::empty_tag('input', [ + 'type' => 'submit', + 'class' => 'btn btn-secondary mr-1', + 'name' => 'deleteselected', + 'value' => get_string('delete'), + 'data-action' => 'toggle', + 'data-togglegroup' => 'qbank', + 'data-toggle' => 'action', + 'disabled' => true, + ]); + } + + if ($canmoveall && count($addcontexts)) { + echo \html_writer::empty_tag('input', [ + 'type' => 'submit', + 'class' => 'btn btn-secondary mr-1', + 'name' => 'move', + 'value' => get_string('moveto', 'question'), + 'data-action' => 'toggle', + 'data-togglegroup' => 'qbank', + 'data-toggle' => 'action', + 'disabled' => true, + ]); + question_category_select_menu($addcontexts, false, 0, "{$category->id},{$category->contextid}"); + } + } + echo "
\n"; + } + + protected function start_table() { + echo '' . "\n"; + echo "\n"; + $this->print_table_headers(); + echo "\n"; + echo "\n"; + } + + protected function end_table() { + echo "\n"; + echo "
\n"; + } + + protected function print_table_headers() { + echo "\n"; + foreach ($this->visiblecolumns as $column) { + $column->display_header(); + } + echo "\n"; + } + + protected function get_row_classes($question, $rowcount) { + $classes = array(); + if ($question->hidden) { + $classes[] = 'dimmed_text'; + } + if ($question->id == $this->lastchangedid) { + $classes[] = 'highlight text-dark'; + } + $classes[] = 'r' . ($rowcount % 2); + return $classes; + } + + protected function print_table_row($question, $rowcount) { + $rowclasses = implode(' ', $this->get_row_classes($question, $rowcount)); + if ($rowclasses) { + echo '' . "\n"; + } else { + echo "\n"; + } + foreach ($this->visiblecolumns as $column) { + $column->display($question, $rowclasses); + } + echo "\n"; + foreach ($this->extrarows as $row) { + $row->display($question, $rowclasses); + } + } + + public function process_actions() { + global $DB; + // Now, check for commands on this page and modify variables as necessary. + if (optional_param('move', false, PARAM_BOOL) and confirm_sesskey()) { + // Move selected questions to new category. + $category = required_param('category', PARAM_SEQUENCE); + list($tocategoryid, $contextid) = explode(',', $category); + if (! $tocategory = $DB->get_record('question_categories', array('id' => $tocategoryid, 'contextid' => $contextid))) { + print_error('cannotfindcate', 'question'); + } + $tocontext = \context::instance_by_id($contextid); + require_capability('moodle/question:add', $tocontext); + $rawdata = (array) data_submitted(); + $questionids = array(); + foreach ($rawdata as $key => $value) { // Parse input for question ids. + if (preg_match('!^q([0-9]+)$!', $key, $matches)) { + $key = $matches[1]; + $questionids[] = $key; + } + } + if ($questionids) { + list($usql, $params) = $DB->get_in_or_equal($questionids); + $questions = $DB->get_records_sql(" + SELECT q.*, c.contextid + FROM {question} q + JOIN {question_categories} c ON c.id = q.category + WHERE q.id {$usql}", $params); + foreach ($questions as $question) { + question_require_capability_on($question, 'move'); + } + question_move_questions_to_category($questionids, $tocategory->id); + redirect($this->baseurl->out(false, + array('category' => "{$tocategoryid},{$contextid}"))); + } + } + + if (optional_param('deleteselected', false, PARAM_BOOL)) { // Delete selected questions from the category. + // If teacher has already confirmed the action. + if (($confirm = optional_param('confirm', '', PARAM_ALPHANUM)) and confirm_sesskey()) { + $deleteselected = required_param('deleteselected', PARAM_RAW); + if ($confirm == md5($deleteselected)) { + if ($questionlist = explode(',', $deleteselected)) { + // For each question either hide it if it is in use or delete it. + foreach ($questionlist as $questionid) { + $questionid = (int)$questionid; + question_require_capability_on($questionid, 'edit'); + if (questions_in_use(array($questionid))) { + $DB->set_field('question', 'hidden', 1, array('id' => $questionid)); + } else { + question_delete_question($questionid); + } + } + } + redirect($this->baseurl); + } else { + print_error('invalidconfirm', 'question'); + } + } + } + + // Unhide a question. + if (($unhide = optional_param('unhide', '', PARAM_INT)) and confirm_sesskey()) { + question_require_capability_on($unhide, 'edit'); + $DB->set_field('question', 'hidden', 0, array('id' => $unhide)); + + // Purge these questions from the cache. + \question_bank::notify_question_edited($unhide); + + redirect($this->baseurl); + } + } + + public function process_actions_needing_ui() { + global $DB, $OUTPUT; + if (optional_param('deleteselected', false, PARAM_BOOL)) { + // Make a list of all the questions that are selected. + $rawquestions = $_REQUEST; // This code is called by both POST forms and GET links, so cannot use data_submitted. + $questionlist = ''; // comma separated list of ids of questions to be deleted + $questionnames = ''; // string with names of questions separated by
with + // an asterix in front of those that are in use + $inuse = false; // set to true if at least one of the questions is in use + foreach ($rawquestions as $key => $value) { // Parse input for question ids. + if (preg_match('!^q([0-9]+)$!', $key, $matches)) { + $key = $matches[1]; + $questionlist .= $key.','; + question_require_capability_on((int)$key, 'edit'); + if (questions_in_use(array($key))) { + $questionnames .= '* '; + $inuse = true; + } + $questionnames .= $DB->get_field('question', 'name', array('id' => $key)) . '
'; + } + } + if (!$questionlist) { // No questions were selected. + redirect($this->baseurl); + } + $questionlist = rtrim($questionlist, ','); + + // Add an explanation about questions in use. + if ($inuse) { + $questionnames .= '
'.get_string('questionsinuse', 'question'); + } + $baseurl = new \moodle_url('edit.php', $this->baseurl->params()); + $deleteurl = new \moodle_url($baseurl, array('deleteselected' => $questionlist, 'confirm' => md5($questionlist), + 'sesskey' => sesskey())); + + $continue = new \single_button($deleteurl, get_string('delete'), 'post'); + echo $OUTPUT->confirm(get_string('deletequestionscheck', 'question', $questionnames), $continue, $baseurl); + + return true; + } + + return false; + } + + /** + * Add another search control to this view. + * @param condition $searchcondition the condition to add. + */ + public function add_searchcondition($searchcondition) { + $this->searchconditions[] = $searchcondition; + } +} diff --git a/question/upgrade.txt b/question/upgrade.txt index 6354f1ff602..f2b7fedbf61 100644 --- a/question/upgrade.txt +++ b/question/upgrade.txt @@ -1,5 +1,12 @@ This files describes API changes for code that uses the question API. +=== 4.0 == + +1) Previously, the questionbank api classes were coupled in one place. Now the classes + are divided in two different parts, base classes and feature classes. All the base + classes are moved classes/local/bank and all the feature classes will be moved to + the plugin for that feature. + === 3.9 == 1) For years, the ..._questions_in_use callback has been the right way for plugins to