2 // This file is part of Moodle - http://moodle.org/
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
18 * More object oriented wrappers around parts of the Moodle question bank.
20 * In due course, I expect that the question bank will be converted to a
21 * fully object oriented structure, at which point this file can be a
25 * @subpackage questionbank
26 * @copyright 2009 The Open University
27 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
31 defined('MOODLE_INTERNAL') ||
die();
33 require_once(__DIR__
. '/../type/questiontypebase.php');
37 * This static class provides access to the other question bank.
39 * It provides functions for managing question types and question definitions.
41 * @copyright 2009 The Open University
42 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
44 abstract class question_bank
{
45 // TODO: This limit can be deleted if someday we move all TEXTS to BIG ones. MDL-19603
46 const MAX_SUMMARY_LENGTH
= 32000;
48 /** @var array question type name => question_type subclass. */
49 private static $questiontypes = array();
51 /** @var array question type name => 1. Records which question definitions have been loaded. */
52 private static $loadedqdefs = array();
54 /** @var boolean nasty hack to allow unit tests to call {@link load_question()}. */
55 private static $testmode = false;
56 private static $testdata = array();
58 private static $questionconfig = null;
61 * @var array string => string The standard set of grade options (fractions)
62 * to use when editing questions, in the range 0 to 1 inclusive. Array keys
63 * are string becuase: a) we want grades to exactly 7 d.p., and b. you can't
64 * have float array keys in PHP.
65 * Initialised by {@link ensure_grade_options_initialised()}.
67 private static $fractionoptions = null;
68 /** @var array string => string The full standard set of (fractions) -1 to 1 inclusive. */
69 private static $fractionoptionsfull = null;
72 * @param string $qtypename a question type name, e.g. 'multichoice'.
73 * @return bool whether that question type is installed in this Moodle.
75 public static function is_qtype_installed($qtypename) {
76 $plugindir = core_component
::get_plugin_directory('qtype', $qtypename);
77 return $plugindir && is_readable($plugindir . '/questiontype.php');
81 * Get the question type class for a particular question type.
82 * @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'.
83 * @param bool $mustexist if false, the missing question type is returned when
84 * the requested question type is not installed.
85 * @return question_type the corresponding question type class.
87 public static function get_qtype($qtypename, $mustexist = true) {
89 if (isset(self
::$questiontypes[$qtypename])) {
90 return self
::$questiontypes[$qtypename];
92 $file = core_component
::get_plugin_directory('qtype', $qtypename) . '/questiontype.php';
93 if (!is_readable($file)) {
94 if ($mustexist ||
$qtypename == 'missingtype') {
95 throw new coding_exception('Unknown question type ' . $qtypename);
97 return self
::get_qtype('missingtype');
101 $class = 'qtype_' . $qtypename;
102 if (!class_exists($class)) {
103 throw new coding_exception("Class {$class} must be defined in {$file}.");
105 self
::$questiontypes[$qtypename] = new $class();
106 return self
::$questiontypes[$qtypename];
110 * Load the question configuration data from config_plugins.
111 * @return object get_config('question') with caching.
113 public static function get_config() {
114 if (is_null(self
::$questionconfig)) {
115 self
::$questionconfig = get_config('question');
117 return self
::$questionconfig;
121 * @param string $qtypename the internal name of a question type. For example multichoice.
122 * @return bool whether users are allowed to create questions of this type.
124 public static function qtype_enabled($qtypename) {
125 $config = self
::get_config();
126 $enabledvar = $qtypename . '_disabled';
127 return self
::qtype_exists($qtypename) && empty($config->$enabledvar) &&
128 self
::get_qtype($qtypename)->menu_name() != '';
132 * @param string $qtypename the internal name of a question type. For example multichoice.
133 * @return bool whether this question type exists.
135 public static function qtype_exists($qtypename) {
136 return array_key_exists($qtypename, core_component
::get_plugin_list('qtype'));
140 * @param $qtypename the internal name of a question type, for example multichoice.
141 * @return string the human_readable name of this question type, from the language pack.
143 public static function get_qtype_name($qtypename) {
144 return self
::get_qtype($qtypename)->local_name();
148 * @return array all the installed question types.
150 public static function get_all_qtypes() {
152 foreach (core_component
::get_plugin_list('qtype') as $plugin => $notused) {
154 $qtypes[$plugin] = self
::get_qtype($plugin);
155 } catch (coding_exception
$e) {
156 // Catching coding_exceptions here means that incompatible
157 // question types do not cause the rest of Moodle to break.
164 * Sort an array of question types according to the order the admin set up,
165 * and then alphabetically for the rest.
166 * @param array qtype->name() => qtype->local_name().
167 * @return array sorted array.
169 public static function sort_qtype_array($qtypes, $config = null) {
170 if (is_null($config)) {
171 $config = self
::get_config();
174 $sortorder = array();
175 $otherqtypes = array();
176 foreach ($qtypes as $name => $localname) {
177 $sortvar = $name . '_sortorder';
178 if (isset($config->$sortvar)) {
179 $sortorder[$config->$sortvar] = $name;
181 $otherqtypes[$name] = $localname;
186 core_collator
::asort($otherqtypes);
188 $sortedqtypes = array();
189 foreach ($sortorder as $name) {
190 $sortedqtypes[$name] = $qtypes[$name];
192 foreach ($otherqtypes as $name => $notused) {
193 $sortedqtypes[$name] = $qtypes[$name];
195 return $sortedqtypes;
199 * @return array all the question types that users are allowed to create,
200 * sorted into the preferred order set on the admin screen.
202 public static function get_creatable_qtypes() {
203 $config = self
::get_config();
204 $allqtypes = self
::get_all_qtypes();
206 $qtypenames = array();
207 foreach ($allqtypes as $name => $qtype) {
208 if (self
::qtype_enabled($name)) {
209 $qtypenames[$name] = $qtype->local_name();
213 $qtypenames = self
::sort_qtype_array($qtypenames);
215 $creatableqtypes = array();
216 foreach ($qtypenames as $name => $notused) {
217 $creatableqtypes[$name] = $allqtypes[$name];
219 return $creatableqtypes;
223 * Load the question definition class(es) belonging to a question type. That is,
224 * include_once('/question/type/' . $qtypename . '/question.php'), with a bit
226 * @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'.
228 public static function load_question_definition_classes($qtypename) {
230 if (isset(self
::$loadedqdefs[$qtypename])) {
233 $file = $CFG->dirroot
. '/question/type/' . $qtypename . '/question.php';
234 if (!is_readable($file)) {
235 throw new coding_exception('Unknown question type (no definition) ' . $qtypename);
238 self
::$loadedqdefs[$qtypename] = 1;
242 * This method needs to be called whenever a question is edited.
244 public static function notify_question_edited($questionid) {
245 question_finder
::get_instance()->uncache_question($questionid);
249 * Load a question definition data from the database. The data will be
250 * returned as a plain stdClass object.
251 * @param int $questionid the id of the question to load.
252 * @return object question definition loaded from the database.
254 public static function load_question_data($questionid) {
255 return question_finder
::get_instance()->load_question_data($questionid);
259 * Load a question definition from the database. The object returned
260 * will actually be of an appropriate {@link question_definition} subclass.
261 * @param int $questionid the id of the question to load.
262 * @param bool $allowshuffle if false, then any shuffle option on the selected
263 * quetsion is disabled.
264 * @return question_definition loaded from the database.
266 public static function load_question($questionid, $allowshuffle = true) {
268 if (self
::$testmode) {
269 // Evil, test code in production, but no way round it.
270 return self
::return_test_question_data($questionid);
273 $questiondata = self
::load_question_data($questionid);
275 if (!$allowshuffle) {
276 $questiondata->options
->shuffleanswers
= false;
278 return self
::make_question($questiondata);
282 * Convert the question information loaded with {@link get_question_options()}
283 * to a question_definintion object.
284 * @param object $questiondata raw data loaded from the database.
285 * @return question_definition loaded from the database.
287 public static function make_question($questiondata) {
288 return self
::get_qtype($questiondata->qtype
, false)->make_question($questiondata, false);
292 * @return question_finder a question finder.
294 public static function get_finder() {
295 return question_finder
::get_instance();
299 * Only to be called from unit tests. Allows {@link load_test_data()} to be used.
301 public static function start_unit_test() {
302 self
::$testmode = true;
306 * Only to be called from unit tests. Allows {@link load_test_data()} to be used.
308 public static function end_unit_test() {
309 self
::$testmode = false;
310 self
::$testdata = array();
313 private static function return_test_question_data($questionid) {
314 if (!isset(self
::$testdata[$questionid])) {
315 throw new coding_exception('question_bank::return_test_data(' . $questionid .
316 ') called, but no matching question has been loaded by load_test_data.');
318 return self
::$testdata[$questionid];
322 * To be used for unit testing only. Will throw an exception if
323 * {@link start_unit_test()} has not been called first.
324 * @param object $questiondata a question data object to put in the test data store.
326 public static function load_test_question_data(question_definition
$question) {
327 if (!self
::$testmode) {
328 throw new coding_exception('question_bank::load_test_data called when ' .
329 'not in test mode.');
331 self
::$testdata[$question->id
] = $question;
334 protected static function ensure_fraction_options_initialised() {
335 if (!is_null(self
::$fractionoptions)) {
339 // define basic array of grades. This list comprises all fractions of the form:
340 // a. p/q for q <= 6, 0 <= p <= q
341 // b. p/10 for 0 <= p <= 10
342 // c. 1/q for 1 <= q <= 10
344 $rawfractions = array(
366 // Put the None option at the top.
367 self
::$fractionoptions = array(
368 '0.0' => get_string('none'),
371 self
::$fractionoptionsfull = array(
372 '0.0' => get_string('none'),
376 // The the positive grades in descending order.
377 foreach ($rawfractions as $fraction) {
378 $percentage = format_float(100 * $fraction, 5, true, true) . '%';
379 self
::$fractionoptions["{$fraction}"] = $percentage;
380 self
::$fractionoptionsfull["{$fraction}"] = $percentage;
383 // The the negative grades in descending order.
384 foreach (array_reverse($rawfractions) as $fraction) {
385 self
::$fractionoptionsfull['' . (-$fraction)] =
386 format_float(-100 * $fraction, 5, true, true) . '%';
389 self
::$fractionoptionsfull['-1.0'] = '-100%';
393 * @return array string => string The standard set of grade options (fractions)
394 * to use when editing questions, in the range 0 to 1 inclusive. Array keys
395 * are string becuase: a) we want grades to exactly 7 d.p., and b. you can't
396 * have float array keys in PHP.
397 * Initialised by {@link ensure_grade_options_initialised()}.
399 public static function fraction_options() {
400 self
::ensure_fraction_options_initialised();
401 return self
::$fractionoptions;
404 /** @return array string => string The full standard set of (fractions) -1 to 1 inclusive. */
405 public static function fraction_options_full() {
406 self
::ensure_fraction_options_initialised();
407 return self
::$fractionoptionsfull;
411 * Return a list of the different question types present in the given categories.
413 * @param array $categories a list of category ids
414 * @return array the list of question types in the categories
417 public static function get_all_question_types_in_categories($categories) {
420 list($categorysql, $params) = $DB->get_in_or_equal($categories);
421 $sql = "SELECT DISTINCT q.qtype
423 WHERE q.category $categorysql";
425 $qtypes = $DB->get_fieldset_sql($sql, $params);
432 * Class for loading questions according to various criteria.
434 * @copyright 2009 The Open University
435 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
437 class question_finder
implements cache_data_source
{
438 /** @var question_finder the singleton instance of this class. */
439 protected static $questionfinder = null;
442 * @return question_finder a question finder.
444 public static function get_instance() {
445 if (is_null(self
::$questionfinder)) {
446 self
::$questionfinder = new question_finder();
448 return self
::$questionfinder;
451 /* See cache_data_source::get_instance_for_cache. */
452 public static function get_instance_for_cache(cache_definition
$definition) {
453 return self
::get_instance();
457 * @return get the question definition cache we are using.
459 protected function get_data_cache() {
460 // Do not double cache here because it may break cache resetting.
461 return cache
::make('core', 'questiondata');
465 * This method needs to be called whenever a question is edited.
467 public function uncache_question($questionid) {
468 $this->get_data_cache()->delete($questionid);
472 * Load a question definition data from the database. The data will be
473 * returned as a plain stdClass object.
474 * @param int $questionid the id of the question to load.
475 * @return object question definition loaded from the database.
477 public function load_question_data($questionid) {
478 return $this->get_data_cache()->get($questionid);
482 * Get the ids of all the questions in a list of categories.
483 * @param array $categoryids either a categoryid, or a comma-separated list
484 * category ids, or an array of them.
485 * @param string $extraconditions extra conditions to AND with the rest of
486 * the where clause. Must use named parameters.
487 * @param array $extraparams any parameters used by $extraconditions.
488 * @return array questionid => questionid.
490 public function get_questions_from_categories($categoryids, $extraconditions,
491 $extraparams = array()) {
494 list($qcsql, $qcparams) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED
, 'qc');
496 if ($extraconditions) {
497 $extraconditions = ' AND (' . $extraconditions . ')';
500 return $DB->get_records_select_menu('question',
504 {$extraconditions}", $qcparams +
$extraparams, '', 'id,id AS id2');
508 * Get the ids of all the questions in a list of categories, with the number
509 * of times they have already been used in a given set of usages.
511 * The result array is returned in order of increasing (count previous uses).
513 * @param array $categoryids an array question_category ids.
514 * @param qubaid_condition $qubaids which question_usages to count previous uses from.
515 * @param string $extraconditions extra conditions to AND with the rest of
516 * the where clause. Must use named parameters.
517 * @param array $extraparams any parameters used by $extraconditions.
518 * @return array questionid => count of number of previous uses.
520 public function get_questions_from_categories_with_usage_counts($categoryids,
521 qubaid_condition
$qubaids, $extraconditions = '', $extraparams = array()) {
522 return $this->get_questions_from_categories_and_tags_with_usage_counts(
523 $categoryids, $qubaids, $extraconditions, $extraparams);
527 * Get the ids of all the questions in a list of categories that have ALL the provided tags,
528 * with the number of times they have already been used in a given set of usages.
530 * The result array is returned in order of increasing (count previous uses).
532 * @param array $categoryids an array of question_category ids.
533 * @param qubaid_condition $qubaids which question_usages to count previous uses from.
534 * @param string $extraconditions extra conditions to AND with the rest of
535 * the where clause. Must use named parameters.
536 * @param array $extraparams any parameters used by $extraconditions.
537 * @param array $tagids an array of tag ids
538 * @return array questionid => count of number of previous uses.
540 public function get_questions_from_categories_and_tags_with_usage_counts($categoryids,
541 qubaid_condition
$qubaids, $extraconditions = '', $extraparams = array(), $tagids = array()) {
544 list($qcsql, $qcparams) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED
, 'qc');
546 $select = "q.id, (SELECT COUNT(1)
547 FROM " . $qubaids->from_question_attempts('qa') . "
548 WHERE qa.questionid = q.id AND " . $qubaids->where() . "
549 ) AS previous_attempts";
550 $from = "{question} q";
551 $where = "q.category {$qcsql}
556 if (!empty($tagids)) {
557 // We treat each additional tag as an AND condition rather than
560 // For example, if the user filters by the tags "foo" and "bar" then
561 // we reduce the question list to questions that are tagged with both
562 // "foo" AND "bar". Any question that does not have ALL of the specified
563 // tags will be omitted.
564 list($tagsql, $tagparams) = $DB->get_in_or_equal($tagids, SQL_PARAMS_NAMED
, 'ti');
565 $tagparams['tagcount'] = count($tagids);
566 $tagparams['questionitemtype'] = 'question';
567 $tagparams['questioncomponent'] = 'core_question';
568 $where .= " AND q.id IN (SELECT ti.itemid
569 FROM {tag_instance} ti
570 WHERE ti.itemtype = :questionitemtype
571 AND ti.component = :questioncomponent
572 AND ti.tagid {$tagsql}
574 HAVING COUNT(itemid) = :tagcount)";
575 $params +
= $tagparams;
578 if ($extraconditions) {
579 $extraconditions = ' AND (' . $extraconditions . ')';
582 return $DB->get_records_sql_menu("SELECT $select
584 WHERE $where $extraconditions
585 ORDER BY previous_attempts",
586 $qubaids->from_where_params() +
$params +
$extraparams);
589 /* See cache_data_source::load_for_cache. */
590 public function load_for_cache($questionid) {
592 $questiondata = $DB->get_record_sql('
593 SELECT q.*, qc.contextid
595 JOIN {question_categories} qc ON q.category = qc.id
596 WHERE q.id = :id', array('id' => $questionid), MUST_EXIST
);
597 get_question_options($questiondata);
598 return $questiondata;
601 /* See cache_data_source::load_many_for_cache. */
602 public function load_many_for_cache(array $questionids) {
604 list($idcondition, $params) = $DB->get_in_or_equal($questionids);
605 $questiondata = $DB->get_records_sql('
606 SELECT q.*, qc.contextid
608 JOIN {question_categories} qc ON q.category = qc.id
609 WHERE q.id ' . $idcondition, $params);
611 foreach ($questionids as $id) {
612 if (!array_key_exists($id, $questiondata)) {
613 throw new dml_missing_record_exception('question', '', array('id' => $id));
615 get_question_options($questiondata[$id]);
617 return $questiondata;