. /** * More object oriented wrappers around parts of the Moodle question bank. * * In due course, I expect that the question bank will be converted to a * fully object oriented structure, at which point this file can be a * starting point. * * @package moodlecore * @subpackage questionbank * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../type/questiontypebase.php'); /** * This static class provides access to the other question bank. * * It provides functions for managing question types and question definitions. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class question_bank { // TODO: This limit can be deleted if someday we move all TEXTS to BIG ones. MDL-19603 const MAX_SUMMARY_LENGTH = 32000; /** @var array question type name => question_type subclass. */ private static $questiontypes = array(); /** @var array question type name => 1. Records which question definitions have been loaded. */ private static $loadedqdefs = array(); /** @var boolean nasty hack to allow unit tests to call {@link load_question()}. */ private static $testmode = false; private static $testdata = array(); private static $questionconfig = null; /** * @var array string => string The standard set of grade options (fractions) * to use when editing questions, in the range 0 to 1 inclusive. Array keys * are string becuase: a) we want grades to exactly 7 d.p., and b. you can't * have float array keys in PHP. * Initialised by {@link ensure_grade_options_initialised()}. */ private static $fractionoptions = null; /** @var array string => string The full standard set of (fractions) -1 to 1 inclusive. */ private static $fractionoptionsfull = null; /** * @param string $qtypename a question type name, e.g. 'multichoice'. * @return bool whether that question type is installed in this Moodle. */ public static function is_qtype_installed($qtypename) { $plugindir = core_component::get_plugin_directory('qtype', $qtypename); return $plugindir && is_readable($plugindir . '/questiontype.php'); } /** * Get the question type class for a particular question type. * @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'. * @param bool $mustexist if false, the missing question type is returned when * the requested question type is not installed. * @return question_type the corresponding question type class. */ public static function get_qtype($qtypename, $mustexist = true) { global $CFG; if (isset(self::$questiontypes[$qtypename])) { return self::$questiontypes[$qtypename]; } $file = core_component::get_plugin_directory('qtype', $qtypename) . '/questiontype.php'; if (!is_readable($file)) { if ($mustexist || $qtypename == 'missingtype') { throw new coding_exception('Unknown question type ' . $qtypename); } else { return self::get_qtype('missingtype'); } } include_once($file); $class = 'qtype_' . $qtypename; if (!class_exists($class)) { throw new coding_exception("Class {$class} must be defined in {$file}."); } self::$questiontypes[$qtypename] = new $class(); return self::$questiontypes[$qtypename]; } /** * Load the question configuration data from config_plugins. * @return object get_config('question') with caching. */ public static function get_config() { if (is_null(self::$questionconfig)) { self::$questionconfig = get_config('question'); } return self::$questionconfig; } /** * @param string $qtypename the internal name of a question type. For example multichoice. * @return bool whether users are allowed to create questions of this type. */ public static function qtype_enabled($qtypename) { $config = self::get_config(); $enabledvar = $qtypename . '_disabled'; return self::qtype_exists($qtypename) && empty($config->$enabledvar) && self::get_qtype($qtypename)->menu_name() != ''; } /** * @param string $qtypename the internal name of a question type. For example multichoice. * @return bool whether this question type exists. */ public static function qtype_exists($qtypename) { return array_key_exists($qtypename, core_component::get_plugin_list('qtype')); } /** * @param $qtypename the internal name of a question type, for example multichoice. * @return string the human_readable name of this question type, from the language pack. */ public static function get_qtype_name($qtypename) { return self::get_qtype($qtypename)->local_name(); } /** * @return array all the installed question types. */ public static function get_all_qtypes() { $qtypes = array(); foreach (core_component::get_plugin_list('qtype') as $plugin => $notused) { try { $qtypes[$plugin] = self::get_qtype($plugin); } catch (coding_exception $e) { // Catching coding_exceptions here means that incompatible // question types do not cause the rest of Moodle to break. } } return $qtypes; } /** * Sort an array of question types according to the order the admin set up, * and then alphabetically for the rest. * @param array qtype->name() => qtype->local_name(). * @return array sorted array. */ public static function sort_qtype_array($qtypes, $config = null) { if (is_null($config)) { $config = self::get_config(); } $sortorder = array(); $otherqtypes = array(); foreach ($qtypes as $name => $localname) { $sortvar = $name . '_sortorder'; if (isset($config->$sortvar)) { $sortorder[$config->$sortvar] = $name; } else { $otherqtypes[$name] = $localname; } } ksort($sortorder); core_collator::asort($otherqtypes); $sortedqtypes = array(); foreach ($sortorder as $name) { $sortedqtypes[$name] = $qtypes[$name]; } foreach ($otherqtypes as $name => $notused) { $sortedqtypes[$name] = $qtypes[$name]; } return $sortedqtypes; } /** * @return array all the question types that users are allowed to create, * sorted into the preferred order set on the admin screen. */ public static function get_creatable_qtypes() { $config = self::get_config(); $allqtypes = self::get_all_qtypes(); $qtypenames = array(); foreach ($allqtypes as $name => $qtype) { if (self::qtype_enabled($name)) { $qtypenames[$name] = $qtype->local_name(); } } $qtypenames = self::sort_qtype_array($qtypenames); $creatableqtypes = array(); foreach ($qtypenames as $name => $notused) { $creatableqtypes[$name] = $allqtypes[$name]; } return $creatableqtypes; } /** * Load the question definition class(es) belonging to a question type. That is, * include_once('/question/type/' . $qtypename . '/question.php'), with a bit * of checking. * @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'. */ public static function load_question_definition_classes($qtypename) { global $CFG; if (isset(self::$loadedqdefs[$qtypename])) { return; } $file = $CFG->dirroot . '/question/type/' . $qtypename . '/question.php'; if (!is_readable($file)) { throw new coding_exception('Unknown question type (no definition) ' . $qtypename); } include_once($file); self::$loadedqdefs[$qtypename] = 1; } /** * This method needs to be called whenever a question is edited. */ public static function notify_question_edited($questionid) { question_finder::get_instance()->uncache_question($questionid); } /** * Load a question definition data from the database. The data will be * returned as a plain stdClass object. * @param int $questionid the id of the question to load. * @return object question definition loaded from the database. */ public static function load_question_data($questionid) { return question_finder::get_instance()->load_question_data($questionid); } /** * Load a question definition from the database. The object returned * will actually be of an appropriate {@link question_definition} subclass. * @param int $questionid the id of the question to load. * @param bool $allowshuffle if false, then any shuffle option on the selected * quetsion is disabled. * @return question_definition loaded from the database. */ public static function load_question($questionid, $allowshuffle = true) { global $DB; if (self::$testmode) { // Evil, test code in production, but no way round it. return self::return_test_question_data($questionid); } $questiondata = self::load_question_data($questionid); if (!$allowshuffle) { $questiondata->options->shuffleanswers = false; } return self::make_question($questiondata); } /** * Convert the question information loaded with {@link get_question_options()} * to a question_definintion object. * @param object $questiondata raw data loaded from the database. * @return question_definition loaded from the database. */ public static function make_question($questiondata) { return self::get_qtype($questiondata->qtype, false)->make_question($questiondata, false); } /** * @return question_finder a question finder. */ public static function get_finder() { return question_finder::get_instance(); } /** * Only to be called from unit tests. Allows {@link load_test_data()} to be used. */ public static function start_unit_test() { self::$testmode = true; } /** * Only to be called from unit tests. Allows {@link load_test_data()} to be used. */ public static function end_unit_test() { self::$testmode = false; self::$testdata = array(); } private static function return_test_question_data($questionid) { if (!isset(self::$testdata[$questionid])) { throw new coding_exception('question_bank::return_test_data(' . $questionid . ') called, but no matching question has been loaded by load_test_data.'); } return self::$testdata[$questionid]; } /** * To be used for unit testing only. Will throw an exception if * {@link start_unit_test()} has not been called first. * @param object $questiondata a question data object to put in the test data store. */ public static function load_test_question_data(question_definition $question) { if (!self::$testmode) { throw new coding_exception('question_bank::load_test_data called when ' . 'not in test mode.'); } self::$testdata[$question->id] = $question; } protected static function ensure_fraction_options_initialised() { if (!is_null(self::$fractionoptions)) { return; } // define basic array of grades. This list comprises all fractions of the form: // a. p/q for q <= 6, 0 <= p <= q // b. p/10 for 0 <= p <= 10 // c. 1/q for 1 <= q <= 10 // d. 1/20 $rawfractions = array( 0.9000000, 0.8333333, 0.8000000, 0.7500000, 0.7000000, 0.6666667, 0.6000000, 0.5000000, 0.4000000, 0.3333333, 0.3000000, 0.2500000, 0.2000000, 0.1666667, 0.1428571, 0.1250000, 0.1111111, 0.1000000, 0.0500000, ); // Put the None option at the top. self::$fractionoptions = array( '0.0' => get_string('none'), '1.0' => '100%', ); self::$fractionoptionsfull = array( '0.0' => get_string('none'), '1.0' => '100%', ); // The the positive grades in descending order. foreach ($rawfractions as $fraction) { $percentage = format_float(100 * $fraction, 5, true, true) . '%'; self::$fractionoptions["{$fraction}"] = $percentage; self::$fractionoptionsfull["{$fraction}"] = $percentage; } // The the negative grades in descending order. foreach (array_reverse($rawfractions) as $fraction) { self::$fractionoptionsfull['' . (-$fraction)] = format_float(-100 * $fraction, 5, true, true) . '%'; } self::$fractionoptionsfull['-1.0'] = '-100%'; } /** * @return array string => string The standard set of grade options (fractions) * to use when editing questions, in the range 0 to 1 inclusive. Array keys * are string becuase: a) we want grades to exactly 7 d.p., and b. you can't * have float array keys in PHP. * Initialised by {@link ensure_grade_options_initialised()}. */ public static function fraction_options() { self::ensure_fraction_options_initialised(); return self::$fractionoptions; } /** @return array string => string The full standard set of (fractions) -1 to 1 inclusive. */ public static function fraction_options_full() { self::ensure_fraction_options_initialised(); return self::$fractionoptionsfull; } /** * Return a list of the different question types present in the given categories. * * @param array $categories a list of category ids * @return array the list of question types in the categories * @since Moodle 3.1 */ public static function get_all_question_types_in_categories($categories) { global $DB; list($categorysql, $params) = $DB->get_in_or_equal($categories); $sql = "SELECT DISTINCT q.qtype FROM {question} q WHERE q.category $categorysql"; $qtypes = $DB->get_fieldset_sql($sql, $params); return $qtypes; } } /** * Class for loading questions according to various criteria. * * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_finder implements cache_data_source { /** @var question_finder the singleton instance of this class. */ protected static $questionfinder = null; /** * @return question_finder a question finder. */ public static function get_instance() { if (is_null(self::$questionfinder)) { self::$questionfinder = new question_finder(); } return self::$questionfinder; } /* See cache_data_source::get_instance_for_cache. */ public static function get_instance_for_cache(cache_definition $definition) { return self::get_instance(); } /** * @return get the question definition cache we are using. */ protected function get_data_cache() { // Do not double cache here because it may break cache resetting. return cache::make('core', 'questiondata'); } /** * This method needs to be called whenever a question is edited. */ public function uncache_question($questionid) { $this->get_data_cache()->delete($questionid); } /** * Load a question definition data from the database. The data will be * returned as a plain stdClass object. * @param int $questionid the id of the question to load. * @return object question definition loaded from the database. */ public function load_question_data($questionid) { return $this->get_data_cache()->get($questionid); } /** * Get the ids of all the questions in a list of categories. * @param array $categoryids either a categoryid, or a comma-separated list * category ids, or an array of them. * @param string $extraconditions extra conditions to AND with the rest of * the where clause. Must use named parameters. * @param array $extraparams any parameters used by $extraconditions. * @return array questionid => questionid. */ public function get_questions_from_categories($categoryids, $extraconditions, $extraparams = array()) { global $DB; list($qcsql, $qcparams) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED, 'qc'); if ($extraconditions) { $extraconditions = ' AND (' . $extraconditions . ')'; } return $DB->get_records_select_menu('question', "category {$qcsql} AND parent = 0 AND hidden = 0 {$extraconditions}", $qcparams + $extraparams, '', 'id,id AS id2'); } /** * Get the ids of all the questions in a list of categories, with the number * of times they have already been used in a given set of usages. * * The result array is returned in order of increasing (count previous uses). * * @param array $categoryids an array question_category ids. * @param qubaid_condition $qubaids which question_usages to count previous uses from. * @param string $extraconditions extra conditions to AND with the rest of * the where clause. Must use named parameters. * @param array $extraparams any parameters used by $extraconditions. * @return array questionid => count of number of previous uses. */ public function get_questions_from_categories_with_usage_counts($categoryids, qubaid_condition $qubaids, $extraconditions = '', $extraparams = array()) { return $this->get_questions_from_categories_and_tags_with_usage_counts( $categoryids, $qubaids, $extraconditions, $extraparams); } /** * Get the ids of all the questions in a list of categories that have ALL the provided tags, * with the number of times they have already been used in a given set of usages. * * The result array is returned in order of increasing (count previous uses). * * @param array $categoryids an array of question_category ids. * @param qubaid_condition $qubaids which question_usages to count previous uses from. * @param string $extraconditions extra conditions to AND with the rest of * the where clause. Must use named parameters. * @param array $extraparams any parameters used by $extraconditions. * @param array $tagids an array of tag ids * @return array questionid => count of number of previous uses. */ public function get_questions_from_categories_and_tags_with_usage_counts($categoryids, qubaid_condition $qubaids, $extraconditions = '', $extraparams = array(), $tagids = array()) { global $DB; list($qcsql, $qcparams) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED, 'qc'); $select = "q.id, (SELECT COUNT(1) FROM " . $qubaids->from_question_attempts('qa') . " WHERE qa.questionid = q.id AND " . $qubaids->where() . " ) AS previous_attempts"; $from = "{question} q"; $where = "q.category {$qcsql} AND q.parent = 0 AND q.hidden = 0"; $params = $qcparams; if (!empty($tagids)) { // We treat each additional tag as an AND condition rather than // an OR condition. // // For example, if the user filters by the tags "foo" and "bar" then // we reduce the question list to questions that are tagged with both // "foo" AND "bar". Any question that does not have ALL of the specified // tags will be omitted. list($tagsql, $tagparams) = $DB->get_in_or_equal($tagids, SQL_PARAMS_NAMED, 'ti'); $tagparams['tagcount'] = count($tagids); $tagparams['questionitemtype'] = 'question'; $tagparams['questioncomponent'] = 'core_question'; $where .= " AND q.id IN (SELECT ti.itemid FROM {tag_instance} ti WHERE ti.itemtype = :questionitemtype AND ti.component = :questioncomponent AND ti.tagid {$tagsql} GROUP BY ti.itemid HAVING COUNT(itemid) = :tagcount)"; $params += $tagparams; } if ($extraconditions) { $extraconditions = ' AND (' . $extraconditions . ')'; } return $DB->get_records_sql_menu("SELECT $select FROM $from WHERE $where $extraconditions ORDER BY previous_attempts", $qubaids->from_where_params() + $params + $extraparams); } /* See cache_data_source::load_for_cache. */ public function load_for_cache($questionid) { global $DB; $questiondata = $DB->get_record_sql(' SELECT q.*, qc.contextid FROM {question} q JOIN {question_categories} qc ON q.category = qc.id WHERE q.id = :id', array('id' => $questionid), MUST_EXIST); get_question_options($questiondata); return $questiondata; } /* See cache_data_source::load_many_for_cache. */ public function load_many_for_cache(array $questionids) { global $DB; list($idcondition, $params) = $DB->get_in_or_equal($questionids); $questiondata = $DB->get_records_sql(' SELECT q.*, qc.contextid FROM {question} q JOIN {question_categories} qc ON q.category = qc.id WHERE q.id ' . $idcondition, $params); foreach ($questionids as $id) { if (!array_key_exists($id, $questionids)) { throw new dml_missing_record_exception('question', '', array('id' => $id)); } get_question_options($questiondata[$id]); } return $questiondata; } }