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