. /** * 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\bank; use core_question\bank\search\condition; use qbank_editquestion\editquestion_helper; use qbank_managecategories\helper; /** * 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 * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class view { /** * Maximum number of sorts allowed. */ 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. */ public $cm; /** * @var object the course we are within. */ public $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 = []; /** * @var string url of the new question page. */ public $returnurl; /** * @var bool enable or disable filters while calling the API. */ public $enablefilters = true; /** * @var array to pass custom filters instead of the specified ones. */ public $customfilterobjects = null; /** * Constructor for view. * * @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. $this->returnurl = $pageurl->out_as_local_url(false); $this->editquestionurl = new \moodle_url('/question/bank/editquestion/question.php', ['returnurl' => $this->returnurl]); if ($this->cm !== null) { $this->editquestionurl->param('cmid', $this->cm->id); } else { $this->editquestionurl->param('courseid', $this->course->id); } $this->lastchangedid = optional_param('lastchanged', 0, PARAM_INT); // Possibly the heading part can be removed. $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(): void { $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); } } } /** * Get the list of qbank plugins with available objects for features. * * @return array */ protected function get_question_bank_plugins(): array { $questionbankclasscolumns = []; $newpluginclasscolumns = []; $corequestionbankcolumns = [ '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' ]; if (question_get_display_preference('qbshowtext', 0, PARAM_BOOL, new \moodle_url(''))) { $corequestionbankcolumns[] = 'question_text_row'; } foreach ($corequestionbankcolumns as $fullname) { $shortname = $fullname; if (class_exists('core_question\\local\\bank\\' . $fullname)) { $fullname = 'core_question\\local\\bank\\' . $fullname; $questionbankclasscolumns[$shortname] = new $fullname($this); } else { $questionbankclasscolumns[$shortname] = ''; } } $plugins = \core_component::get_plugin_list_with_class('qbank', 'plugin_feature', 'plugin_feature.php'); foreach ($plugins as $componentname => $plugin) { $pluginentrypointobject = new $plugin(); $pluginobjects = $pluginentrypointobject->get_question_columns($this); // Don't need the plugins without column objects. if (empty($pluginobjects)) { unset($plugins[$componentname]); continue; } foreach ($pluginobjects as $pluginobject) { $classname = new \ReflectionClass(get_class($pluginobject)); foreach ($corequestionbankcolumns as $key => $corequestionbankcolumn) { if (!\core\plugininfo\qbank::is_plugin_enabled($componentname)) { unset($questionbankclasscolumns[$classname->getShortName()]); continue; } // Check if it has custom preference selector to view/hide. if ($pluginobject->has_preference()) { if (!$pluginobject->get_preference()) { continue; } } if ($corequestionbankcolumn === $classname->getShortName()) { $questionbankclasscolumns[$classname->getShortName()] = $pluginobject; } else { // Any community plugin for column/action. $newpluginclasscolumns[$classname->getShortName()] = $pluginobject; } } } } // New plugins added at the end of the array, will change in sorting feature. foreach ($newpluginclasscolumns as $key => $newpluginclasscolumn) { $questionbankclasscolumns[$key] = $newpluginclasscolumn; } // Mitigate the error in case of any regression. foreach ($questionbankclasscolumns as $shortname => $questionbankclasscolumn) { if (empty($questionbankclasscolumn)){ unset($questionbankclasscolumns[$shortname]); } } return $questionbankclasscolumns; } /** * Loads all the available columns. * * @return array */ protected function wanted_columns(): array { $this->requiredcolumns = []; $questionbankcolumns = $this->get_question_bank_plugins(); foreach ($questionbankcolumns as $classobject) { if (empty($classobject)) { continue; } $this->requiredcolumns[get_class($classobject)] = $classobject; } return $this->requiredcolumns; } /** * Check a column object from its name and get the object for sort. * * @param string $columnname */ protected function get_column_type($columnname) { if (empty($this->requiredcolumns[$columnname])) { $this->requiredcolumns[$columnname] = new $columnname($this); } } /** * Specify the column heading * * @return string Column name for the heading */ protected function heading_column(): string { return 'qbank_viewquestionname\viewquestionname_column_helper'; } /** * 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 = ''): void { // 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 = []; $this->extrarows = []; 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(); } } /** * Checks if the column included in the output. * * @param string $colname a column internal name. * @return bool is this column included in the output? */ public function has_column($colname): bool { return isset($this->visiblecolumns[$colname]); } /** * Get the count of the columns. * * @return int The number of columns in the table. */ public function get_column_count(): int { return count($this->visiblecolumns); } /** * Get course id. * @return mixed */ public function get_courseid() { return $this->course->id; } /** * Initialise sorting. */ protected function init_sort(): void { $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 [$colname, $subsort]. */ protected function parse_subsort($sort): array { // Do the parsing. if (strpos($sort, '-') !== false) { list($colname, $subsort) = explode('-', $sort, 2); } else { $colname = $sort; $subsort = ''; } // Validate the column name. $this->get_column_type($colname); $column = $this->requiredcolumns[$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 [$colname, $subsort]; } /** * Initialise sort from parameters. */ protected function init_sort_from_params(): void { $this->sort = []; 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->get_column_type($colname); $this->sort[$sort] = $order; } } /** * Sort to parameters. * * @param array $sorts * @return array */ protected function sort_to_params($sorts): array { $params = []; $i = 0; foreach ($sorts as $sort => $order) { $i += 1; if ($order < 0) { $sort = '-' . $sort; } $params['qbs' . $i] = $sort; } return $params; } /** * Default sort for question data. * @return int[] */ protected function default_sort(): array { $defaultsort = []; if (class_exists('\\qbank_viewquestiontype\\question_type_column')) { $sort = 'qbank_viewquestiontype\question_type_column'; } $defaultsort[$sort] = 1; if (class_exists('\\qbank_viewquestionname\\question_name_idnumber_tags_column')) { $sort = 'qbank_viewquestionname\question_name_idnumber_tags_column'; } $defaultsort[$sort . '-name'] = 1; return $defaultsort; } /** * Gets the primary sort order according to the default sort. * * @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): int { $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): string { 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, based on * \core_question\bank\search\condition filters. */ protected function build_query(): void { // Get the required tables and fields. $joins = []; $fields = ['q.hidden', 'q.category']; if (!empty($this->requiredcolumns)) { 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 = []; 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 = ['q.parent = 0']; $this->sqlparams = []; 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); } /** * Get the number of questions. * @return int */ protected function get_question_count(): int { 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): \moodle_recordset { 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; } /** * Returns the base url. */ public function base_url(): \moodle_url { return $this->baseurl; } /** * Get the URL for editing a question as a 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 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(): \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. * @deprecated since Moodle 4.0 * @see \qbank_previewquestion\helper::question_preview_url() * @todo Final deprecation on Moodle 4.4 MDL-72438 */ public function preview_question_url($questiondata) { debugging('Function preview_question_url() has been deprecated and moved to qbank_previewquestion plugin, please use qbank_previewquestion\helper::question_preview_url() instead.', DEBUG_DEVELOPER); return question_preview_url($questiondata->id, null, null, null, null, $this->get_most_specific_context()); } /** * Shows the question bank 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 * params: $tabname question bank edit tab name, for permission checking * $pagevars current list of page variables * * @param string $tabname * @param array $pagevars */ public function display($pagevars, $tabname): void { $page = $pagevars['qpage']; $perpage = $pagevars['qperpage']; $cat = $pagevars['cat']; $recurse = $pagevars['recurse']; $showhidden = $pagevars['showhidden']; $showquestiontext = $pagevars['qbshowtext']; $tagids = []; if (!empty($pagevars['qtagids'])) { $tagids = $pagevars['qtagids']; } echo \html_writer::start_div('questionbankwindow boxwidthwide boxaligncenter'); // This one will become redundant after implementing bulk actions plugin. if ($this->process_actions_needing_ui()) { return; } $editcontexts = $this->contexts->having_one_edit_tab_cap($tabname); // Show the filters and search options. $this->wanted_filters($cat, $tagids, $showhidden, $recurse, $editcontexts, $showquestiontext); // Continues with list of questions. $this->display_question_list($this->baseurl, $cat, null, $page, $perpage, $this->contexts->having_cap('moodle/question:add')); echo \html_writer::end_div(); } /** * The filters for the question bank. * * @param string $cat 'categoryid,contextid' * @param array $tagids current list of selected tags * @param bool $showhidden whether deleted questions should be displayed * @param int $recurse Whether to include subcategories * @param array $editcontexts parent contexts * @param bool $showquestiontext whether the text of each question should be shown in the list */ public function wanted_filters($cat, $tagids, $showhidden, $recurse, $editcontexts, $showquestiontext): void { global $CFG; 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/enablefilters is true. if ($this->enablefilters) { if (is_array($this->customfilterobjects)) { foreach ($this->customfilterobjects as $filterobjects) { $this->searchconditions[] = $filterobjects; } } else { if ($CFG->usetags) { array_unshift($this->searchconditions, new \core_question\bank\search\tag_condition([$catcontext, $thiscontext], $tagids)); } 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); } /** * Print the text if category id not available. */ protected function print_choose_category_message(): void { echo \html_writer::start_tag('p', ['style' => "\"text-align:center;\""]); echo \html_writer::tag('b', get_string('selectcategoryabove', 'question')); echo \html_writer::end_tag('p'); } /** * Gets current selected category. * @param string $categoryandcontext * @return false|mixed|\stdClass */ protected function get_current_category($categoryandcontext) { global $DB, $OUTPUT; list($categoryid, $contextid) = explode(',', $categoryandcontext); if (!$categoryid) { $this->print_choose_category_message(); return false; } if (!$category = $DB->get_record('question_categories', ['id' => $categoryid, 'contextid' => $contextid])) { echo $OUTPUT->box_start('generalbox questionbank'); echo $OUTPUT->notification('Category not found!'); echo $OUTPUT->box_end(); return false; } return $category; } /** * 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. */ protected function display_options_form($showquestiontext): void { global $PAGE; // The html will be refactored in the filter feature implementation. echo \html_writer::start_tag('form', ['method' => 'get', 'action' => new \moodle_url($this->baseurl), 'id' => 'displayoptions']); echo \html_writer::start_div(); $excludes = ['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); $advancedsearch = []; foreach ($this->searchconditions as $searchcondition) { if ($searchcondition->display_options_adv()) { $advancedsearch[] = $searchcondition; } echo $searchcondition->display_options(); } $this->display_showtext_checkbox($showquestiontext); if (!empty($advancedsearch)) { $this->display_advanced_search_form($advancedsearch); } $go = \html_writer::empty_tag('input', ['type' => 'submit', 'value' => get_string('go')]); echo \html_writer::tag('noscript', \html_writer::div($go), ['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. * * @param array $advancedsearch */ protected function display_advanced_search_form($advancedsearch): void { print_collapsible_region_start('', 'advancedsearch', get_string('advancedsearchoptions', 'question'), 'question_bank_advanced_search'); foreach ($advancedsearch 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): void { global $PAGE; $displaydata = [ 'checked' => $showquestiontext ]; if (class_exists('qbank_viewquestiontext\\question_text_row')) { if (\core\plugininfo\qbank::is_plugin_enabled('qbank_viewquestiontext')) { echo $PAGE->get_renderer('core_question', 'bank')->render_showtext_checkbox($displaydata); } } } /** * Display the header element for the question bank. */ protected function display_question_bank_header(): void { global $OUTPUT; echo $OUTPUT->heading(get_string('questionbank', 'question'), 2); } /** * Create a new question form. * * @param false|mixed|\stdClass $category * @param bool $canadd */ protected function create_new_question_form($category, $canadd): void { if (\core\plugininfo\qbank::is_plugin_enabled('qbank_editquestion')) { echo editquestion_helper::create_new_question_button($category->id, $this->requiredcolumns['qbank_editquestion\edit_action_column']->editquestionurl->params(), $canadd); } } /** * Prints the table of questions in a category with interactions * * @param \moodle_url $pageurl The URL to reload this page. * @param string $categoryandcontext 'categoryID,contextID'. * @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 array $addcontexts contexts where the user is allowed to add new questions. */ protected function display_question_list($pageurl, $categoryandcontext, $recurse = 1, $page = 0, $perpage = 100, $addcontexts = []): void { 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) { if (!empty($question->id)) { $questions[$question->id] = $question; } } $questionsrs->close(); foreach ($this->requiredcolumns as $name => $column) { $column->load_additional_data($questions); } $pageingurl = new \moodle_url($pageurl, $pageurl->params()); $pagingbar = new \paging_bar($totalnumber, $page, $perpage, $pageingurl); $pagingbar->pagevar = 'qpage'; $this->display_top_pagnation($OUTPUT->render($pagingbar)); // This html will be refactored in the bulk actions implementation. echo \html_writer::start_tag('form', ['action' => $pageurl, 'method' => 'post']); echo \html_writer::start_tag('fieldset', ['class' => 'invisiblefieldset', 'style' => "display: block;"]); echo \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey()]); echo \html_writer::input_hidden_params($this->baseurl); $this->display_questions($questions); $this->display_bottom_pagination($OUTPUT->render($pagingbar), $totalnumber, $perpage, $pageurl); $this->display_bottom_controls($totalnumber, $recurse, $category, $catcontext, $addcontexts); echo \html_writer::end_tag('fieldset'); echo \html_writer::end_tag('form'); } /** * Display the top pagination bar. * * @param object $pagination */ protected function display_top_pagnation($pagination): void { global $PAGE; $displaydata = [ 'pagination' => $pagination ]; echo $PAGE->get_renderer('core_question', 'bank')->render_question_pagination($displaydata); } /** * Display bottom pagination bar. * * @param string $pagination * @param int $totalnumber * @param int $perpage * @param \moodle_url $pageurl */ protected function display_bottom_pagination($pagination, $totalnumber, $perpage, $pageurl): void { global $PAGE; $displaydata = array ( 'extraclasses' => 'pagingbottom', 'pagination' => $pagination, 'biggertotal' => true, ); if ($totalnumber > DEFAULT_QUESTIONS_PER_PAGE) { $displaydata['showall'] = true; if ($perpage == DEFAULT_QUESTIONS_PER_PAGE) { $url = new \moodle_url($pageurl, array_merge($pageurl->params(), ['qpage' => 0, 'qperpage' => MAXIMUM_QUESTIONS_PER_PAGE])); if ($totalnumber > MAXIMUM_QUESTIONS_PER_PAGE) { $displaydata['totalnumber'] = MAXIMUM_QUESTIONS_PER_PAGE; } else { $displaydata['biggertotal'] = false; $displaydata['totalnumber'] = $totalnumber; } } else { $url = new \moodle_url($pageurl, array_merge($pageurl->params(), ['qperpage' => DEFAULT_QUESTIONS_PER_PAGE])); $displaydata['totalnumber'] = DEFAULT_QUESTIONS_PER_PAGE; } $displaydata['showallurl'] = $url; } echo $PAGE->get_renderer('core_question', 'bank')->render_question_pagination($displaydata); } /** * 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): void { $caneditall = has_capability('moodle/question:editall', $catcontext); $canuseall = has_capability('moodle/question:useall', $catcontext); $canmoveall = has_capability('moodle/question:moveall', $catcontext); echo \html_writer::start_tag('div', ['class' => "modulespecificbuttonscontainer"]); if ($caneditall || $canmoveall || $canuseall) { $withselectedcontent = ' ' . get_string('withselected', 'question') . ':'; echo \html_writer::tag('strong', $withselectedcontent); echo \html_writer::empty_tag('br'); // 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, ]); helper::question_category_select_menu($addcontexts, false, 0, "{$category->id},{$category->contextid}"); } } echo \html_writer::end_tag('div'); } /** * Display the questions. * * @param array $questions */ protected function display_questions($questions): void { echo \html_writer::start_tag('div', ['class' => 'categoryquestionscontainer', 'id' => 'questionscontainer']); $this->print_table($questions); echo \html_writer::end_tag('div'); } /** * Prints the actual table with question. * * @param array $questions */ protected function print_table($questions): void { // Start of the table. echo \html_writer::start_tag('table', ['id' => 'categoryquestions']); // Prints the table header. echo \html_writer::start_tag('thead'); echo \html_writer::start_tag('tr'); $this->print_table_headers(); echo \html_writer::end_tag('tr'); echo \html_writer::end_tag('thead'); // Prints the table row or content. echo \html_writer::start_tag('tbody'); $rowcount = 0; foreach ($questions as $question) { $this->print_table_row($question, $rowcount); $rowcount += 1; } echo \html_writer::end_tag('tbody'); // End of the table. echo \html_writer::end_tag('table'); } /** * Start of the table html. * * @deprecated since Moodle 4.0 * @see print_table() * @todo Final deprecation of this function in moodle 4.4 */ protected function start_table() { debugging('Function start_table() is deprecated, please use print_table() instead.', DEBUG_DEVELOPER); echo '' . "\n"; echo "\n"; $this->print_table_headers(); echo "\n"; echo "\n"; } /** * End of the table html. * * @deprecated since Moodle 4.0 * @see print_table() * @todo Final deprecation of this function in moodle 4.4 */ protected function end_table() { debugging('Function end_table() is deprecated, please use print_table() instead.', DEBUG_DEVELOPER); echo "\n"; echo "
\n"; } /** * Print table headers from child classes. */ protected function print_table_headers(): void { foreach ($this->visiblecolumns as $column) { $column->display_header(); } } /** * Gets the classes for the row. * * @param \stdClass $question * @param int $rowcount * @return array */ protected function get_row_classes($question, $rowcount): array { $classes = []; if ($question->hidden) { $classes[] = 'dimmed_text'; } if ($question->id == $this->lastchangedid) { $classes[] = 'highlight text-dark'; } $classes[] = 'r' . ($rowcount % 2); return $classes; } /** * Prints the table row from child classes. * * @param \stdClass $question * @param int $rowcount */ protected function print_table_row($question, $rowcount): void { $rowclasses = implode(' ', $this->get_row_classes($question, $rowcount)); $attributes = []; if ($rowclasses) { $attributes['class'] = $rowclasses; } echo \html_writer::start_tag('tr', $attributes); foreach ($this->visiblecolumns as $column) { $column->display($question, $rowclasses); } echo \html_writer::end_tag('tr'); foreach ($this->extrarows as $row) { $row->display($question, $rowclasses); } } /** * Process actions for the selected action. * */ public function process_actions(): void { 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', ['id' => $tocategoryid, 'contextid' => $contextid])) { throw new \moodle_exception('cannotfindcate', 'question'); } $tocontext = \context::instance_by_id($contextid); require_capability('moodle/question:add', $tocontext); $rawdata = (array) data_submitted(); $questionids = []; 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, ['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([$questionid])) { $DB->set_field('question', 'hidden', 1, ['id' => $questionid]); } else { question_delete_question($questionid); } } } redirect($this->baseurl); } else { throw new \moodle_exception('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, ['id' => $unhide]); // Purge these questions from the cache. \question_bank::notify_question_edited($unhide); redirect($this->baseurl); } } /** * Process actions with ui. * @return bool */ public function process_actions_needing_ui(): bool { 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([$key])) { $questionnames .= '* '; $inuse = true; } $questionnames .= $DB->get_field('question', 'name', ['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($this->baseurl, $this->baseurl->params()); $deleteurl = new \moodle_url($baseurl, ['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): void { $this->searchconditions[] = $searchcondition; } }