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