Merge branch 'MDL-70584-310' of git://github.com/marinaglancy/moodle into MOODLE_310_...
[moodle.git] / question / engine / lib.php
CommitLineData
d1b7e03d 1<?php
d1b7e03d
TH
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/>.
16
17/**
18 * This defines the core classes of the Moodle question engine.
19 *
017bc1d9 20 * @package moodlecore
d1b7e03d 21 * @subpackage questionengine
017bc1d9
TH
22 * @copyright 2009 The Open University
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
24 */
25
26
a17b297d
TH
27defined('MOODLE_INTERNAL') || die();
28
0d14f2eb 29require_once($CFG->libdir . '/filelib.php');
1fcf0ca8
RS
30require_once(__DIR__ . '/questionusage.php');
31require_once(__DIR__ . '/questionattempt.php');
32require_once(__DIR__ . '/questionattemptstep.php');
33require_once(__DIR__ . '/states.php');
34require_once(__DIR__ . '/datalib.php');
35require_once(__DIR__ . '/renderer.php');
36require_once(__DIR__ . '/bank.php');
37require_once(__DIR__ . '/../type/questiontypebase.php');
38require_once(__DIR__ . '/../type/questionbase.php');
39require_once(__DIR__ . '/../type/rendererbase.php');
40require_once(__DIR__ . '/../behaviour/behaviourtypebase.php');
41require_once(__DIR__ . '/../behaviour/behaviourbase.php');
42require_once(__DIR__ . '/../behaviour/rendererbase.php');
d1b7e03d
TH
43require_once($CFG->libdir . '/questionlib.php');
44
45
46/**
47 * This static class provides access to the other question engine classes.
48 *
49 * It provides functions for managing question behaviours), and for
50 * creating, loading, saving and deleting {@link question_usage_by_activity}s,
51 * which is the main class that is used by other code that wants to use questions.
52 *
017bc1d9
TH
53 * @copyright 2009 The Open University
54 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
55 */
56abstract class question_engine {
57 /** @var array behaviour name => 1. Records which behaviours have been loaded. */
58 private static $loadedbehaviours = array();
59
f3460297
TH
60 /** @var array behaviour name => question_behaviour_type for this behaviour. */
61 private static $behaviourtypes = array();
62
d1b7e03d
TH
63 /**
64 * Create a new {@link question_usage_by_activity}. The usage is
65 * created in memory. If you want it to persist, you will need to call
66 * {@link save_questions_usage_by_activity()}.
67 *
68 * @param string $component the plugin creating this attempt. For example mod_quiz.
69 * @param object $context the context this usage belongs to.
70 * @return question_usage_by_activity the newly created object.
71 */
72 public static function make_questions_usage_by_activity($component, $context) {
73 return new question_usage_by_activity($component, $context);
74 }
75
76 /**
77 * Load a {@link question_usage_by_activity} from the database, based on its id.
f7970e3c 78 * @param int $qubaid the id of the usage to load.
0a606a2b 79 * @param moodle_database $db a database connectoin. Defaults to global $DB.
d1b7e03d
TH
80 * @return question_usage_by_activity loaded from the database.
81 */
0a606a2b
TH
82 public static function load_questions_usage_by_activity($qubaid, moodle_database $db = null) {
83 $dm = new question_engine_data_mapper($db);
d1b7e03d
TH
84 return $dm->load_questions_usage_by_activity($qubaid);
85 }
86
87 /**
88 * Save a {@link question_usage_by_activity} to the database. This works either
89 * if the usage was newly created by {@link make_questions_usage_by_activity()}
90 * or loaded from the database using {@link load_questions_usage_by_activity()}
91 * @param question_usage_by_activity the usage to save.
0a606a2b 92 * @param moodle_database $db a database connectoin. Defaults to global $DB.
d1b7e03d 93 */
0a606a2b
TH
94 public static function save_questions_usage_by_activity(question_usage_by_activity $quba, moodle_database $db = null) {
95 $dm = new question_engine_data_mapper($db);
d1b7e03d
TH
96 $observer = $quba->get_observer();
97 if ($observer instanceof question_engine_unit_of_work) {
98 $observer->save($dm);
99 } else {
100 $dm->insert_questions_usage_by_activity($quba);
101 }
102 }
103
104 /**
105 * Delete a {@link question_usage_by_activity} from the database, based on its id.
f7970e3c 106 * @param int $qubaid the id of the usage to delete.
d1b7e03d
TH
107 */
108 public static function delete_questions_usage_by_activity($qubaid) {
6b5f24d3 109 self::delete_questions_usage_by_activities(new qubaid_list(array($qubaid)));
d1b7e03d
TH
110 }
111
112 /**
6b5f24d3
TH
113 * Delete {@link question_usage_by_activity}s from the database.
114 * @param qubaid_condition $qubaids identifies which questions usages to delete.
d1b7e03d 115 */
6b5f24d3 116 public static function delete_questions_usage_by_activities(qubaid_condition $qubaids) {
d1b7e03d 117 $dm = new question_engine_data_mapper();
6b5f24d3 118 $dm->delete_questions_usage_by_activities($qubaids);
d1b7e03d
TH
119 }
120
121 /**
122 * Change the maxmark for the question_attempt with number in usage $slot
123 * for all the specified question_attempts.
124 * @param qubaid_condition $qubaids Selects which usages are updated.
f7970e3c 125 * @param int $slot the number is usage to affect.
d1b7e03d
TH
126 * @param number $newmaxmark the new max mark to set.
127 */
128 public static function set_max_mark_in_attempts(qubaid_condition $qubaids,
129 $slot, $newmaxmark) {
130 $dm = new question_engine_data_mapper();
131 $dm->set_max_mark_in_attempts($qubaids, $slot, $newmaxmark);
132 }
133
f3460297
TH
134 /**
135 * Validate that the manual grade submitted for a particular question is in range.
136 * @param int $qubaid the question_usage id.
137 * @param int $slot the slot number within the usage.
138 * @return bool whether the submitted data is in range.
139 */
140 public static function is_manual_grade_in_range($qubaid, $slot) {
141 $prefix = 'q' . $qubaid . ':' . $slot . '_';
142 $mark = question_utils::optional_param_mark($prefix . '-mark');
143 $maxmark = optional_param($prefix . '-maxmark', null, PARAM_FLOAT);
144 $minfraction = optional_param($prefix . ':minfraction', null, PARAM_FLOAT);
4e3d8293 145 $maxfraction = optional_param($prefix . ':maxfraction', null, PARAM_FLOAT);
b2694c02 146 return $mark === '' ||
ddc01683
TH
147 ($mark !== null && $mark >= $minfraction * $maxmark && $mark <= $maxfraction * $maxmark) ||
148 ($mark === null && $maxmark === null);
f3460297
TH
149 }
150
d1b7e03d
TH
151 /**
152 * @param array $questionids of question ids.
07f88584
TH
153 * @param qubaid_condition $qubaids ids of the usages to consider.
154 * @return boolean whether any of these questions are being used by any of
155 * those usages.
d1b7e03d 156 */
07f88584
TH
157 public static function questions_in_use(array $questionids, qubaid_condition $qubaids = null) {
158 if (is_null($qubaids)) {
159 return false;
160 }
d1b7e03d 161 $dm = new question_engine_data_mapper();
07f88584 162 return $dm->questions_in_use($questionids, $qubaids);
d1b7e03d
TH
163 }
164
bb93fc24
TH
165 /**
166 * Get the number of times each variant has been used for each question in a list
167 * in a set of usages.
168 * @param array $questionids of question ids.
169 * @param qubaid_condition $qubaids ids of the usages to consider.
170 * @return array questionid => variant number => num uses.
171 */
172 public static function load_used_variants(array $questionids, qubaid_condition $qubaids) {
173 $dm = new question_engine_data_mapper();
174 return $dm->load_used_variants($questionids, $qubaids);
175 }
176
d1b7e03d
TH
177 /**
178 * Create an archetypal behaviour for a particular question attempt.
179 * Used by {@link question_definition::make_behaviour()}.
180 *
181 * @param string $preferredbehaviour the type of model required.
182 * @param question_attempt $qa the question attempt the model will process.
183 * @return question_behaviour an instance of appropriate behaviour class.
184 */
185 public static function make_archetypal_behaviour($preferredbehaviour, question_attempt $qa) {
f3460297 186 if (!self::is_behaviour_archetypal($preferredbehaviour)) {
9c197f44
TH
187 throw new coding_exception('The requested behaviour is not actually ' .
188 'an archetypal one.');
d1b7e03d 189 }
f3460297
TH
190
191 self::load_behaviour_class($preferredbehaviour);
192 $class = 'qbehaviour_' . $preferredbehaviour;
d1b7e03d
TH
193 return new $class($qa, $preferredbehaviour);
194 }
195
196 /**
197 * @param string $behaviour the name of a behaviour.
198 * @return array of {@link question_display_options} field names, that are
199 * not relevant to this behaviour before a 'finish' action.
200 */
201 public static function get_behaviour_unused_display_options($behaviour) {
f3460297 202 return self::get_behaviour_type($behaviour)->get_unused_display_options();
d1b7e03d
TH
203 }
204
47be39ef
TH
205 /**
206 * With this behaviour, is it possible that a question might finish as the student
207 * interacts with it, without a call to the {@link question_attempt::finish()} method?
208 * @param string $behaviour the name of a behaviour. E.g. 'deferredfeedback'.
209 * @return bool whether with this behaviour, questions may finish naturally.
210 */
211 public static function can_questions_finish_during_the_attempt($behaviour) {
212 return self::get_behaviour_type($behaviour)->can_questions_finish_during_the_attempt();
213 }
214
d1b7e03d 215 /**
c4efeb2b 216 * Create a behaviour for a particular type. If that type cannot be
d1b7e03d
TH
217 * found, return an instance of qbehaviour_missing.
218 *
219 * Normally you should use {@link make_archetypal_behaviour()}, or
220 * call the constructor of a particular model class directly. This method
221 * is only intended for use by {@link question_attempt::load_from_records()}.
222 *
223 * @param string $behaviour the type of model to create.
224 * @param question_attempt $qa the question attempt the model will process.
225 * @param string $preferredbehaviour the preferred behaviour for the containing usage.
226 * @return question_behaviour an instance of appropriate behaviour class.
227 */
228 public static function make_behaviour($behaviour, question_attempt $qa, $preferredbehaviour) {
229 try {
230 self::load_behaviour_class($behaviour);
231 } catch (Exception $e) {
9c197f44 232 self::load_behaviour_class('missing');
d1b7e03d
TH
233 return new qbehaviour_missing($qa, $preferredbehaviour);
234 }
235 $class = 'qbehaviour_' . $behaviour;
236 return new $class($qa, $preferredbehaviour);
237 }
238
239 /**
240 * Load the behaviour class(es) belonging to a particular model. That is,
241 * include_once('/question/behaviour/' . $behaviour . '/behaviour.php'), with a bit
242 * of checking.
243 * @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'.
244 */
245 public static function load_behaviour_class($behaviour) {
246 global $CFG;
247 if (isset(self::$loadedbehaviours[$behaviour])) {
248 return;
249 }
250 $file = $CFG->dirroot . '/question/behaviour/' . $behaviour . '/behaviour.php';
251 if (!is_readable($file)) {
88f0eb15 252 throw new coding_exception('Unknown question behaviour ' . $behaviour);
d1b7e03d
TH
253 }
254 include_once($file);
f3460297
TH
255
256 $class = 'qbehaviour_' . $behaviour;
257 if (!class_exists($class)) {
258 throw new coding_exception('Question behaviour ' . $behaviour .
259 ' does not define the required class ' . $class . '.');
260 }
261
d1b7e03d
TH
262 self::$loadedbehaviours[$behaviour] = 1;
263 }
264
f3460297 265 /**
c4efeb2b 266 * Create a behaviour for a particular type. If that type cannot be
f3460297
TH
267 * found, return an instance of qbehaviour_missing.
268 *
269 * Normally you should use {@link make_archetypal_behaviour()}, or
270 * call the constructor of a particular model class directly. This method
271 * is only intended for use by {@link question_attempt::load_from_records()}.
272 *
273 * @param string $behaviour the type of model to create.
274 * @param question_attempt $qa the question attempt the model will process.
275 * @param string $preferredbehaviour the preferred behaviour for the containing usage.
276 * @return question_behaviour_type an instance of appropriate behaviour class.
277 */
278 public static function get_behaviour_type($behaviour) {
279
280 if (array_key_exists($behaviour, self::$behaviourtypes)) {
281 return self::$behaviourtypes[$behaviour];
282 }
283
284 self::load_behaviour_type_class($behaviour);
285
286 $class = 'qbehaviour_' . $behaviour . '_type';
287 if (class_exists($class)) {
288 self::$behaviourtypes[$behaviour] = new $class();
289 } else {
290 debugging('Question behaviour ' . $behaviour .
291 ' does not define the required class ' . $class . '.', DEBUG_DEVELOPER);
292 self::$behaviourtypes[$behaviour] = new question_behaviour_type_fallback($behaviour);
293 }
294
295 return self::$behaviourtypes[$behaviour];
296 }
297
298 /**
299 * Load the behaviour type class for a particular behaviour. That is,
300 * include_once('/question/behaviour/' . $behaviour . '/behaviourtype.php').
301 * @param string $behaviour the behaviour name. For example 'interactive' or 'deferredfeedback'.
302 */
303 protected static function load_behaviour_type_class($behaviour) {
304 global $CFG;
305 if (isset(self::$behaviourtypes[$behaviour])) {
306 return;
307 }
308 $file = $CFG->dirroot . '/question/behaviour/' . $behaviour . '/behaviourtype.php';
309 if (!is_readable($file)) {
310 debugging('Question behaviour ' . $behaviour .
311 ' is missing the behaviourtype.php file.', DEBUG_DEVELOPER);
312 }
313 include_once($file);
314 }
315
d1b7e03d
TH
316 /**
317 * Return an array where the keys are the internal names of the archetypal
318 * behaviours, and the values are a human-readable name. An
319 * archetypal behaviour is one that is suitable to pass the name of to
320 * {@link question_usage_by_activity::set_preferred_behaviour()}.
321 *
322 * @return array model name => lang string for this behaviour name.
323 */
324 public static function get_archetypal_behaviours() {
325 $archetypes = array();
bd3b3bba 326 $behaviours = core_component::get_plugin_list('qbehaviour');
fde4560d
TH
327 foreach ($behaviours as $behaviour => $notused) {
328 if (self::is_behaviour_archetypal($behaviour)) {
d1b7e03d
TH
329 $archetypes[$behaviour] = self::get_behaviour_name($behaviour);
330 }
331 }
332 asort($archetypes, SORT_LOCALE_STRING);
333 return $archetypes;
334 }
335
fde4560d
TH
336 /**
337 * @param string $behaviour the name of a behaviour. E.g. 'deferredfeedback'.
338 * @return bool whether this is an archetypal behaviour.
339 */
340 public static function is_behaviour_archetypal($behaviour) {
f3460297 341 return self::get_behaviour_type($behaviour)->is_archetypal();
fde4560d
TH
342 }
343
d1b7e03d
TH
344 /**
345 * Return an array where the keys are the internal names of the behaviours
346 * in preferred order and the values are a human-readable name.
347 *
348 * @param array $archetypes, array of behaviours
fde4560d
TH
349 * @param string $orderlist, a comma separated list of behaviour names
350 * @param string $disabledlist, a comma separated list of behaviour names
351 * @param string $current, current behaviour name
d1b7e03d
TH
352 * @return array model name => lang string for this behaviour name.
353 */
fde4560d 354 public static function sort_behaviours($archetypes, $orderlist, $disabledlist, $current=null) {
d1b7e03d
TH
355
356 // Get disabled behaviours
fde4560d
TH
357 if ($disabledlist) {
358 $disabled = explode(',', $disabledlist);
d1b7e03d 359 } else {
fde4560d 360 $disabled = array();
d1b7e03d
TH
361 }
362
fde4560d
TH
363 if ($orderlist) {
364 $order = explode(',', $orderlist);
d1b7e03d 365 } else {
fde4560d 366 $order = array();
d1b7e03d
TH
367 }
368
fde4560d
TH
369 foreach ($disabled as $behaviour) {
370 if (array_key_exists($behaviour, $archetypes) && $behaviour != $current) {
371 unset($archetypes[$behaviour]);
d1b7e03d
TH
372 }
373 }
374
375 // Get behaviours in preferred order
fde4560d
TH
376 $behaviourorder = array();
377 foreach ($order as $behaviour) {
378 if (array_key_exists($behaviour, $archetypes)) {
379 $behaviourorder[$behaviour] = $archetypes[$behaviour];
d1b7e03d
TH
380 }
381 }
382 // Get the rest of behaviours and sort them alphabetically
fde4560d 383 $leftover = array_diff_key($archetypes, $behaviourorder);
d1b7e03d
TH
384 asort($leftover, SORT_LOCALE_STRING);
385
386 // Set up the final order to be displayed
fde4560d 387 return $behaviourorder + $leftover;
d1b7e03d
TH
388 }
389
390 /**
391 * Return an array where the keys are the internal names of the behaviours
392 * in preferred order and the values are a human-readable name.
393 *
fde4560d 394 * @param string $currentbehaviour
d1b7e03d
TH
395 * @return array model name => lang string for this behaviour name.
396 */
fde4560d
TH
397 public static function get_behaviour_options($currentbehaviour) {
398 $config = question_bank::get_config();
d1b7e03d
TH
399 $archetypes = self::get_archetypal_behaviours();
400
401 // If no admin setting return all behavious
fde4560d 402 if (empty($config->disabledbehaviours) && empty($config->behavioursortorder)) {
d1b7e03d
TH
403 return $archetypes;
404 }
405
fde4560d
TH
406 if (empty($config->behavioursortorder)) {
407 $order = '';
408 } else {
409 $order = $config->behavioursortorder;
410 }
411 if (empty($config->disabledbehaviours)) {
412 $disabled = '';
413 } else {
414 $disabled = $config->disabledbehaviours;
415 }
416
417 return self::sort_behaviours($archetypes, $order, $disabled, $currentbehaviour);
d1b7e03d
TH
418 }
419
420 /**
c4efeb2b 421 * Get the translated name of a behaviour, for display in the UI.
d1b7e03d
TH
422 * @param string $behaviour the internal name of the model.
423 * @return string name from the current language pack.
424 */
425 public static function get_behaviour_name($behaviour) {
aa9bdbe3 426 return get_string('pluginname', 'qbehaviour_' . $behaviour);
d1b7e03d
TH
427 }
428
c749527b
TH
429 /**
430 * @return array all the file area names that may contain response files.
431 */
432 public static function get_all_response_file_areas() {
433 $variables = array();
434 foreach (question_bank::get_all_qtypes() as $qtype) {
2908ed54 435 $variables = array_merge($variables, $qtype->response_file_areas());
c749527b
TH
436 }
437
438 $areas = array();
439 foreach (array_unique($variables) as $variable) {
440 $areas[] = 'response_' . $variable;
441 }
442 return $areas;
443 }
444
d1b7e03d
TH
445 /**
446 * Returns the valid choices for the number of decimal places for showing
447 * question marks. For use in the user interface.
188458a6 448 * @return array suitable for passing to {@link html_writer::select()} or similar.
d1b7e03d
TH
449 */
450 public static function get_dp_options() {
451 return question_display_options::get_dp_options();
452 }
453
6b5f24d3
TH
454 /**
455 * Initialise the JavaScript required on pages where questions will be displayed.
1aacbb72
TH
456 *
457 * @return string
6b5f24d3 458 */
d1b7e03d
TH
459 public static function initialise_js() {
460 return question_flags::initialise_js();
461 }
462}
463
464
465/**
466 * This class contains all the options that controls how a question is displayed.
467 *
468 * Normally, what will happen is that the calling code will set up some display
469 * options to indicate what sort of question display it wants, and then before the
470 * question is rendered, the behaviour will be given a chance to modify the
471 * display options, so that, for example, A question that is finished will only
472 * be shown read-only, and a question that has not been submitted will not have
473 * any sort of feedback displayed.
474 *
017bc1d9
TH
475 * @copyright 2009 The Open University
476 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
477 */
478class question_display_options {
479 /**#@+ @var integer named constants for the values that most of the options take. */
480 const HIDDEN = 0;
481 const VISIBLE = 1;
482 const EDITABLE = 2;
483 /**#@-*/
484
485 /**#@+ @var integer named constants for the {@link $marks} option. */
486 const MAX_ONLY = 1;
487 const MARK_AND_MAX = 2;
488 /**#@-*/
489
490 /**
491 * @var integer maximum value for the {@link $markpd} option. This is
492 * effectively set by the database structure, which uses NUMBER(12,7) columns
493 * for question marks/fractions.
494 */
495 const MAX_DP = 7;
496
497 /**
498 * @var boolean whether the question should be displayed as a read-only review,
499 * or in an active state where you can change the answer.
500 */
501 public $readonly = false;
502
503 /**
504 * @var boolean whether the question type should output hidden form fields
505 * to reset any incorrect parts of the resonse to blank.
506 */
507 public $clearwrong = false;
508
509 /**
510 * Should the student have what they got right and wrong clearly indicated.
511 * This includes the green/red hilighting of the bits of their response,
512 * whether the one-line summary of the current state of the question says
513 * correct/incorrect or just answered.
514 * @var integer {@link question_display_options::HIDDEN} or
515 * {@link question_display_options::VISIBLE}
516 */
517 public $correctness = self::VISIBLE;
518
519 /**
520 * The the mark and/or the maximum available mark for this question be visible?
521 * @var integer {@link question_display_options::HIDDEN},
522 * {@link question_display_options::MAX_ONLY} or {@link question_display_options::MARK_AND_MAX}
523 */
524 public $marks = self::MARK_AND_MAX;
525
526 /** @var number of decimal places to use when formatting marks for output. */
527 public $markdp = 2;
528
529 /**
530 * Should the flag this question UI element be visible, and if so, should the
531 * flag state be changable?
532 * @var integer {@link question_display_options::HIDDEN},
533 * {@link question_display_options::VISIBLE} or {@link question_display_options::EDITABLE}
534 */
535 public $flags = self::VISIBLE;
536
537 /**
538 * Should the specific feedback be visible.
539 * @var integer {@link question_display_options::HIDDEN} or
540 * {@link question_display_options::VISIBLE}
541 */
542 public $feedback = self::VISIBLE;
543
544 /**
545 * For questions with a number of sub-parts (like matching, or
546 * multiple-choice, multiple-reponse) display the number of sub-parts that
547 * were correct.
548 * @var integer {@link question_display_options::HIDDEN} or
549 * {@link question_display_options::VISIBLE}
550 */
551 public $numpartscorrect = self::VISIBLE;
552
553 /**
554 * Should the general feedback be visible?
555 * @var integer {@link question_display_options::HIDDEN} or
556 * {@link question_display_options::VISIBLE}
557 */
558 public $generalfeedback = self::VISIBLE;
559
560 /**
561 * Should the automatically generated display of what the correct answer is
562 * be visible?
563 * @var integer {@link question_display_options::HIDDEN} or
564 * {@link question_display_options::VISIBLE}
565 */
566 public $rightanswer = self::VISIBLE;
567
568 /**
569 * Should the manually added marker's comment be visible. Should the link for
570 * adding/editing the comment be there.
571 * @var integer {@link question_display_options::HIDDEN},
572 * {@link question_display_options::VISIBLE}, or {@link question_display_options::EDITABLE}.
573 * Editable means that form fields are displayed inline.
574 */
575 public $manualcomment = self::VISIBLE;
576
577 /**
578 * Should we show a 'Make comment or override grade' link?
579 * @var string base URL for the edit comment script, which will be shown if
580 * $manualcomment = self::VISIBLE.
581 */
582 public $manualcommentlink = null;
583
584 /**
585 * Used in places like the question history table, to show a link to review
586 * this question in a certain state. If blank, a link is not shown.
1aacbb72 587 * @var moodle_url base URL for a review question script.
d1b7e03d
TH
588 */
589 public $questionreviewlink = null;
590
591 /**
592 * Should the history of previous question states table be visible?
593 * @var integer {@link question_display_options::HIDDEN} or
594 * {@link question_display_options::VISIBLE}
595 */
596 public $history = self::HIDDEN;
597
5e63b335
TH
598 /**
599 * @since 2.9
97e9eb20
TH
600 * @var string extra HTML to include at the end of the outcome (feedback) box
601 * of the question display.
602 *
603 * This field is now badly named. The place it included is was changed
604 * (for the better) but the name was left unchanged for backwards compatibility.
5e63b335
TH
605 */
606 public $extrainfocontent = '';
607
608 /**
609 * @since 2.9
610 * @var string extra HTML to include in the history box of the question display,
611 * if it is shown.
612 */
613 public $extrahistorycontent = '';
614
da729916
TH
615 /**
616 * If not empty, then a link to edit the question will be included in
617 * the info box for the question.
618 *
619 * If used, this array must contain an element courseid or cmid.
620 *
621 * It shoudl also contain a parameter returnurl => moodle_url giving a
622 * sensible URL to go back to when the editing form is submitted or cancelled.
623 *
624 * @var array url parameter for the edit link. id => questiosnid will be
625 * added automatically.
626 */
627 public $editquestionparams = array();
628
caeeff07
TH
629 /**
630 * @var int the context the attempt being output belongs to.
631 */
8026d4aa 632 public $context;
caeeff07 633
d1b7e03d
TH
634 /**
635 * Set all the feedback-related fields {@link $feedback}, {@link generalfeedback},
636 * {@link rightanswer} and {@link manualcomment} to
637 * {@link question_display_options::HIDDEN}.
638 */
639 public function hide_all_feedback() {
640 $this->feedback = self::HIDDEN;
641 $this->numpartscorrect = self::HIDDEN;
642 $this->generalfeedback = self::HIDDEN;
643 $this->rightanswer = self::HIDDEN;
644 $this->manualcomment = self::HIDDEN;
645 $this->correctness = self::HIDDEN;
646 }
647
648 /**
649 * Returns the valid choices for the number of decimal places for showing
650 * question marks. For use in the user interface.
651 *
652 * Calling code should probably use {@link question_engine::get_dp_options()}
653 * rather than calling this method directly.
654 *
188458a6 655 * @return array suitable for passing to {@link html_writer::select()} or similar.
d1b7e03d
TH
656 */
657 public static function get_dp_options() {
658 $options = array();
659 for ($i = 0; $i <= self::MAX_DP; $i += 1) {
660 $options[$i] = $i;
661 }
662 return $options;
663 }
664}
665
666
667/**
668 * Contains the logic for handling question flags.
669 *
f7970e3c 670 * @copyright 2010 The Open University
017bc1d9 671 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
672 */
673abstract class question_flags {
674 /**
675 * Get the checksum that validates that a toggle request is valid.
f7970e3c
TH
676 * @param int $qubaid the question usage id.
677 * @param int $questionid the question id.
678 * @param int $sessionid the question_attempt id.
d1b7e03d
TH
679 * @param object $user the user. If null, defaults to $USER.
680 * @return string that needs to be sent to question/toggleflag.php for it to work.
681 */
9c197f44
TH
682 protected static function get_toggle_checksum($qubaid, $questionid,
683 $qaid, $slot, $user = null) {
d1b7e03d
TH
684 if (is_null($user)) {
685 global $USER;
686 $user = $USER;
687 }
06f8ed54 688 return md5($qubaid . "_" . $user->secret . "_" . $questionid . "_" . $qaid . "_" . $slot);
d1b7e03d
TH
689 }
690
691 /**
692 * Get the postdata that needs to be sent to question/toggleflag.php to change the flag state.
693 * You need to append &newstate=0/1 to this.
694 * @return the post data to send.
695 */
696 public static function get_postdata(question_attempt $qa) {
697 $qaid = $qa->get_database_id();
698 $qubaid = $qa->get_usage_id();
64207dab 699 $qid = $qa->get_question_id();
06f8ed54
TH
700 $slot = $qa->get_slot();
701 $checksum = self::get_toggle_checksum($qubaid, $qid, $qaid, $slot);
f4fe3968 702 return "qaid={$qaid}&qubaid={$qubaid}&qid={$qid}&slot={$slot}&checksum={$checksum}&sesskey=" .
404da238 703 sesskey() . '&newstate=';
d1b7e03d
TH
704 }
705
706 /**
707 * If the request seems valid, update the flag state of a question attempt.
708 * Throws exceptions if this is not a valid update request.
f7970e3c
TH
709 * @param int $qubaid the question usage id.
710 * @param int $questionid the question id.
711 * @param int $sessionid the question_attempt id.
d1b7e03d
TH
712 * @param string $checksum checksum, as computed by {@link get_toggle_checksum()}
713 * corresponding to the last three arguments.
f7970e3c 714 * @param bool $newstate the new state of the flag. true = flagged.
d1b7e03d 715 */
06f8ed54 716 public static function update_flag($qubaid, $questionid, $qaid, $slot, $checksum, $newstate) {
d1b7e03d 717 // Check the checksum - it is very hard to know who a question session belongs
f9b0500f 718 // to, so we require that checksum parameter is matches an md5 hash of the
d1b7e03d
TH
719 // three ids and the users username. Since we are only updating a flag, that
720 // probably makes it sufficiently difficult for malicious users to toggle
721 // other users flags.
9c197f44 722 if ($checksum != self::get_toggle_checksum($qubaid, $questionid, $qaid, $slot)) {
88f0eb15 723 throw new moodle_exception('errorsavingflags', 'question');
d1b7e03d
TH
724 }
725
726 $dm = new question_engine_data_mapper();
06f8ed54 727 $dm->update_question_attempt_flag($qubaid, $questionid, $qaid, $slot, $newstate);
d1b7e03d
TH
728 }
729
730 public static function initialise_js() {
f29aeb5a
TH
731 global $CFG, $PAGE, $OUTPUT;
732 static $done = false;
733 if ($done) {
734 return;
735 }
736 $module = array(
737 'name' => 'core_question_flags',
738 'fullpath' => '/question/flags.js',
739 'requires' => array('base', 'dom', 'event-delegate', 'io-base'),
740 );
741 $actionurl = $CFG->wwwroot . '/question/toggleflag.php';
22c97c64
TH
742 $flagtext = array(
743 0 => get_string('clickflag', 'question'),
744 1 => get_string('clickunflag', 'question')
745 );
f29aeb5a
TH
746 $flagattributes = array(
747 0 => array(
663640f5 748 'src' => $OUTPUT->image_url('i/unflagged') . '',
f29aeb5a
TH
749 'title' => get_string('clicktoflag', 'question'),
750 'alt' => get_string('notflagged', 'question'),
474ee938 751 // 'text' => get_string('clickflag', 'question'),
f29aeb5a
TH
752 ),
753 1 => array(
663640f5 754 'src' => $OUTPUT->image_url('i/flagged') . '',
f29aeb5a
TH
755 'title' => get_string('clicktounflag', 'question'),
756 'alt' => get_string('flagged', 'question'),
474ee938 757 // 'text' => get_string('clickunflag', 'question'),
f29aeb5a 758 ),
d1b7e03d 759 );
f29aeb5a 760 $PAGE->requires->js_init_call('M.core_question_flags.init',
22c97c64 761 array($actionurl, $flagattributes, $flagtext), false, $module);
f29aeb5a 762 $done = true;
d1b7e03d
TH
763 }
764}
765
766
f7970e3c
TH
767/**
768 * Exception thrown when the system detects that a student has done something
769 * out-of-order to a question. This can happen, for example, if they click
5e8a85aa 770 * the browser's back button in a quiz, then try to submit a different response.
f7970e3c
TH
771 *
772 * @copyright 2010 The Open University
773 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
774 */
d1b7e03d 775class question_out_of_sequence_exception extends moodle_exception {
c7df5006 776 public function __construct($qubaid, $slot, $postdata) {
d1b7e03d
TH
777 if ($postdata == null) {
778 $postdata = data_submitted();
779 }
780 parent::__construct('submissionoutofsequence', 'question', '', null,
f4fe3968 781 "QUBAid: {$qubaid}, slot: {$slot}, post data: " . print_r($postdata, true));
d1b7e03d
TH
782 }
783}
784
785
d1b7e03d
TH
786/**
787 * Useful functions for writing question types and behaviours.
788 *
789 * @copyright 2010 The Open University
017bc1d9 790 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
791 */
792abstract class question_utils {
e3bfcb1c
TH
793 /**
794 * @var float tolerance to use when comparing question mark/fraction values.
795 *
796 * When comparing floating point numbers in a computer, the representation is not
797 * necessarily exact. Therefore, we need to allow a tolerance.
798 * Question marks are stored in the database as decimal numbers with 7 decimal places.
799 * Therefore, this is the appropriate tolerance to use.
800 */
801 const MARK_TOLERANCE = 0.00000005;
802
d1b7e03d
TH
803 /**
804 * Tests to see whether two arrays have the same keys, with the same values
805 * (as compared by ===) for each key. However, the order of the arrays does
806 * not have to be the same.
807 * @param array $array1 the first array.
808 * @param array $array2 the second array.
f7970e3c 809 * @return bool whether the two arrays have the same keys with the same
d1b7e03d
TH
810 * corresponding values.
811 */
812 public static function arrays_have_same_keys_and_values(array $array1, array $array2) {
813 if (count($array1) != count($array2)) {
814 return false;
815 }
816 foreach ($array1 as $key => $value1) {
817 if (!array_key_exists($key, $array2)) {
818 return false;
819 }
820 if (((string) $value1) !== ((string) $array2[$key])) {
821 return false;
822 }
823 }
824 return true;
825 }
826
827 /**
828 * Tests to see whether two arrays have the same value at a particular key.
829 * This method will return true if:
830 * 1. Neither array contains the key; or
831 * 2. Both arrays contain the key, and the corresponding values compare
832 * identical when cast to strings and compared with ===.
833 * @param array $array1 the first array.
834 * @param array $array2 the second array.
835 * @param string $key an array key.
f7970e3c 836 * @return bool whether the two arrays have the same value (or lack of
d1b7e03d
TH
837 * one) for a given key.
838 */
839 public static function arrays_same_at_key(array $array1, array $array2, $key) {
840 if (array_key_exists($key, $array1) && array_key_exists($key, $array2)) {
841 return ((string) $array1[$key]) === ((string) $array2[$key]);
842 }
843 if (!array_key_exists($key, $array1) && !array_key_exists($key, $array2)) {
844 return true;
845 }
846 return false;
847 }
848
849 /**
850 * Tests to see whether two arrays have the same value at a particular key.
851 * Missing values are replaced by '', and then the values are cast to
852 * strings and compared with ===.
853 * @param array $array1 the first array.
854 * @param array $array2 the second array.
855 * @param string $key an array key.
f7970e3c 856 * @return bool whether the two arrays have the same value (or lack of
d1b7e03d
TH
857 * one) for a given key.
858 */
859 public static function arrays_same_at_key_missing_is_blank(
860 array $array1, array $array2, $key) {
861 if (array_key_exists($key, $array1)) {
862 $value1 = $array1[$key];
863 } else {
864 $value1 = '';
865 }
866 if (array_key_exists($key, $array2)) {
867 $value2 = $array2[$key];
868 } else {
869 $value2 = '';
870 }
871 return ((string) $value1) === ((string) $value2);
872 }
873
874 /**
875 * Tests to see whether two arrays have the same value at a particular key.
876 * Missing values are replaced by 0, and then the values are cast to
877 * integers and compared with ===.
878 * @param array $array1 the first array.
879 * @param array $array2 the second array.
880 * @param string $key an array key.
f7970e3c 881 * @return bool whether the two arrays have the same value (or lack of
d1b7e03d
TH
882 * one) for a given key.
883 */
884 public static function arrays_same_at_key_integer(
885 array $array1, array $array2, $key) {
886 if (array_key_exists($key, $array1)) {
f593a641 887 $value1 = (int) $array1[$key];
d1b7e03d
TH
888 } else {
889 $value1 = 0;
890 }
891 if (array_key_exists($key, $array2)) {
f593a641 892 $value2 = (int) $array2[$key];
d1b7e03d
TH
893 } else {
894 $value2 = 0;
895 }
f593a641 896 return $value1 === $value2;
d1b7e03d
TH
897 }
898
9c197f44
TH
899 private static $units = array('', 'i', 'ii', 'iii', 'iv', 'v', 'vi', 'vii', 'viii', 'ix');
900 private static $tens = array('', 'x', 'xx', 'xxx', 'xl', 'l', 'lx', 'lxx', 'lxxx', 'xc');
901 private static $hundreds = array('', 'c', 'cc', 'ccc', 'cd', 'd', 'dc', 'dcc', 'dccc', 'cm');
d1b7e03d
TH
902 private static $thousands = array('', 'm', 'mm', 'mmm');
903
904 /**
905 * Convert an integer to roman numerals.
9c197f44
TH
906 * @param int $number an integer between 1 and 3999 inclusive. Anything else
907 * will throw an exception.
d1b7e03d
TH
908 * @return string the number converted to lower case roman numerals.
909 */
910 public static function int_to_roman($number) {
911 if (!is_integer($number) || $number < 1 || $number > 3999) {
912 throw new coding_exception('Only integers between 0 and 3999 can be ' .
913 'converted to roman numerals.', $number);
914 }
915
916 return self::$thousands[$number / 1000 % 10] . self::$hundreds[$number / 100 % 10] .
917 self::$tens[$number / 10 % 10] . self::$units[$number % 10];
918 }
2c76c894 919
5179a01a
K
920 /**
921 * Convert an integer to a letter of alphabet.
922 * @param int $number an integer between 1 and 26 inclusive.
923 * Anything else will throw an exception.
924 * @return string the number converted to upper case letter of alphabet.
925 */
926 public static function int_to_letter($number) {
927 $alphabet = [
928 '1' => 'A',
929 '2' => 'B',
930 '3' => 'C',
931 '4' => 'D',
932 '5' => 'E',
933 '6' => 'F',
934 '7' => 'G',
935 '8' => 'H',
936 '9' => 'I',
937 '10' => 'J',
938 '11' => 'K',
939 '12' => 'L',
940 '13' => 'M',
941 '14' => 'N',
942 '15' => 'O',
943 '16' => 'P',
944 '17' => 'Q',
945 '18' => 'R',
946 '19' => 'S',
947 '20' => 'T',
948 '21' => 'U',
949 '22' => 'V',
950 '23' => 'W',
951 '24' => 'X',
952 '25' => 'Y',
953 '26' => 'Z'
954 ];
955 if (!is_integer($number) || $number < 1 || $number > count($alphabet)) {
956 throw new coding_exception('Only integers between 1 and 26 can be converted to letters.', $number);
957 }
958 return $alphabet[$number];
959 }
960
2c76c894
TH
961 /**
962 * Typically, $mark will have come from optional_param($name, null, PARAM_RAW_TRIMMED).
963 * This method copes with:
b2694c02
TH
964 * - keeping null or '' input unchanged - important to let teaches set a question back to requries grading.
965 * - numbers that were typed as either 1.00 or 1,00 form.
966 * - invalid things, which get turned into null.
2c76c894
TH
967 *
968 * @param string|null $mark raw use input of a mark.
969 * @return float|string|null cleaned mark as a float if possible. Otherwise '' or null.
970 */
971 public static function clean_param_mark($mark) {
972 if ($mark === '' || is_null($mark)) {
973 return $mark;
974 }
975
b2694c02
TH
976 $mark = str_replace(',', '.', $mark);
977 // This regexp should match the one in validate_param.
978 if (!preg_match('/^[\+-]?[0-9]*\.?[0-9]*(e[-+]?[0-9]+)?$/i', $mark)) {
979 return null;
980 }
981
982 return clean_param($mark, PARAM_FLOAT);
2c76c894
TH
983 }
984
985 /**
986 * Get a sumitted variable (from the GET or POST data) that is a mark.
987 * @param string $parname the submitted variable name.
988 * @return float|string|null cleaned mark as a float if possible. Otherwise '' or null.
989 */
990 public static function optional_param_mark($parname) {
991 return self::clean_param_mark(
992 optional_param($parname, null, PARAM_RAW_TRIMMED));
993 }
e2b388c1
TH
994
995 /**
996 * Convert part of some question content to plain text.
997 * @param string $text the text.
998 * @param int $format the text format.
999 * @param array $options formatting options. Passed to {@link format_text}.
1000 * @return float|string|null cleaned mark as a float if possible. Otherwise '' or null.
1001 */
1002 public static function to_plain_text($text, $format, $options = array('noclean' => 'true')) {
1003 // The following call to html_to_text uses the option that strips out
1004 // all URLs, but format_text complains if it finds @@PLUGINFILE@@ tokens.
1005 // So, we need to replace @@PLUGINFILE@@ with a real URL, but it doesn't
1006 // matter what. We use http://example.com/.
1007 $text = str_replace('@@PLUGINFILE@@/', 'http://example.com/', $text);
1008 return html_to_text(format_text($text, $format, $options), 0, false);
1009 }
fd2ce923
AH
1010
1011 /**
1012 * Get the options required to configure the filepicker for one of the editor
1013 * toolbar buttons.
1014 * @param mixed $acceptedtypes array of types of '*'.
1015 * @param int $draftitemid the draft area item id.
1016 * @param object $context the context.
1017 * @return object the required options.
1018 */
1019 protected static function specific_filepicker_options($acceptedtypes, $draftitemid, $context) {
1020 $filepickeroptions = new stdClass();
1021 $filepickeroptions->accepted_types = $acceptedtypes;
1022 $filepickeroptions->return_types = FILE_INTERNAL | FILE_EXTERNAL;
1023 $filepickeroptions->context = $context;
1024 $filepickeroptions->env = 'filepicker';
1025
1026 $options = initialise_filepicker($filepickeroptions);
1027 $options->context = $context;
1028 $options->client_id = uniqid();
1029 $options->env = 'editor';
1030 $options->itemid = $draftitemid;
1031
1032 return $options;
1033 }
1034
1035 /**
1036 * Get filepicker options for question related text areas.
1037 * @param object $context the context.
1038 * @param int $draftitemid the draft area item id.
1039 * @return array An array of options
1040 */
1041 public static function get_filepicker_options($context, $draftitemid) {
1042 return [
1043 'image' => self::specific_filepicker_options(['image'], $draftitemid, $context),
1044 'media' => self::specific_filepicker_options(['video', 'audio'], $draftitemid, $context),
1045 'link' => self::specific_filepicker_options('*', $draftitemid, $context),
1046 ];
1047 }
1048
1049 /**
1050 * Get editor options for question related text areas.
1051 * @param object $context the context.
1052 * @return array An array of options
1053 */
1054 public static function get_editor_options($context) {
1055 global $CFG;
1056
1057 $editoroptions = [
1058 'subdirs' => 0,
1059 'context' => $context,
1060 'maxfiles' => EDITOR_UNLIMITED_FILES,
1061 'maxbytes' => $CFG->maxbytes,
1062 'noclean' => 0,
1063 'trusttext' => 0,
1064 'autosave' => false
1065 ];
1066
1067 return $editoroptions;
1068 }
dcd03928 1069}
1da821bb
TH
1070
1071
1072/**
1073 * The interface for strategies for controlling which variant of each question is used.
1074 *
c014b989 1075 * @copyright 2011 The Open University
1da821bb
TH
1076 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1077 */
1078interface question_variant_selection_strategy {
1079 /**
1080 * @param int $maxvariants the num
1081 * @param string $seed data that can be used to controls how the variant is selected
1082 * in a semi-random way.
1083 * @return int the variant to use, a number betweeb 1 and $maxvariants inclusive.
1084 */
1085 public function choose_variant($maxvariants, $seed);
1086}
1087
1088
1089/**
1090 * A {@link question_variant_selection_strategy} that is completely random.
1091 *
c014b989 1092 * @copyright 2011 The Open University
1da821bb
TH
1093 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1094 */
1095class question_variant_random_strategy implements question_variant_selection_strategy {
1096 public function choose_variant($maxvariants, $seed) {
1097 return rand(1, $maxvariants);
1098 }
1099}
c014b989
TH
1100
1101
1102/**
1103 * A {@link question_variant_selection_strategy} that is effectively random
1104 * for the first attempt, and then after that cycles through the available
1105 * variants so that the students will not get a repeated variant until they have
1106 * seen them all.
1107 *
1108 * @copyright 2011 The Open University
1109 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1110 */
1111class question_variant_pseudorandom_no_repeats_strategy
1112 implements question_variant_selection_strategy {
1113
1114 /** @var int the number of attempts this users has had, including the curent one. */
1115 protected $attemptno;
1116
1117 /** @var int the user id the attempt belongs to. */
1118 protected $userid;
1119
d7db097d
TH
1120 /** @var string extra input fed into the pseudo-random code. */
1121 protected $extrarandomness = '';
1122
c014b989
TH
1123 /**
1124 * Constructor.
1125 * @param int $attemptno The attempt number.
1126 * @param int $userid the user the attempt is for (defaults to $USER->id).
1127 */
d7db097d 1128 public function __construct($attemptno, $userid = null, $extrarandomness = '') {
c014b989
TH
1129 $this->attemptno = $attemptno;
1130 if (is_null($userid)) {
1131 global $USER;
1132 $this->userid = $USER->id;
1133 } else {
1134 $this->userid = $userid;
1135 }
d7db097d
TH
1136
1137 if ($extrarandomness) {
1138 $this->extrarandomness = '|' . $extrarandomness;
1139 }
c014b989
TH
1140 }
1141
1142 public function choose_variant($maxvariants, $seed) {
1143 if ($maxvariants == 1) {
1144 return 1;
1145 }
1146
d7db097d 1147 $hash = sha1($seed . '|user' . $this->userid . $this->extrarandomness);
c014b989
TH
1148 $randint = hexdec(substr($hash, 17, 7));
1149
1150 return ($randint + $this->attemptno) % $maxvariants + 1;
1151 }
1152}
f7f596a2
JP
1153
1154/**
1155 * A {@link question_variant_selection_strategy} designed ONLY for testing.
1156 * For selected questions it wil return a specific variants. For the other
1157 * slots it will use a fallback strategy.
1158 *
1159 * @copyright 2013 The Open University
1160 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1161 */
1162class question_variant_forced_choices_selection_strategy
1163 implements question_variant_selection_strategy {
1164
1165 /** @var array seed => variant to select. */
1166 protected $forcedchoices;
1167
1168 /** @var question_variant_selection_strategy strategy used to make the non-forced choices. */
1169 protected $basestrategy;
1170
1171 /**
1172 * Constructor.
1173 * @param array $forcedchoices array seed => variant to select.
1174 * @param question_variant_selection_strategy $basestrategy strategy used
1175 * to make the non-forced choices.
1176 */
1177 public function __construct(array $forcedchoices, question_variant_selection_strategy $basestrategy) {
1178 $this->forcedchoices = $forcedchoices;
1179 $this->basestrategy = $basestrategy;
1180 }
1181
1182 public function choose_variant($maxvariants, $seed) {
1183 if (array_key_exists($seed, $this->forcedchoices)) {
1184 if ($this->forcedchoices[$seed] > $maxvariants) {
1185 throw new coding_exception('Forced variant out of range.');
1186 }
1187 return $this->forcedchoices[$seed];
1188 } else {
1189 return $this->basestrategy->choose_variant($maxvariants, $seed);
1190 }
1191 }
1192
1193 /**
1194 * Helper method for preparing the $forcedchoices array.
1195 * @param array $variantsbyslot slot number => variant to select.
1196 * @param question_usage_by_activity $quba the question usage we need a strategy for.
1197 * @throws coding_exception when variant cannot be forced as doesn't work.
1198 * @return array that can be passed to the constructor as $forcedchoices.
1199 */
1200 public static function prepare_forced_choices_array(array $variantsbyslot,
1201 question_usage_by_activity $quba) {
1202
1203 $forcedchoices = array();
1204
1205 foreach ($variantsbyslot as $slot => $varianttochoose) {
1206 $question = $quba->get_question($slot);
1207 $seed = $question->get_variants_selection_seed();
1208 if (array_key_exists($seed, $forcedchoices) && $forcedchoices[$seed] != $varianttochoose) {
1209 throw new coding_exception('Inconsistent forced variant detected at slot ' . $slot);
1210 }
1211 if ($varianttochoose > $question->get_num_variants()) {
1212 throw new coding_exception('Forced variant out of range at slot ' . $slot);
1213 }
1214 $forcedchoices[$seed] = $varianttochoose;
1215 }
1216 return $forcedchoices;
1217 }
1218}