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 '
\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";
+ }
+}
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 "
\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 '
\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 '
';
+ }
+
+ /**
+ * 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 '
';
+
+ 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 '
\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