ad4e4930ae17cfa181239c879defb91598a0fcd3
[moodle.git] / question / engine / bank.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
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.
8 //
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.
13 //
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/>.
17 /**
18  * More object oriented wrappers around parts of the Moodle question bank.
19  *
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
22  * starting point.
23  *
24  * @package    moodlecore
25  * @subpackage questionbank
26  * @copyright  2009 The Open University
27  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
28  */
31 defined('MOODLE_INTERNAL') || die();
33 require_once(dirname(__FILE__) . '/../type/questiontypebase.php');
36 /**
37  * This static class provides access to the other question bank.
38  *
39  * It provides functions for managing question types and question definitions.
40  *
41  * @copyright  2009 The Open University
42  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
43  */
44 abstract class question_bank {
45     const MAX_SUMMARY_LENGTH = 65000;
47     /** @var array question type name => question_type subclass. */
48     private static $questiontypes = array();
50     /** @var array question type name => 1. Records which question definitions have been loaded. */
51     private static $loadedqdefs = array();
53     protected static $questionfinder = null;
55     /** @var boolean nasty hack to allow unit tests to call {@link load_question()}. */
56     private static $testmode = false;
57     private static $testdata = array();
59     private static $questionconfig = null;
61     /**
62      * @var array string => string The standard set of grade options (fractions)
63      * to use when editing questions, in the range 0 to 1 inclusive. Array keys
64      * are string becuase: a) we want grades to exactly 7 d.p., and b. you can't
65      * have float array keys in PHP.
66      * Initialised by {@link ensure_grade_options_initialised()}.
67      */
68     private static $fractionoptions = null;
69     /** @var array string => string The full standard set of (fractions) -1 to 1 inclusive. */
70     private static $fractionoptionsfull = null;
72     /**
73      * @param string $qtypename a question type name, e.g. 'multichoice'.
74      * @return bool whether that question type is installed in this Moodle.
75      */
76     public static function is_qtype_installed($qtypename) {
77         $plugindir = get_plugin_directory('qtype', $qtypename);
78         return $plugindir && is_readable($plugindir . '/questiontype.php');
79     }
81     /**
82      * Get the question type class for a particular question type.
83      * @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'.
84      * @param bool $mustexist if false, the missing question type is returned when
85      *      the requested question type is not installed.
86      * @return question_type the corresponding question type class.
87      */
88     public static function get_qtype($qtypename, $mustexist = true) {
89         global $CFG;
90         if (isset(self::$questiontypes[$qtypename])) {
91             return self::$questiontypes[$qtypename];
92         }
93         $file = get_plugin_directory('qtype', $qtypename) . '/questiontype.php';
94         if (!is_readable($file)) {
95             if ($mustexist || $qtypename == 'missingtype') {
96                 throw new coding_exception('Unknown question type ' . $qtypename);
97             } else {
98                 return self::get_qtype('missingtype');
99             }
100         }
101         include_once($file);
102         $class = 'qtype_' . $qtypename;
103         if (!class_exists($class)) {
104             throw new coding_exception("Class $class must be defined in $file");
105         }
106         self::$questiontypes[$qtypename] = new $class();
107         return self::$questiontypes[$qtypename];
108     }
110     /**
111      * Load the question configuration data from config_plugins.
112      * @return object get_config('question') with caching.
113      */
114     public static function get_config() {
115         if (is_null(self::$questionconfig)) {
116             self::$questionconfig = get_config('question');
117         }
118         return self::$questionconfig;
119     }
121     /**
122      * @param string $qtypename the internal name of a question type. For example multichoice.
123      * @return bool whether users are allowed to create questions of this type.
124      */
125     public static function qtype_enabled($qtypename) {
126         $config = self::get_config();
127         $enabledvar = $qtypename . '_disabled';
128         return self::qtype_exists($qtypename) && empty($config->$enabledvar) &&
129                 self::get_qtype($qtypename)->menu_name() != '';
130     }
132     /**
133      * @param string $qtypename the internal name of a question type. For example multichoice.
134      * @return bool whether this question type exists.
135      */
136     public static function qtype_exists($qtypename) {
137         return array_key_exists($qtypename, get_plugin_list('qtype'));
138     }
140     /**
141      * @param $qtypename the internal name of a question type, for example multichoice.
142      * @return string the human_readable name of this question type, from the language pack.
143      */
144     public static function get_qtype_name($qtypename) {
145         return self::get_qtype($qtypename)->local_name();
146     }
148     /**
149      * @return array all the installed question types.
150      */
151     public static function get_all_qtypes() {
152         $qtypes = array();
153         foreach (get_plugin_list('qtype') as $plugin => $notused) {
154             try {
155                 $qtypes[$plugin] = self::get_qtype($plugin);
156             } catch (coding_exception $e) {
157                 // Catching coding_exceptions here means that incompatible
158                 // question types do not cause the rest of Moodle to break.
159             }
160         }
161         return $qtypes;
162     }
164     /**
165      * Sort an array of question types according to the order the admin set up,
166      * and then alphabetically for the rest.
167      * @param array qtype->name() => qtype->local_name().
168      * @return array sorted array.
169      */
170     public static function sort_qtype_array($qtypes, $config = null) {
171         if (is_null($config)) {
172             $config = self::get_config();
173         }
175         $sortorder = array();
176         $otherqtypes = array();
177         foreach ($qtypes as $name => $localname) {
178             $sortvar = $name . '_sortorder';
179             if (isset($config->$sortvar)) {
180                 $sortorder[$config->$sortvar] = $name;
181             } else {
182                 $otherqtypes[$name] = $localname;
183             }
184         }
186         ksort($sortorder);
187         collatorlib::asort($otherqtypes);
189         $sortedqtypes = array();
190         foreach ($sortorder as $name) {
191             $sortedqtypes[$name] = $qtypes[$name];
192         }
193         foreach ($otherqtypes as $name => $notused) {
194             $sortedqtypes[$name] = $qtypes[$name];
195         }
196         return $sortedqtypes;
197     }
199     /**
200      * @return array all the question types that users are allowed to create,
201      *      sorted into the preferred order set on the admin screen.
202      */
203     public static function get_creatable_qtypes() {
204         $config = self::get_config();
205         $allqtypes = self::get_all_qtypes();
207         $qtypenames = array();
208         foreach ($allqtypes as $name => $qtype) {
209             if (self::qtype_enabled($name)) {
210                 $qtypenames[$name] = $qtype->local_name();
211             }
212         }
214         $qtypenames = self::sort_qtype_array($qtypenames);
216         $creatableqtypes = array();
217         foreach ($qtypenames as $name => $notused) {
218             $creatableqtypes[$name] = $allqtypes[$name];
219         }
220         return $creatableqtypes;
221     }
223     /**
224      * Load the question definition class(es) belonging to a question type. That is,
225      * include_once('/question/type/' . $qtypename . '/question.php'), with a bit
226      * of checking.
227      * @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'.
228      */
229     public static function load_question_definition_classes($qtypename) {
230         global $CFG;
231         if (isset(self::$loadedqdefs[$qtypename])) {
232             return;
233         }
234         $file = $CFG->dirroot . '/question/type/' . $qtypename . '/question.php';
235         if (!is_readable($file)) {
236             throw new coding_exception('Unknown question type (no definition) ' . $qtypename);
237         }
238         include_once($file);
239         self::$loadedqdefs[$qtypename] = 1;
240     }
242     /**
243      * Load a question definition from the database. The object returned
244      * will actually be of an appropriate {@link question_definition} subclass.
245      * @param int $questionid the id of the question to load.
246      * @param bool $allowshuffle if false, then any shuffle option on the selected
247      *      quetsion is disabled.
248      * @return question_definition loaded from the database.
249      */
250     public static function load_question($questionid, $allowshuffle = true) {
251         global $DB;
253         if (self::$testmode) {
254             // Evil, test code in production, but now way round it.
255             return self::return_test_question_data($questionid);
256         }
258         $questiondata = $DB->get_record_sql('
259                 SELECT q.*, qc.contextid
260                 FROM {question} q
261                 JOIN {question_categories} qc ON q.category = qc.id
262                 WHERE q.id = :id', array('id' => $questionid), MUST_EXIST);
263         get_question_options($questiondata);
264         if (!$allowshuffle) {
265             $questiondata->options->shuffleanswers = false;
266         }
267         return self::make_question($questiondata);
268     }
270     /**
271      * Convert the question information loaded with {@link get_question_options()}
272      * to a question_definintion object.
273      * @param object $questiondata raw data loaded from the database.
274      * @return question_definition loaded from the database.
275      */
276     public static function make_question($questiondata) {
277         return self::get_qtype($questiondata->qtype, false)->make_question($questiondata, false);
278     }
280     /**
281      * @return question_finder a question finder.
282      */
283     public static function get_finder() {
284         if (is_null(self::$questionfinder)) {
285             self::$questionfinder = new question_finder();
286         }
287         return self::$questionfinder;
288     }
290     /**
291      * Only to be called from unit tests. Allows {@link load_test_data()} to be used.
292      */
293     public static function start_unit_test() {
294         self::$testmode = true;
295     }
297     /**
298      * Only to be called from unit tests. Allows {@link load_test_data()} to be used.
299      */
300     public static function end_unit_test() {
301         self::$testmode = false;
302         self::$testdata = array();
303     }
305     private static function return_test_question_data($questionid) {
306         if (!isset(self::$testdata[$questionid])) {
307             throw new coding_exception('question_bank::return_test_data(' . $questionid .
308                     ') called, but no matching question has been loaded by load_test_data.');
309         }
310         return self::$testdata[$questionid];
311     }
313     /**
314      * To be used for unit testing only. Will throw an exception if
315      * {@link start_unit_test()} has not been called first.
316      * @param object $questiondata a question data object to put in the test data store.
317      */
318     public static function load_test_question_data(question_definition $question) {
319         if (!self::$testmode) {
320             throw new coding_exception('question_bank::load_test_data called when ' .
321                     'not in test mode.');
322         }
323         self::$testdata[$question->id] = $question;
324     }
326     protected function ensure_fraction_options_initialised() {
327         if (!is_null(self::$fractionoptions)) {
328             return;
329         }
331         // define basic array of grades. This list comprises all fractions of the form:
332         // a. p/q for q <= 6, 0 <= p <= q
333         // b. p/10 for 0 <= p <= 10
334         // c. 1/q for 1 <= q <= 10
335         // d. 1/20
336         $rawfractions = array(
337             0.9000000,
338             0.8333333,
339             0.8000000,
340             0.7500000,
341             0.7000000,
342             0.6666667,
343             0.6000000,
344             0.5000000,
345             0.4000000,
346             0.3333333,
347             0.3000000,
348             0.2500000,
349             0.2000000,
350             0.1666667,
351             0.1428571,
352             0.1250000,
353             0.1111111,
354             0.1000000,
355             0.0500000,
356         );
358         // Put the None option at the top.
359         self::$fractionoptions = array(
360             '0.0' => get_string('none'),
361             '1.0' => '100%',
362         );
363         self::$fractionoptionsfull = array(
364             '0.0' => get_string('none'),
365             '1.0' => '100%',
366         );
368         // The the positive grades in descending order.
369         foreach ($rawfractions as $fraction) {
370             $percentage = (100 * $fraction) . '%';
371             self::$fractionoptions["$fraction"] = $percentage;
372             self::$fractionoptionsfull["$fraction"] = $percentage;
373         }
375         // The the negative grades in descending order.
376         foreach (array_reverse($rawfractions) as $fraction) {
377             self::$fractionoptionsfull['' . (-$fraction)] = (-100 * $fraction) . '%';
378         }
380         self::$fractionoptionsfull['-1.0'] = '-100%';
381     }
383     /**
384      * @return array string => string The standard set of grade options (fractions)
385      * to use when editing questions, in the range 0 to 1 inclusive. Array keys
386      * are string becuase: a) we want grades to exactly 7 d.p., and b. you can't
387      * have float array keys in PHP.
388      * Initialised by {@link ensure_grade_options_initialised()}.
389      */
390     public static function fraction_options() {
391         self::ensure_fraction_options_initialised();
392         return self::$fractionoptions;
393     }
395     /** @return array string => string The full standard set of (fractions) -1 to 1 inclusive. */
396     public static function fraction_options_full() {
397         self::ensure_fraction_options_initialised();
398         return self::$fractionoptionsfull;
399     }
403 /**
404  * Class for loading questions according to various criteria.
405  *
406  * @copyright  2009 The Open University
407  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
408  */
409 class question_finder {
410     /**
411      * Get the ids of all the questions in a list of categoryies.
412      * @param array $categoryids either a categoryid, or a comma-separated list
413      *      category ids, or an array of them.
414      * @param string $extraconditions extra conditions to AND with the rest of
415      *      the where clause. Must use named parameters.
416      * @param array $extraparams any parameters used by $extraconditions.
417      * @return array questionid => questionid.
418      */
419     public function get_questions_from_categories($categoryids, $extraconditions,
420             $extraparams = array()) {
421         global $DB;
423         list($qcsql, $qcparams) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED, 'qc');
425         if ($extraconditions) {
426             $extraconditions = ' AND (' . $extraconditions . ')';
427         }
429         return $DB->get_records_select_menu('question',
430                 "category $qcsql
431                  AND parent = 0
432                  AND hidden = 0
433                  $extraconditions", $qcparams + $extraparams, '', 'id,id AS id2');
434     }