MDL-20636 Essay questions can now handle files in the HTML editor. #216
[moodle.git] / question / engine / lib.php
CommitLineData
d1b7e03d
TH
1<?php
2
3// This file is part of Moodle - http://moodle.org/
4//
5// Moodle is free software: you can redistribute it and/or modify
6// it under the terms of the GNU General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// Moodle is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU General Public License for more details.
14//
15// You should have received a copy of the GNU General Public License
16// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17
18/**
19 * This defines the core classes of the Moodle question engine.
20 *
017bc1d9 21 * @package moodlecore
d1b7e03d 22 * @subpackage questionengine
017bc1d9
TH
23 * @copyright 2009 The Open University
24 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
25 */
26
27
a17b297d
TH
28defined('MOODLE_INTERNAL') || die();
29
d1b7e03d
TH
30require_once(dirname(__FILE__) . '/states.php');
31require_once(dirname(__FILE__) . '/datalib.php');
32require_once(dirname(__FILE__) . '/renderer.php');
33require_once(dirname(__FILE__) . '/bank.php');
34require_once(dirname(__FILE__) . '/../type/questiontype.php');
35require_once(dirname(__FILE__) . '/../type/questionbase.php');
36require_once(dirname(__FILE__) . '/../type/rendererbase.php');
37require_once(dirname(__FILE__) . '/../behaviour/behaviourbase.php');
38require_once(dirname(__FILE__) . '/../behaviour/rendererbase.php');
39require_once($CFG->libdir . '/questionlib.php');
40
41
42/**
43 * This static class provides access to the other question engine classes.
44 *
45 * It provides functions for managing question behaviours), and for
46 * creating, loading, saving and deleting {@link question_usage_by_activity}s,
47 * which is the main class that is used by other code that wants to use questions.
48 *
017bc1d9
TH
49 * @copyright 2009 The Open University
50 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
51 */
52abstract class question_engine {
53 /** @var array behaviour name => 1. Records which behaviours have been loaded. */
54 private static $loadedbehaviours = array();
55
56 /**
57 * Create a new {@link question_usage_by_activity}. The usage is
58 * created in memory. If you want it to persist, you will need to call
59 * {@link save_questions_usage_by_activity()}.
60 *
61 * @param string $component the plugin creating this attempt. For example mod_quiz.
62 * @param object $context the context this usage belongs to.
63 * @return question_usage_by_activity the newly created object.
64 */
65 public static function make_questions_usage_by_activity($component, $context) {
66 return new question_usage_by_activity($component, $context);
67 }
68
69 /**
70 * Load a {@link question_usage_by_activity} from the database, based on its id.
f7970e3c 71 * @param int $qubaid the id of the usage to load.
d1b7e03d
TH
72 * @return question_usage_by_activity loaded from the database.
73 */
74 public static function load_questions_usage_by_activity($qubaid) {
75 $dm = new question_engine_data_mapper();
76 return $dm->load_questions_usage_by_activity($qubaid);
77 }
78
79 /**
80 * Save a {@link question_usage_by_activity} to the database. This works either
81 * if the usage was newly created by {@link make_questions_usage_by_activity()}
82 * or loaded from the database using {@link load_questions_usage_by_activity()}
83 * @param question_usage_by_activity the usage to save.
84 */
85 public static function save_questions_usage_by_activity(question_usage_by_activity $quba) {
86 $dm = new question_engine_data_mapper();
87 $observer = $quba->get_observer();
88 if ($observer instanceof question_engine_unit_of_work) {
89 $observer->save($dm);
90 } else {
91 $dm->insert_questions_usage_by_activity($quba);
92 }
93 }
94
95 /**
96 * Delete a {@link question_usage_by_activity} from the database, based on its id.
f7970e3c 97 * @param int $qubaid the id of the usage to delete.
d1b7e03d
TH
98 */
99 public static function delete_questions_usage_by_activity($qubaid) {
100 global $CFG;
6b5f24d3 101 self::delete_questions_usage_by_activities(new qubaid_list(array($qubaid)));
d1b7e03d
TH
102 }
103
104 /**
6b5f24d3
TH
105 * Delete {@link question_usage_by_activity}s from the database.
106 * @param qubaid_condition $qubaids identifies which questions usages to delete.
d1b7e03d 107 */
6b5f24d3 108 public static function delete_questions_usage_by_activities(qubaid_condition $qubaids) {
d1b7e03d 109 $dm = new question_engine_data_mapper();
6b5f24d3 110 $dm->delete_questions_usage_by_activities($qubaids);
d1b7e03d
TH
111 }
112
113 /**
114 * Change the maxmark for the question_attempt with number in usage $slot
115 * for all the specified question_attempts.
116 * @param qubaid_condition $qubaids Selects which usages are updated.
f7970e3c 117 * @param int $slot the number is usage to affect.
d1b7e03d
TH
118 * @param number $newmaxmark the new max mark to set.
119 */
120 public static function set_max_mark_in_attempts(qubaid_condition $qubaids,
121 $slot, $newmaxmark) {
122 $dm = new question_engine_data_mapper();
123 $dm->set_max_mark_in_attempts($qubaids, $slot, $newmaxmark);
124 }
125
126 /**
127 * @param array $questionids of question ids.
07f88584
TH
128 * @param qubaid_condition $qubaids ids of the usages to consider.
129 * @return boolean whether any of these questions are being used by any of
130 * those usages.
d1b7e03d 131 */
07f88584
TH
132 public static function questions_in_use(array $questionids, qubaid_condition $qubaids = null) {
133 if (is_null($qubaids)) {
134 return false;
135 }
d1b7e03d 136 $dm = new question_engine_data_mapper();
07f88584 137 return $dm->questions_in_use($questionids, $qubaids);
d1b7e03d
TH
138 }
139
140 /**
141 * Create an archetypal behaviour for a particular question attempt.
142 * Used by {@link question_definition::make_behaviour()}.
143 *
144 * @param string $preferredbehaviour the type of model required.
145 * @param question_attempt $qa the question attempt the model will process.
146 * @return question_behaviour an instance of appropriate behaviour class.
147 */
148 public static function make_archetypal_behaviour($preferredbehaviour, question_attempt $qa) {
149 question_engine::load_behaviour_class($preferredbehaviour);
150 $class = 'qbehaviour_' . $preferredbehaviour;
151 if (!constant($class . '::IS_ARCHETYPAL')) {
88f0eb15 152 throw new coding_exception('The requested behaviour is not actually an archetypal one.');
d1b7e03d
TH
153 }
154 return new $class($qa, $preferredbehaviour);
155 }
156
157 /**
158 * @param string $behaviour the name of a behaviour.
159 * @return array of {@link question_display_options} field names, that are
160 * not relevant to this behaviour before a 'finish' action.
161 */
162 public static function get_behaviour_unused_display_options($behaviour) {
163 self::load_behaviour_class($behaviour);
164 $class = 'qbehaviour_' . $behaviour;
165 if (!method_exists($class, 'get_unused_display_options')) {
166 return question_behaviour::get_unused_display_options();
167 }
168 return call_user_func(array($class, 'get_unused_display_options'));
169 }
170
171 /**
172 * Create an behaviour for a particular type. If that type cannot be
173 * found, return an instance of qbehaviour_missing.
174 *
175 * Normally you should use {@link make_archetypal_behaviour()}, or
176 * call the constructor of a particular model class directly. This method
177 * is only intended for use by {@link question_attempt::load_from_records()}.
178 *
179 * @param string $behaviour the type of model to create.
180 * @param question_attempt $qa the question attempt the model will process.
181 * @param string $preferredbehaviour the preferred behaviour for the containing usage.
182 * @return question_behaviour an instance of appropriate behaviour class.
183 */
184 public static function make_behaviour($behaviour, question_attempt $qa, $preferredbehaviour) {
185 try {
186 self::load_behaviour_class($behaviour);
187 } catch (Exception $e) {
188 question_engine::load_behaviour_class('missing');
189 return new qbehaviour_missing($qa, $preferredbehaviour);
190 }
191 $class = 'qbehaviour_' . $behaviour;
192 return new $class($qa, $preferredbehaviour);
193 }
194
195 /**
196 * Load the behaviour class(es) belonging to a particular model. That is,
197 * include_once('/question/behaviour/' . $behaviour . '/behaviour.php'), with a bit
198 * of checking.
199 * @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'.
200 */
201 public static function load_behaviour_class($behaviour) {
202 global $CFG;
203 if (isset(self::$loadedbehaviours[$behaviour])) {
204 return;
205 }
206 $file = $CFG->dirroot . '/question/behaviour/' . $behaviour . '/behaviour.php';
207 if (!is_readable($file)) {
88f0eb15 208 throw new coding_exception('Unknown question behaviour ' . $behaviour);
d1b7e03d
TH
209 }
210 include_once($file);
211 self::$loadedbehaviours[$behaviour] = 1;
212 }
213
214 /**
215 * Return an array where the keys are the internal names of the archetypal
216 * behaviours, and the values are a human-readable name. An
217 * archetypal behaviour is one that is suitable to pass the name of to
218 * {@link question_usage_by_activity::set_preferred_behaviour()}.
219 *
220 * @return array model name => lang string for this behaviour name.
221 */
222 public static function get_archetypal_behaviours() {
223 $archetypes = array();
224 $behaviours = get_list_of_plugins('question/behaviour');
225 foreach ($behaviours as $path) {
226 $behaviour = basename($path);
227 self::load_behaviour_class($behaviour);
228 $plugin = 'qbehaviour_' . $behaviour;
229 if (constant($plugin . '::IS_ARCHETYPAL')) {
230 $archetypes[$behaviour] = self::get_behaviour_name($behaviour);
231 }
232 }
233 asort($archetypes, SORT_LOCALE_STRING);
234 return $archetypes;
235 }
236
237 /**
238 * Return an array where the keys are the internal names of the behaviours
239 * in preferred order and the values are a human-readable name.
240 *
241 * @param array $archetypes, array of behaviours
242 * @param string $questionbehavioursorder, a comma separated list of behaviour names
243 * @param string $questionbehavioursdisabled, a comma separated list of behaviour names
244 * @param string $currentbahaviour, current behaviour name
245 * @return array model name => lang string for this behaviour name.
246 */
247 public static function sort_behaviours($archetypes, $questionbehavioursorder,
248 $questionbehavioursdisabled, $currentbahaviour) {
249 $behaviourorder = array();
250 $behaviourdisabled = array();
251
252 // Get disabled behaviours
253 if ($questionbehavioursdisabled) {
254 $behaviourdisabledtemp = preg_split('/[\s,;]+/', $questionbehavioursdisabled);
255 } else {
256 $behaviourdisabledtemp = array();
257 }
258
259 if ($questionbehavioursorder) {
260 $behaviourordertemp = preg_split('/[\s,;]+/', $questionbehavioursorder);
261 } else {
262 $behaviourordertemp = array();
263 }
264
265 foreach ($behaviourdisabledtemp as $key) {
266 if (array_key_exists($key, $archetypes)) {
267 // Do not disable the current behaviour
268 if ($key != $currentbahaviour) {
269 $behaviourdisabled[$key] = $archetypes[$key];
270 }
271 }
272 }
273
274 // Get behaviours in preferred order
275 foreach ($behaviourordertemp as $key) {
276 if (array_key_exists($key, $archetypes)) {
277 $behaviourorder[$key] = $archetypes[$key];
278 }
279 }
280 // Get the rest of behaviours and sort them alphabetically
281 $leftover = array_diff_key($archetypes, $behaviourdisabled, $behaviourorder);
282 asort($leftover, SORT_LOCALE_STRING);
283
284 // Set up the final order to be displayed
285 $finalorder = $behaviourorder + $leftover;
286 return $finalorder;
287 }
288
289 /**
290 * Return an array where the keys are the internal names of the behaviours
291 * in preferred order and the values are a human-readable name.
292 *
293 * @param string $currentbahaviour
294 * @return array model name => lang string for this behaviour name.
295 */
296 public static function get_behaviour_options($currentbahaviour) {
297 global $CFG;
298 $archetypes = self::get_archetypal_behaviours();
299
300 // If no admin setting return all behavious
c76145d3 301 if (empty($CFG->questionbehavioursdisabled) && empty($CFG->questionbehavioursorder)) {
d1b7e03d
TH
302 return $archetypes;
303 }
304
305 return self::sort_behaviours($archetypes, $CFG->questionbehavioursorder,
306 $CFG->questionbehavioursdisabled, $currentbahaviour);
307 }
308
309 /**
310 * Get the translated name of an behaviour, for display in the UI.
311 * @param string $behaviour the internal name of the model.
312 * @return string name from the current language pack.
313 */
314 public static function get_behaviour_name($behaviour) {
aa9bdbe3 315 return get_string('pluginname', 'qbehaviour_' . $behaviour);
d1b7e03d
TH
316 }
317
318 /**
319 * Returns the valid choices for the number of decimal places for showing
320 * question marks. For use in the user interface.
321 * @return array suitable for passing to {@link choose_from_menu()} or similar.
322 */
323 public static function get_dp_options() {
324 return question_display_options::get_dp_options();
325 }
326
6b5f24d3
TH
327 /**
328 * Initialise the JavaScript required on pages where questions will be displayed.
329 */
d1b7e03d
TH
330 public static function initialise_js() {
331 return question_flags::initialise_js();
332 }
333}
334
335
336/**
337 * This class contains all the options that controls how a question is displayed.
338 *
339 * Normally, what will happen is that the calling code will set up some display
340 * options to indicate what sort of question display it wants, and then before the
341 * question is rendered, the behaviour will be given a chance to modify the
342 * display options, so that, for example, A question that is finished will only
343 * be shown read-only, and a question that has not been submitted will not have
344 * any sort of feedback displayed.
345 *
017bc1d9
TH
346 * @copyright 2009 The Open University
347 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
348 */
349class question_display_options {
350 /**#@+ @var integer named constants for the values that most of the options take. */
351 const HIDDEN = 0;
352 const VISIBLE = 1;
353 const EDITABLE = 2;
354 /**#@-*/
355
356 /**#@+ @var integer named constants for the {@link $marks} option. */
357 const MAX_ONLY = 1;
358 const MARK_AND_MAX = 2;
359 /**#@-*/
360
361 /**
362 * @var integer maximum value for the {@link $markpd} option. This is
363 * effectively set by the database structure, which uses NUMBER(12,7) columns
364 * for question marks/fractions.
365 */
366 const MAX_DP = 7;
367
368 /**
369 * @var boolean whether the question should be displayed as a read-only review,
370 * or in an active state where you can change the answer.
371 */
372 public $readonly = false;
373
374 /**
375 * @var boolean whether the question type should output hidden form fields
376 * to reset any incorrect parts of the resonse to blank.
377 */
378 public $clearwrong = false;
379
380 /**
381 * Should the student have what they got right and wrong clearly indicated.
382 * This includes the green/red hilighting of the bits of their response,
383 * whether the one-line summary of the current state of the question says
384 * correct/incorrect or just answered.
385 * @var integer {@link question_display_options::HIDDEN} or
386 * {@link question_display_options::VISIBLE}
387 */
388 public $correctness = self::VISIBLE;
389
390 /**
391 * The the mark and/or the maximum available mark for this question be visible?
392 * @var integer {@link question_display_options::HIDDEN},
393 * {@link question_display_options::MAX_ONLY} or {@link question_display_options::MARK_AND_MAX}
394 */
395 public $marks = self::MARK_AND_MAX;
396
397 /** @var number of decimal places to use when formatting marks for output. */
398 public $markdp = 2;
399
400 /**
401 * Should the flag this question UI element be visible, and if so, should the
402 * flag state be changable?
403 * @var integer {@link question_display_options::HIDDEN},
404 * {@link question_display_options::VISIBLE} or {@link question_display_options::EDITABLE}
405 */
406 public $flags = self::VISIBLE;
407
408 /**
409 * Should the specific feedback be visible.
410 * @var integer {@link question_display_options::HIDDEN} or
411 * {@link question_display_options::VISIBLE}
412 */
413 public $feedback = self::VISIBLE;
414
415 /**
416 * For questions with a number of sub-parts (like matching, or
417 * multiple-choice, multiple-reponse) display the number of sub-parts that
418 * were correct.
419 * @var integer {@link question_display_options::HIDDEN} or
420 * {@link question_display_options::VISIBLE}
421 */
422 public $numpartscorrect = self::VISIBLE;
423
424 /**
425 * Should the general feedback be visible?
426 * @var integer {@link question_display_options::HIDDEN} or
427 * {@link question_display_options::VISIBLE}
428 */
429 public $generalfeedback = self::VISIBLE;
430
431 /**
432 * Should the automatically generated display of what the correct answer is
433 * be visible?
434 * @var integer {@link question_display_options::HIDDEN} or
435 * {@link question_display_options::VISIBLE}
436 */
437 public $rightanswer = self::VISIBLE;
438
439 /**
440 * Should the manually added marker's comment be visible. Should the link for
441 * adding/editing the comment be there.
442 * @var integer {@link question_display_options::HIDDEN},
443 * {@link question_display_options::VISIBLE}, or {@link question_display_options::EDITABLE}.
444 * Editable means that form fields are displayed inline.
445 */
446 public $manualcomment = self::VISIBLE;
447
448 /**
449 * Should we show a 'Make comment or override grade' link?
450 * @var string base URL for the edit comment script, which will be shown if
451 * $manualcomment = self::VISIBLE.
452 */
453 public $manualcommentlink = null;
454
455 /**
456 * Used in places like the question history table, to show a link to review
457 * this question in a certain state. If blank, a link is not shown.
458 * @var string base URL for a review question script.
459 */
460 public $questionreviewlink = null;
461
462 /**
463 * Should the history of previous question states table be visible?
464 * @var integer {@link question_display_options::HIDDEN} or
465 * {@link question_display_options::VISIBLE}
466 */
467 public $history = self::HIDDEN;
468
caeeff07
TH
469 /**
470 * @var int the context the attempt being output belongs to.
471 */
8026d4aa 472 public $context;
caeeff07 473
d1b7e03d
TH
474 /**
475 * Set all the feedback-related fields {@link $feedback}, {@link generalfeedback},
476 * {@link rightanswer} and {@link manualcomment} to
477 * {@link question_display_options::HIDDEN}.
478 */
479 public function hide_all_feedback() {
480 $this->feedback = self::HIDDEN;
481 $this->numpartscorrect = self::HIDDEN;
482 $this->generalfeedback = self::HIDDEN;
483 $this->rightanswer = self::HIDDEN;
484 $this->manualcomment = self::HIDDEN;
485 $this->correctness = self::HIDDEN;
486 }
487
488 /**
489 * Returns the valid choices for the number of decimal places for showing
490 * question marks. For use in the user interface.
491 *
492 * Calling code should probably use {@link question_engine::get_dp_options()}
493 * rather than calling this method directly.
494 *
495 * @return array suitable for passing to {@link choose_from_menu()} or similar.
496 */
497 public static function get_dp_options() {
498 $options = array();
499 for ($i = 0; $i <= self::MAX_DP; $i += 1) {
500 $options[$i] = $i;
501 }
502 return $options;
503 }
504}
505
506
507/**
508 * Contains the logic for handling question flags.
509 *
f7970e3c 510 * @copyright 2010 The Open University
017bc1d9 511 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
512 */
513abstract class question_flags {
514 /**
515 * Get the checksum that validates that a toggle request is valid.
f7970e3c
TH
516 * @param int $qubaid the question usage id.
517 * @param int $questionid the question id.
518 * @param int $sessionid the question_attempt id.
d1b7e03d
TH
519 * @param object $user the user. If null, defaults to $USER.
520 * @return string that needs to be sent to question/toggleflag.php for it to work.
521 */
06f8ed54 522 protected static function get_toggle_checksum($qubaid, $questionid, $qaid, $slot, $user = null) {
d1b7e03d
TH
523 if (is_null($user)) {
524 global $USER;
525 $user = $USER;
526 }
06f8ed54 527 return md5($qubaid . "_" . $user->secret . "_" . $questionid . "_" . $qaid . "_" . $slot);
d1b7e03d
TH
528 }
529
530 /**
531 * Get the postdata that needs to be sent to question/toggleflag.php to change the flag state.
532 * You need to append &newstate=0/1 to this.
533 * @return the post data to send.
534 */
535 public static function get_postdata(question_attempt $qa) {
536 $qaid = $qa->get_database_id();
537 $qubaid = $qa->get_usage_id();
538 $qid = $qa->get_question()->id;
06f8ed54
TH
539 $slot = $qa->get_slot();
540 $checksum = self::get_toggle_checksum($qubaid, $qid, $qaid, $slot);
404da238
TH
541 return "qaid=$qaid&qubaid=$qubaid&qid=$qid&slot=$slot&checksum=$checksum&sesskey=" .
542 sesskey() . '&newstate=';
d1b7e03d
TH
543 }
544
545 /**
546 * If the request seems valid, update the flag state of a question attempt.
547 * Throws exceptions if this is not a valid update request.
f7970e3c
TH
548 * @param int $qubaid the question usage id.
549 * @param int $questionid the question id.
550 * @param int $sessionid the question_attempt id.
d1b7e03d
TH
551 * @param string $checksum checksum, as computed by {@link get_toggle_checksum()}
552 * corresponding to the last three arguments.
f7970e3c 553 * @param bool $newstate the new state of the flag. true = flagged.
d1b7e03d 554 */
06f8ed54 555 public static function update_flag($qubaid, $questionid, $qaid, $slot, $checksum, $newstate) {
d1b7e03d 556 // Check the checksum - it is very hard to know who a question session belongs
f9b0500f 557 // to, so we require that checksum parameter is matches an md5 hash of the
d1b7e03d
TH
558 // three ids and the users username. Since we are only updating a flag, that
559 // probably makes it sufficiently difficult for malicious users to toggle
560 // other users flags.
06f8ed54 561 if ($checksum != question_flags::get_toggle_checksum($qubaid, $questionid, $qaid, $slot)) {
88f0eb15 562 throw new moodle_exception('errorsavingflags', 'question');
d1b7e03d
TH
563 }
564
565 $dm = new question_engine_data_mapper();
06f8ed54 566 $dm->update_question_attempt_flag($qubaid, $questionid, $qaid, $slot, $newstate);
d1b7e03d
TH
567 }
568
569 public static function initialise_js() {
f29aeb5a
TH
570 global $CFG, $PAGE, $OUTPUT;
571 static $done = false;
572 if ($done) {
573 return;
574 }
575 $module = array(
576 'name' => 'core_question_flags',
577 'fullpath' => '/question/flags.js',
578 'requires' => array('base', 'dom', 'event-delegate', 'io-base'),
579 );
580 $actionurl = $CFG->wwwroot . '/question/toggleflag.php';
581 $flagattributes = array(
582 0 => array(
583 'src' => $OUTPUT->pix_url('i/unflagged') . '',
584 'title' => get_string('clicktoflag', 'question'),
585 'alt' => get_string('notflagged', 'question'),
586 ),
587 1 => array(
588 'src' => $OUTPUT->pix_url('i/flagged') . '',
589 'title' => get_string('clicktounflag', 'question'),
590 'alt' => get_string('flagged', 'question'),
591 ),
d1b7e03d 592 );
f29aeb5a
TH
593 $PAGE->requires->js_init_call('M.core_question_flags.init',
594 array($actionurl, $flagattributes), false, $module);
595 $done = true;
d1b7e03d
TH
596 }
597}
598
599
f7970e3c
TH
600/**
601 * Exception thrown when the system detects that a student has done something
602 * out-of-order to a question. This can happen, for example, if they click
5e8a85aa 603 * the browser's back button in a quiz, then try to submit a different response.
f7970e3c
TH
604 *
605 * @copyright 2010 The Open University
606 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
607 */
d1b7e03d 608class question_out_of_sequence_exception extends moodle_exception {
c7df5006 609 public function __construct($qubaid, $slot, $postdata) {
d1b7e03d
TH
610 if ($postdata == null) {
611 $postdata = data_submitted();
612 }
613 parent::__construct('submissionoutofsequence', 'question', '', null,
614 "QUBAid: $qubaid, slot: $slot, post data: " . print_r($postdata, true));
615 }
616}
617
618
619/**
620 * This class keeps track of a group of questions that are being attempted,
621 * and which state, and so on, each one is currently in.
622 *
623 * A quiz attempt or a lesson attempt could use an instance of this class to
624 * keep track of all the questions in the attempt and process student submissions.
625 * It is basically a collection of {@question_attempt} objects.
626 *
627 * The questions being attempted as part of this usage are identified by an integer
628 * that is passed into many of the methods as $slot. ($question->id is not
629 * used so that the same question can be used more than once in an attempt.)
630 *
631 * Normally, calling code should be able to do everything it needs to be calling
632 * methods of this class. You should not normally need to get individual
633 * {@question_attempt} objects and play around with their inner workind, in code
634 * that it outside the quetsion engine.
635 *
636 * Instances of this class correspond to rows in the question_usages table.
637 *
017bc1d9
TH
638 * @copyright 2009 The Open University
639 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
640 */
641class question_usage_by_activity {
642 /**
643 * @var integer|string the id for this usage. If this usage was loaded from
644 * the database, then this is the database id. Otherwise a unique random
645 * string is used.
646 */
647 protected $id = null;
648
649 /**
650 * @var string name of an archetypal behaviour, that should be used
651 * by questions in this usage if possible.
652 */
653 protected $preferredbehaviour = null;
654
655 /** @var object the context this usage belongs to. */
656 protected $context;
657
658 /** @var string plugin name of the plugin this usage belongs to. */
659 protected $owningcomponent;
660
661 /** @var array {@link question_attempt}s that make up this usage. */
662 protected $questionattempts = array();
663
664 /** @var question_usage_observer that tracks changes to this usage. */
665 protected $observer;
666
667 /**
668 * Create a new instance. Normally, calling code should use
669 * {@link question_engine::make_questions_usage_by_activity()} or
670 * {@link question_engine::load_questions_usage_by_activity()} rather than
671 * calling this constructor directly.
672 *
673 * @param string $component the plugin creating this attempt. For example mod_quiz.
674 * @param object $context the context this usage belongs to.
675 */
676 public function __construct($component, $context) {
677 $this->owningcomponent = $component;
678 $this->context = $context;
679 $this->observer = new question_usage_null_observer();
680 }
681
682 /**
683 * @param string $behaviour the name of an archetypal behaviour, that should
684 * be used by questions in this usage if possible.
685 */
686 public function set_preferred_behaviour($behaviour) {
687 $this->preferredbehaviour = $behaviour;
688 $this->observer->notify_modified();
689 }
690
691 /** @return string the name of the preferred behaviour. */
692 public function get_preferred_behaviour() {
693 return $this->preferredbehaviour;
694 }
695
0ff4bd08 696 /** @return object the context this usage belongs to. */
d1b7e03d
TH
697 public function get_owning_context() {
698 return $this->context;
699 }
700
701 /** @return string the name of the plugin that owns this attempt. */
702 public function get_owning_component() {
703 return $this->owningcomponent;
704 }
705
f7970e3c 706 /** @return int|string If this usage came from the database, then the id
d1b7e03d
TH
707 * from the question_usages table is returned. Otherwise a random string is
708 * returned. */
709 public function get_id() {
710 if (is_null($this->id)) {
711 $this->id = random_string(10);
712 }
713 return $this->id;
714 }
715
716 /** @return question_usage_observer that is tracking changes made to this usage. */
717 public function get_observer() {
718 return $this->observer;
719 }
720
721 /**
722 * For internal use only. Used by {@link question_engine_data_mapper} to set
723 * the id when a usage is saved to the database.
f7970e3c 724 * @param int $id the newly determined id for this usage.
d1b7e03d
TH
725 */
726 public function set_id_from_database($id) {
727 $this->id = $id;
728 foreach ($this->questionattempts as $qa) {
729 $qa->set_usage_id($id);
730 }
731 }
732
733 /**
734 * Add another question to this usage.
735 *
736 * The added question is not started until you call {@link start_question()}
737 * on it.
738 *
739 * @param question_definition $question the question to add.
740 * @param number $maxmark the maximum this question will be marked out of in
741 * this attempt (optional). If not given, $question->defaultmark is used.
f7970e3c 742 * @return int the number used to identify this question within this usage.
d1b7e03d
TH
743 */
744 public function add_question(question_definition $question, $maxmark = null) {
2daffca5 745 $qa = new question_attempt($question, $this->get_id(), $this->observer, $maxmark);
d1b7e03d
TH
746 if (count($this->questionattempts) == 0) {
747 $this->questionattempts[1] = $qa;
748 } else {
749 $this->questionattempts[] = $qa;
750 }
751 $qa->set_number_in_usage(end(array_keys($this->questionattempts)));
752 $this->observer->notify_attempt_added($qa);
753 return $qa->get_slot();
754 }
755
756 /**
757 * Get the question_definition for a question in this attempt.
f7970e3c 758 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
759 * @return question_definition the requested question object.
760 */
761 public function get_question($slot) {
762 return $this->get_question_attempt($slot)->get_question();
763 }
764
765 /** @return array all the identifying numbers of all the questions in this usage. */
a1eb3a44 766 public function get_slots() {
d1b7e03d
TH
767 return array_keys($this->questionattempts);
768 }
769
f7970e3c 770 /** @return int the identifying number of the first question that was added to this usage. */
d1b7e03d
TH
771 public function get_first_question_number() {
772 reset($this->questionattempts);
773 return key($this->questionattempts);
774 }
775
f7970e3c 776 /** @return int the number of questions that are currently in this usage. */
d1b7e03d
TH
777 public function question_count() {
778 return count($this->questionattempts);
779 }
780
781 /**
782 * Note the part of the {@link question_usage_by_activity} comment that explains
783 * that {@link question_attempt} objects should be considered part of the inner
784 * workings of the question engine, and should not, if possible, be accessed directly.
785 *
786 * @return question_attempt_iterator for iterating over all the questions being
787 * attempted. as part of this usage.
788 */
789 public function get_attempt_iterator() {
790 return new question_attempt_iterator($this);
791 }
792
793 /**
794 * Check whether $number actually corresponds to a question attempt that is
795 * part of this usage. Throws an exception if not.
796 *
f7970e3c 797 * @param int $slot a number allegedly identifying a question within this usage.
d1b7e03d
TH
798 */
799 protected function check_slot($slot) {
800 if (!array_key_exists($slot, $this->questionattempts)) {
88f0eb15 801 throw new coding_exception("There is no question_attempt number $slot in this attempt.");
d1b7e03d
TH
802 }
803 }
804
805 /**
806 * Note the part of the {@link question_usage_by_activity} comment that explains
807 * that {@link question_attempt} objects should be considered part of the inner
808 * workings of the question engine, and should not, if possible, be accessed directly.
809 *
f7970e3c 810 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
811 * @return question_attempt the corresponding {@link question_attempt} object.
812 */
813 public function get_question_attempt($slot) {
814 $this->check_slot($slot);
815 return $this->questionattempts[$slot];
816 }
817
818 /**
819 * Get the current state of the attempt at a question.
f7970e3c 820 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
821 * @return question_state.
822 */
823 public function get_question_state($slot) {
824 return $this->get_question_attempt($slot)->get_state();
825 }
826
827 /**
f7970e3c
TH
828 * @param int $slot the number used to identify this question within this usage.
829 * @param bool $showcorrectness Whether right/partial/wrong states should
d1b7e03d
TH
830 * be distinguised.
831 * @return string A brief textual description of the current state.
832 */
833 public function get_question_state_string($slot, $showcorrectness) {
834 return $this->get_question_attempt($slot)->get_state_string($showcorrectness);
835 }
836
837 /**
838 * Get the time of the most recent action performed on a question.
f7970e3c
TH
839 * @param int $slot the number used to identify this question within this usage.
840 * @return int timestamp.
d1b7e03d
TH
841 */
842 public function get_question_action_time($slot) {
843 return $this->get_question_attempt($slot)->get_last_action_time();
844 }
845
846 /**
847 * Get the current fraction awarded for the attempt at a question.
f7970e3c 848 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
849 * @return number|null The current fraction for this question, or null if one has
850 * not been assigned yet.
851 */
852 public function get_question_fraction($slot) {
853 return $this->get_question_attempt($slot)->get_fraction();
854 }
855
856 /**
857 * Get the current mark awarded for the attempt at a question.
f7970e3c 858 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
859 * @return number|null The current mark for this question, or null if one has
860 * not been assigned yet.
861 */
862 public function get_question_mark($slot) {
863 return $this->get_question_attempt($slot)->get_mark();
864 }
865
866 /**
867 * Get the maximum mark possible for the attempt at a question.
f7970e3c 868 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
869 * @return number the available marks for this question.
870 */
871 public function get_question_max_mark($slot) {
872 return $this->get_question_attempt($slot)->get_max_mark();
873 }
874
875 /**
876 * Get the current mark awarded for the attempt at a question.
f7970e3c 877 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
878 * @return number|null The current mark for this question, or null if one has
879 * not been assigned yet.
880 */
881 public function get_total_mark() {
882 $mark = 0;
883 foreach ($this->questionattempts as $qa) {
f86390dc 884 if ($qa->get_max_mark() > 0 && $qa->get_state() == question_state::$needsgrading) {
d1b7e03d
TH
885 return null;
886 }
887 $mark += $qa->get_mark();
888 }
889 return $mark;
890 }
891
892 /**
893 * @return string a simple textual summary of the question that was asked.
894 */
895 public function get_question_summary($slot) {
896 return $this->get_question_attempt($slot)->get_question_summary();
897 }
898
899 /**
900 * @return string a simple textual summary of response given.
901 */
902 public function get_response_summary($slot) {
903 return $this->get_question_attempt($slot)->get_response_summary();
904 }
905
906 /**
907 * @return string a simple textual summary of the correct resonse.
908 */
909 public function get_right_answer_summary($slot) {
910 return $this->get_question_attempt($slot)->get_right_answer_summary();
911 }
912
913 /**
914 * Get the {@link core_question_renderer}, in collaboration with appropriate
915 * {@link qbehaviour_renderer} and {@link qtype_renderer} subclasses, to generate the
916 * HTML to display this question.
f7970e3c 917 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
918 * @param question_display_options $options controls how the question is rendered.
919 * @param string|null $number The question number to display. 'i' is a special
920 * value that gets displayed as Information. Null means no number is displayed.
921 * @return string HTML fragment representing the question.
922 */
923 public function render_question($slot, $options, $number = null) {
8026d4aa 924 $options->context = $this->context;
d1b7e03d
TH
925 return $this->get_question_attempt($slot)->render($options, $number);
926 }
927
928 /**
929 * Generate any bits of HTML that needs to go in the <head> tag when this question
930 * is displayed in the body.
f7970e3c 931 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
932 * @return string HTML fragment.
933 */
934 public function render_question_head_html($slot) {
8026d4aa 935 $options->context = $this->context;
d1b7e03d
TH
936 return $this->get_question_attempt($slot)->render_head_html();
937 }
938
939 /**
940 * Like {@link render_question()} but displays the question at the past step
941 * indicated by $seq, rather than showing the latest step.
942 *
f7970e3c
TH
943 * @param int $slot the number used to identify this question within this usage.
944 * @param int $seq the seq number of the past state to display.
d1b7e03d
TH
945 * @param question_display_options $options controls how the question is rendered.
946 * @param string|null $number The question number to display. 'i' is a special
947 * value that gets displayed as Information. Null means no number is displayed.
948 * @return string HTML fragment representing the question.
949 */
950 public function render_question_at_step($slot, $seq, $options, $number = null) {
8026d4aa 951 $options->context = $this->context;
d1b7e03d
TH
952 return $this->get_question_attempt($slot)->render_at_step($seq, $options, $number, $this->preferredbehaviour);
953 }
954
7a719748
TH
955 /**
956 * Checks whether the users is allow to be served a particular file.
f7970e3c 957 * @param int $slot the number used to identify this question within this usage.
7a719748
TH
958 * @param question_display_options $options the options that control display of the question.
959 * @param string $component the name of the component we are serving files for.
960 * @param string $filearea the name of the file area.
961 * @param array $args the remaining bits of the file path.
f7970e3c
TH
962 * @param bool $forcedownload whether the user must be forced to download the file.
963 * @return bool true if the user can access this file.
7a719748
TH
964 */
965 public function check_file_access($slot, $options, $component, $filearea, $args, $forcedownload) {
966 return $this->get_question_attempt($slot)->check_file_access($options, $component, $filearea, $args, $forcedownload);
967 }
968
d1b7e03d
TH
969 /**
970 * Replace a particular question_attempt with a different one.
971 *
972 * For internal use only. Used when reloading the state of a question from the
973 * database.
974 *
975 * @param array $records Raw records loaded from the database.
f7970e3c 976 * @param int $questionattemptid The id of the question_attempt to extract.
d1b7e03d
TH
977 * @return question_attempt The newly constructed question_attempt_step.
978 */
979 public function replace_loaded_question_attempt_info($slot, $qa) {
980 $this->check_slot($slot);
981 $this->questionattempts[$slot] = $qa;
982 }
983
984 /**
985 * You should probably not use this method in code outside the question engine.
986 * The main reason for exposing it was for the benefit of unit tests.
f7970e3c 987 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
988 * @return string return the prefix that is pre-pended to field names in the HTML
989 * that is output.
990 */
991 public function get_field_prefix($slot) {
992 return $this->get_question_attempt($slot)->get_field_prefix();
993 }
994
995 /**
996 * Start the attempt at a question that has been added to this usage.
f7970e3c 997 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
998 */
999 public function start_question($slot) {
1000 $qa = $this->get_question_attempt($slot);
1001 $qa->start($this->preferredbehaviour);
1002 $this->observer->notify_attempt_modified($qa);
1003 }
1004
1005 /**
1006 * Start the attempt at all questions that has been added to this usage.
1007 */
1008 public function start_all_questions() {
1009 foreach ($this->questionattempts as $qa) {
1010 $qa->start($this->preferredbehaviour);
1011 $this->observer->notify_attempt_modified($qa);
1012 }
1013 }
1014
1015 /**
1016 * Start the attempt at a question, starting from the point where the previous
1017 * question_attempt $oldqa had reached. This is used by the quiz 'Each attempt
1018 * builds on last' mode.
f7970e3c 1019 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
1020 * @param question_attempt $oldqa a previous attempt at this quetsion that
1021 * defines the starting point.
1022 */
1023 public function start_question_based_on($slot, question_attempt $oldqa) {
1024 $qa = $this->get_question_attempt($slot);
1025 $qa->start_based_on($oldqa);
1026 $this->observer->notify_attempt_modified($qa);
1027 }
1028
1029 /**
1030 * Process all the question actions in the current request.
1031 *
1032 * If there is a parameter slots included in the post data, then only
1033 * those question numbers will be processed, otherwise all questions in this
1034 * useage will be.
1035 *
1036 * This function also does {@link update_question_flags()}.
1037 *
f7970e3c 1038 * @param int $timestamp optional, use this timestamp as 'now'.
d1b7e03d
TH
1039 * @param array $postdata optional, only intended for testing. Use this data
1040 * instead of the data from $_POST.
1041 */
1042 public function process_all_actions($timestamp = null, $postdata = null) {
1043 $slots = question_attempt::get_submitted_var('slots', PARAM_SEQUENCE, $postdata);
1044 if (is_null($slots)) {
a1eb3a44 1045 $slots = $this->get_slots();
d1b7e03d
TH
1046 } else if (!$slots) {
1047 $slots = array();
1048 } else {
1049 $slots = explode(',', $slots);
1050 }
1051 foreach ($slots as $slot) {
92d04f0b
TH
1052 if (!$this->validate_sequence_number($slot, $postdata)) {
1053 continue;
1054 }
d1b7e03d
TH
1055 $submitteddata = $this->extract_responses($slot, $postdata);
1056 $this->process_action($slot, $submitteddata, $timestamp);
1057 }
1058 $this->update_question_flags($postdata);
1059 }
1060
1061 /**
1062 * Get the submitted data from the current request that belongs to this
1063 * particular question.
1064 *
f7970e3c 1065 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
1066 * @param $postdata optional, only intended for testing. Use this data
1067 * instead of the data from $_POST.
1068 * @return array submitted data specific to this question.
1069 */
1070 public function extract_responses($slot, $postdata = null) {
1071 return $this->get_question_attempt($slot)->get_submitted_data($postdata);
1072 }
1073
1074 /**
1075 * Process a specific action on a specific question.
f7970e3c 1076 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
1077 * @param $submitteddata the submitted data that constitutes the action.
1078 */
1079 public function process_action($slot, $submitteddata, $timestamp = null) {
1080 $qa = $this->get_question_attempt($slot);
1081 $qa->process_action($submitteddata, $timestamp);
1082 $this->observer->notify_attempt_modified($qa);
1083 }
1084
1085 /**
1086 * Check that the sequence number, that detects weird things like the student
92d04f0b
TH
1087 * clicking back, is OK. If the sequence check variable is not present, returns
1088 * false. If the check variable is present and correct, returns true. If the
1089 * variable is present and wrong, throws an exception.
f7970e3c 1090 * @param int $slot the number used to identify this question within this usage.
d1b7e03d 1091 * @param array $submitteddata the submitted data that constitutes the action.
92d04f0b
TH
1092 * @return bool true if the check variable is present and correct. False if it
1093 * is missing. (Throws an exception if the check fails.)
d1b7e03d
TH
1094 */
1095 public function validate_sequence_number($slot, $postdata = null) {
1096 $qa = $this->get_question_attempt($slot);
217f9a61 1097 $sequencecheck = $qa->get_submitted_var(
d1b7e03d 1098 $qa->get_control_field_name('sequencecheck'), PARAM_INT, $postdata);
92d04f0b
TH
1099 if (is_null($sequencecheck)) {
1100 return false;
1101 } else if ($sequencecheck != $qa->get_num_steps()) {
d1b7e03d 1102 throw new question_out_of_sequence_exception($this->id, $slot, $postdata);
92d04f0b
TH
1103 } else {
1104 return true;
d1b7e03d
TH
1105 }
1106 }
1107 /**
1108 * Update the flagged state for all question_attempts in this usage, if their
1109 * flagged state was changed in the request.
1110 *
1111 * @param $postdata optional, only intended for testing. Use this data
1112 * instead of the data from $_POST.
1113 */
1114 public function update_question_flags($postdata = null) {
1115 foreach ($this->questionattempts as $qa) {
217f9a61 1116 $flagged = $qa->get_submitted_var(
d1b7e03d
TH
1117 $qa->get_flag_field_name(), PARAM_BOOL, $postdata);
1118 if (!is_null($flagged) && $flagged != $qa->is_flagged()) {
1119 $qa->set_flagged($flagged);
1120 }
1121 }
d1b7e03d
TH
1122 }
1123
1124 /**
1125 * Get the correct response to a particular question. Passing the results of
1126 * this method to {@link process_action()} will probably result in full marks.
f7970e3c 1127 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
1128 * @return array that constitutes a correct response to this question.
1129 */
1130 public function get_correct_response($slot) {
1131 return $this->get_question_attempt($slot)->get_correct_response();
1132 }
1133
1134 /**
1135 * Finish the active phase of an attempt at a question.
1136 *
1137 * This is an external act of finishing the attempt. Think, for example, of
1138 * the 'Submit all and finish' button in the quiz. Some behaviours,
1139 * (for example, immediatefeedback) give a way of finishing the active phase
1140 * of a question attempt as part of a {@link process_action()} call.
1141 *
1142 * After the active phase is over, the only changes possible are things like
1143 * manual grading, or changing the flag state.
1144 *
f7970e3c 1145 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
1146 */
1147 public function finish_question($slot, $timestamp = null) {
1148 $qa = $this->get_question_attempt($slot);
1149 $qa->finish($timestamp);
1150 $this->observer->notify_attempt_modified($qa);
1151 }
1152
1153 /**
1154 * Finish the active phase of an attempt at a question. See {@link finish_question()}
1155 * for a fuller description of what 'finish' means.
1156 */
1157 public function finish_all_questions($timestamp = null) {
1158 foreach ($this->questionattempts as $qa) {
1159 $qa->finish($timestamp);
1160 $this->observer->notify_attempt_modified($qa);
1161 }
1162 }
1163
1164 /**
1165 * Perform a manual grading action on a question attempt.
f7970e3c 1166 * @param int $slot the number used to identify this question within this usage.
d1b7e03d
TH
1167 * @param string $comment the comment being added to the question attempt.
1168 * @param number $mark the mark that is being assigned. Can be null to just
1169 * add a comment.
1170 */
1171 public function manual_grade($slot, $comment, $mark) {
1172 $qa = $this->get_question_attempt($slot);
1173 $qa->manual_grade($comment, $mark);
1174 $this->observer->notify_attempt_modified($qa);
1175 }
1176
1177 /**
1178 * Regrade a question in this usage. This replays the sequence of submitted
1179 * actions to recompute the outcomes.
f7970e3c
TH
1180 * @param int $slot the number used to identify this question within this usage.
1181 * @param bool $finished whether the question attempt should be forced to be finished
d1b7e03d
TH
1182 * after the regrade, or whether it may still be in progress (default false).
1183 * @param number $newmaxmark (optional) if given, will change the max mark while regrading.
1184 */
1185 public function regrade_question($slot, $finished = false, $newmaxmark = null) {
1186 $oldqa = $this->get_question_attempt($slot);
1187 if (is_null($newmaxmark)) {
1188 $newmaxmark = $oldqa->get_max_mark();
1189 }
1190
1191 $this->observer->notify_delete_attempt_steps($oldqa);
1192
1193 $newqa = new question_attempt($oldqa->get_question(), $oldqa->get_usage_id(),
2daffca5 1194 $this->observer, $newmaxmark);
d1b7e03d
TH
1195 $newqa->set_database_id($oldqa->get_database_id());
1196 $newqa->regrade($oldqa, $finished);
1197
1198 $this->questionattempts[$slot] = $newqa;
1199 $this->observer->notify_attempt_modified($newqa);
1200 }
1201
1202 /**
1203 * Regrade all the questions in this usage (without changing their max mark).
f7970e3c 1204 * @param bool $finished whether each question should be forced to be finished
d1b7e03d
TH
1205 * after the regrade, or whether it may still be in progress (default false).
1206 */
1207 public function regrade_all_questions($finished = false) {
1208 foreach ($this->questionattempts as $slot => $notused) {
1209 $this->regrade_question($slot, $finished);
1210 }
1211 }
1212
1213 /**
1214 * Create a question_usage_by_activity from records loaded from the database.
1215 *
1216 * For internal use only.
1217 *
1218 * @param array $records Raw records loaded from the database.
f7970e3c 1219 * @param int $questionattemptid The id of the question_attempt to extract.
d1b7e03d
TH
1220 * @return question_attempt The newly constructed question_attempt_step.
1221 */
1222 public static function load_from_records(&$records, $qubaid) {
1223 $record = current($records);
1224 while ($record->qubaid != $qubaid) {
1225 $record = next($records);
1226 if (!$record) {
88f0eb15 1227 throw new coding_exception("Question usage $qubaid not found in the database.");
d1b7e03d
TH
1228 }
1229 }
1230
1231 $quba = new question_usage_by_activity($record->component,
1232 get_context_instance_by_id($record->contextid));
1233 $quba->set_id_from_database($record->qubaid);
1234 $quba->set_preferred_behaviour($record->preferredbehaviour);
1235
1236 $quba->observer = new question_engine_unit_of_work($quba);
1237
1238 while ($record && $record->qubaid == $qubaid && !is_null($record->slot)) {
1239 $quba->questionattempts[$record->slot] =
1240 question_attempt::load_from_records($records,
1241 $record->questionattemptid, $quba->observer,
1242 $quba->get_preferred_behaviour());
1243 $record = current($records);
1244 }
1245
1246 return $quba;
1247 }
1248}
1249
1250
1251/**
1252 * A class abstracting access to the
1253 * {@link question_usage_by_activity::$questionattempts} array.
1254 *
1255 * This class snapshots the list of {@link question_attempts} to iterate over
1256 * when it is created. If a question is added to the usage mid-iteration, it
1257 * will now show up.
1258 *
1259 * To create an instance of this class, use
1260 * {@link question_usage_by_activity::get_attempt_iterator()}
1261 *
017bc1d9
TH
1262 * @copyright 2009 The Open University
1263 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1264 */
1265class question_attempt_iterator implements Iterator, ArrayAccess {
1266 /** @var question_usage_by_activity that we are iterating over. */
1267 protected $quba;
1268 /** @var array of question numbers. */
1269 protected $slots;
1270
1271 /**
1272 * To create an instance of this class, use {@link question_usage_by_activity::get_attempt_iterator()}.
1273 * @param $quba the usage to iterate over.
1274 */
1275 public function __construct(question_usage_by_activity $quba) {
1276 $this->quba = $quba;
a1eb3a44 1277 $this->slots = $quba->get_slots();
d1b7e03d
TH
1278 $this->rewind();
1279 }
1280
1281 /** @return question_attempt_step */
1282 public function current() {
1283 return $this->offsetGet(current($this->slots));
1284 }
f7970e3c 1285 /** @return int */
d1b7e03d
TH
1286 public function key() {
1287 return current($this->slots);
1288 }
1289 public function next() {
1290 next($this->slots);
1291 }
1292 public function rewind() {
1293 reset($this->slots);
1294 }
f7970e3c 1295 /** @return bool */
d1b7e03d
TH
1296 public function valid() {
1297 return current($this->slots) !== false;
1298 }
1299
f7970e3c 1300 /** @return bool */
d1b7e03d
TH
1301 public function offsetExists($slot) {
1302 return in_array($slot, $this->slots);
1303 }
1304 /** @return question_attempt_step */
1305 public function offsetGet($slot) {
1306 return $this->quba->get_question_attempt($slot);
1307 }
1308 public function offsetSet($slot, $value) {
88f0eb15 1309 throw new coding_exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot set.');
d1b7e03d
TH
1310 }
1311 public function offsetUnset($slot) {
88f0eb15 1312 throw new coding_exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot unset.');
d1b7e03d
TH
1313 }
1314}
1315
1316
1317/**
1318 * Tracks an attempt at one particular question in a {@link question_usage_by_activity}.
1319 *
1320 * Most calling code should need to access objects of this class. They should be
1321 * able to do everything through the usage interface. This class is an internal
1322 * implementation detail of the question engine.
1323 *
1324 * Instances of this class correspond to rows in the question_attempts table, and
1325 * a collection of {@link question_attempt_steps}. Question inteaction models and
1326 * question types do work with question_attempt objects.
1327 *
017bc1d9
TH
1328 * @copyright 2009 The Open University
1329 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1330 */
1331class question_attempt {
1332 /**
1333 * @var string this is a magic value that question types can return from
1334 * {@link question_definition::get_expected_data()}.
1335 */
1336 const USE_RAW_DATA = 'use raw data';
1337
1338 /**
1339 * @var string special value used by manual grading because {@link PARAM_NUMBER}
1340 * converts '' to 0.
1341 */
1342 const PARAM_MARK = 'parammark';
1343
caeeff07 1344 /**
217f9a61
TH
1345 * @var string special value to indicate a response variable that is uploaded
1346 * files.
caeeff07
TH
1347 */
1348 const PARAM_FILES = 'paramfiles';
1349
48d9c17d
TH
1350 /**
1351 * @var string special value to indicate a response variable that is uploaded
1352 * files.
1353 */
1354 const PARAM_CLEANHTML_FILES = 'paramcleanhtmlfiles';
1355
d1b7e03d
TH
1356 /** @var integer if this attempts is stored in the question_attempts table, the id of that row. */
1357 protected $id = null;
1358
1359 /** @var integer|string the id of the question_usage_by_activity we belong to. */
1360 protected $usageid;
1361
1362 /** @var integer the number used to identify this question_attempt within the usage. */
1363 protected $slot = null;
1364
1365 /**
1366 * @var question_behaviour the behaviour controlling this attempt.
1367 * null until {@link start()} is called.
1368 */
1369 protected $behaviour = null;
1370
1371 /** @var question_definition the question this is an attempt at. */
1372 protected $question;
1373
1374 /** @var number the maximum mark that can be scored at this question. */
1375 protected $maxmark;
1376
1377 /**
1378 * @var number the minimum fraction that can be scored at this question, so
1379 * the minimum mark is $this->minfraction * $this->maxmark.
1380 */
1381 protected $minfraction = null;
1382
1383 /**
1384 * @var string plain text summary of the variant of the question the
1385 * student saw. Intended for reporting purposes.
1386 */
1387 protected $questionsummary = null;
1388
1389 /**
1390 * @var string plain text summary of the response the student gave.
1391 * Intended for reporting purposes.
1392 */
1393 protected $responsesummary = null;
1394
1395 /**
1396 * @var string plain text summary of the correct response to this question
1397 * variant the student saw. The format should be similar to responsesummary.
1398 * Intended for reporting purposes.
1399 */
1400 protected $rightanswer = null;
1401
1402 /** @var array of {@link question_attempt_step}s. The steps in this attempt. */
1403 protected $steps = array();
1404
1405 /** @var boolean whether the user has flagged this attempt within the usage. */
1406 protected $flagged = false;
1407
1408 /** @var question_usage_observer tracks changes to the useage this attempt is part of.*/
1409 protected $observer;
1410
1411 /**#@+
1412 * Constants used by the intereaction models to indicate whether the current
1413 * pending step should be kept or discarded.
1414 */
1415 const KEEP = true;
1416 const DISCARD = false;
1417 /**#@-*/
1418
1419 /**
1420 * Create a new {@link question_attempt}. Normally you should create question_attempts
1421 * indirectly, by calling {@link question_usage_by_activity::add_question()}.
1422 *
1423 * @param question_definition $question the question this is an attempt at.
f7970e3c 1424 * @param int|string $usageid The id of the
d1b7e03d
TH
1425 * {@link question_usage_by_activity} we belong to. Used by {@link get_field_prefix()}.
1426 * @param question_usage_observer $observer tracks changes to the useage this
1427 * attempt is part of. (Optional, a {@link question_usage_null_observer} is
1428 * used if one is not passed.
1429 * @param number $maxmark the maximum grade for this question_attempt. If not
1430 * passed, $question->defaultmark is used.
1431 */
2daffca5 1432 public function __construct(question_definition $question, $usageid,
d1b7e03d
TH
1433 question_usage_observer $observer = null, $maxmark = null) {
1434 $this->question = $question;
1435 $this->usageid = $usageid;
1436 if (is_null($observer)) {
1437 $observer = new question_usage_null_observer();
1438 }
1439 $this->observer = $observer;
1440 if (!is_null($maxmark)) {
1441 $this->maxmark = $maxmark;
1442 } else {
1443 $this->maxmark = $question->defaultmark;
1444 }
1445 }
1446
1447 /** @return question_definition the question this is an attempt at. */
1448 public function get_question() {
1449 return $this->question;
1450 }
1451
1452 /**
1453 * Set the number used to identify this question_attempt within the usage.
1454 * For internal use only.
f7970e3c 1455 * @param int $slot
d1b7e03d
TH
1456 */
1457 public function set_number_in_usage($slot) {
1458 $this->slot = $slot;
1459 }
1460
f7970e3c 1461 /** @return int the number used to identify this question_attempt within the usage. */
d1b7e03d
TH
1462 public function get_slot() {
1463 return $this->slot;
1464 }
1465
1466 /**
f7970e3c 1467 * @return int the id of row for this question_attempt, if it is stored in the
d1b7e03d
TH
1468 * database. null if not.
1469 */
1470 public function get_database_id() {
1471 return $this->id;
1472 }
1473
1474 /**
1475 * For internal use only. Set the id of the corresponding database row.
f7970e3c 1476 * @param int $id the id of row for this question_attempt, if it is
d1b7e03d
TH
1477 * stored in the database.
1478 */
1479 public function set_database_id($id) {
1480 $this->id = $id;
1481 }
1482
f7970e3c 1483 /** @return int|string the id of the {@link question_usage_by_activity} we belong to. */
d1b7e03d
TH
1484 public function get_usage_id() {
1485 return $this->usageid;
1486 }
1487
1488 /**
1489 * Set the id of the {@link question_usage_by_activity} we belong to.
1490 * For internal use only.
f7970e3c 1491 * @param int|string the new id.
d1b7e03d
TH
1492 */
1493 public function set_usage_id($usageid) {
1494 $this->usageid = $usageid;
1495 }
1496
1497 /** @return string the name of the behaviour that is controlling this attempt. */
1498 public function get_behaviour_name() {
1499 return $this->behaviour->get_name();
1500 }
1501
1502 /**
1503 * For internal use only.
1504 * @return question_behaviour the behaviour that is controlling this attempt.
1505 */
1506 public function get_behaviour() {
1507 return $this->behaviour;
1508 }
1509
1510 /**
1511 * Set the flagged state of this question.
f7970e3c 1512 * @param bool $flagged the new state.
d1b7e03d
TH
1513 */
1514 public function set_flagged($flagged) {
1515 $this->flagged = $flagged;
1516 $this->observer->notify_attempt_modified($this);
1517 }
1518
f7970e3c 1519 /** @return bool whether this question is currently flagged. */
d1b7e03d
TH
1520 public function is_flagged() {
1521 return $this->flagged;
1522 }
1523
1524 /**
1525 * Get the name (in the sense a HTML name="" attribute, or a $_POST variable
1526 * name) to use for the field that indicates whether this question is flagged.
1527 *
1528 * @return string The field name to use.
1529 */
1530 public function get_flag_field_name() {
1531 return $this->get_control_field_name('flagged');
1532 }
1533
1534 /**
1535 * Get the name (in the sense a HTML name="" attribute, or a $_POST variable
1536 * name) to use for a question_type variable belonging to this question_attempt.
1537 *
1538 * See the comment on {@link question_attempt_step} for an explanation of
1539 * question type and behaviour variables.
1540 *
1541 * @param $varname The short form of the variable name.
1542 * @return string The field name to use.
1543 */
1544 public function get_qt_field_name($varname) {
1545 return $this->get_field_prefix() . $varname;
1546 }
1547
1548 /**
1549 * Get the name (in the sense a HTML name="" attribute, or a $_POST variable
1550 * name) to use for a question_type variable belonging to this question_attempt.
1551 *
1552 * See the comment on {@link question_attempt_step} for an explanation of
1553 * question type and behaviour variables.
1554 *
1555 * @param $varname The short form of the variable name.
1556 * @return string The field name to use.
1557 */
1558 public function get_behaviour_field_name($varname) {
1559 return $this->get_field_prefix() . '-' . $varname;
1560 }
1561
1562 /**
1563 * Get the name (in the sense a HTML name="" attribute, or a $_POST variable
1564 * name) to use for a control variables belonging to this question_attempt.
1565 *
1566 * Examples are :sequencecheck and :flagged
1567 *
1568 * @param $varname The short form of the variable name.
1569 * @return string The field name to use.
1570 */
1571 public function get_control_field_name($varname) {
1572 return $this->get_field_prefix() . ':' . $varname;
1573 }
1574
1575 /**
1576 * Get the prefix added to variable names to give field names for this
1577 * question attempt.
1578 *
1579 * You should not use this method directly. This is an implementation detail
1580 * anyway, but if you must access it, use {@link question_usage_by_activity::get_field_prefix()}.
1581 *
1582 * @param $varname The short form of the variable name.
1583 * @return string The field name to use.
1584 */
1585 public function get_field_prefix() {
1586 return 'q' . $this->usageid . ':' . $this->slot . '_';
1587 }
1588
1589 /**
1590 * Get one of the steps in this attempt.
1591 * For internal/test code use only.
f7970e3c 1592 * @param int $i the step number.
d1b7e03d
TH
1593 * @return question_attempt_step
1594 */
1595 public function get_step($i) {
1596 if ($i < 0 || $i >= count($this->steps)) {
88f0eb15 1597 throw new coding_exception('Index out of bounds in question_attempt::get_step.');
d1b7e03d
TH
1598 }
1599 return $this->steps[$i];
1600 }
1601
1602 /**
1603 * Get the number of steps in this attempt.
1604 * For internal/test code use only.
f7970e3c 1605 * @return int the number of steps we currently have.
d1b7e03d
TH
1606 */
1607 public function get_num_steps() {
1608 return count($this->steps);
1609 }
1610
1611 /**
1612 * Return the latest step in this question_attempt.
1613 * For internal/test code use only.
1614 * @return question_attempt_step
1615 */
1616 public function get_last_step() {
1617 if (count($this->steps) == 0) {
1618 return new question_null_step();
1619 }
1620 return end($this->steps);
1621 }
1622
1623 /**
1624 * @return question_attempt_step_iterator for iterating over the steps in
1625 * this attempt, in order.
1626 */
1627 public function get_step_iterator() {
1628 return new question_attempt_step_iterator($this);
1629 }
1630
1631 /**
1632 * The same as {@link get_step_iterator()}. However, for a
1633 * {@link question_attempt_with_restricted_history} this returns the full
1634 * list of steps, while {@link get_step_iterator()} returns only the
1635 * limited history.
1636 * @return question_attempt_step_iterator for iterating over the steps in
1637 * this attempt, in order.
1638 */
1639 public function get_full_step_iterator() {
1640 return $this->get_step_iterator();
1641 }
1642
1643 /**
1644 * @return question_attempt_reverse_step_iterator for iterating over the steps in
1645 * this attempt, in reverse order.
1646 */
c7df5006 1647 public function get_reverse_step_iterator() {
d1b7e03d
TH
1648 return new question_attempt_reverse_step_iterator($this);
1649 }
1650
1651 /**
1652 * Get the qt data from the latest step that has any qt data. Return $default
1653 * array if it is no step has qt data.
1654 *
1655 * @param string $name the name of the variable to get.
1656 * @param mixed default the value to return no step has qt data.
1657 * (Optional, defaults to an empty array.)
1658 * @return array|mixed the data, or $default if there is not any.
1659 */
1660 public function get_last_qt_data($default = array()) {
1661 foreach ($this->get_reverse_step_iterator() as $step) {
1662 $response = $step->get_qt_data();
1663 if (!empty($response)) {
1664 return $response;
1665 }
1666 }
1667 return $default;
1668 }
1669
48d9c17d
TH
1670 /**
1671 * Get the last step with a particular question type varialbe set.
1672 * @param string $name the name of the variable to get.
1673 * @return question_attempt_step the last step, or a step with no variables
1674 * if there was not a real step.
1675 */
1676 public function get_last_step_with_qt_var($name) {
1677 foreach ($this->get_reverse_step_iterator() as $step) {
1678 if ($step->has_qt_var($name)) {
1679 return $step;
1680 }
1681 }
1682 return new question_attempt_step_read_only();
1683 }
1684
d1b7e03d
TH
1685 /**
1686 * Get the latest value of a particular question type variable. That is, get
1687 * the value from the latest step that has it set. Return null if it is not
1688 * set in any step.
1689 *
1690 * @param string $name the name of the variable to get.
1691 * @param mixed default the value to return in the variable has never been set.
1692 * (Optional, defaults to null.)
1693 * @return mixed string value, or $default if it has never been set.
1694 */
1695 public function get_last_qt_var($name, $default = null) {
48d9c17d
TH
1696 $step = $this->get_last_step_with_qt_var($name);
1697 if ($step->has_qt_var($name)) {
1698 return $step->get_qt_var($name);
1699 } else {
1700 return $default;
d1b7e03d 1701 }
d1b7e03d
TH
1702 }
1703
caeeff07
TH
1704 /**
1705 * Get the latest set of files for a particular question type variable of
1706 * type question_attempt::PARAM_FILES.
1707 *
1708 * @param string $name the name of the associated variable.
1709 * @return array of {@link stored_files}.
1710 */
1711 public function get_last_qt_files($name, $contextid) {
1712 foreach ($this->get_reverse_step_iterator() as $step) {
1713 if ($step->has_qt_var($name)) {
1714 return $step->get_qt_files($name, $contextid);
1715 }
1716 }
1717 return array();
1718 }
1719
1720 /**
1721 * Get the URL of a file that belongs to a response variable of this
1722 * question_attempt.
1723 * @param stored_file $file the file to link to.
1724 * @return string the URL of that file.
1725 */
1726 public function get_response_file_url(stored_file $file) {
1727 return file_encode_url(new moodle_url('/pluginfile.php'), '/' . implode('/', array(
1728 $file->get_contextid(),
1729 $file->get_component(),
1730 $file->get_filearea(),
1731 $this->usageid,
1732 $this->slot,
1733 $file->get_itemid())) .
217f9a61 1734 $file->get_filepath() . $file->get_filename(), true);
caeeff07
TH
1735 }
1736
8026d4aa 1737 /**
217f9a61
TH
1738 * Prepare a draft file are for the files belonging the a response variable
1739 * of this question attempt. The draft area is populated with the files from
1740 * the most recent step having files.
1741 *
1742 * @param string $name the variable name the files belong to.
1743 * @param int $contextid the id of the context the quba belongs to.
8026d4aa
TH
1744 * @return int the draft itemid.
1745 */
1746 public function prepare_response_files_draft_itemid($name, $contextid) {
217f9a61
TH
1747 foreach ($this->get_reverse_step_iterator() as $step) {
1748 if ($step->has_qt_var($name)) {
1749 return $step->prepare_response_files_draft_itemid($name, $contextid);
1750 }
1751 }
1752
1753 // No files yet.
1754 $draftid = 0; // Will be filled in by file_prepare_draft_area.
1755 file_prepare_draft_area($draftid, $contextid, 'question', 'response_' . $name, null);
8026d4aa
TH
1756 return $draftid;
1757 }
1758
d1b7e03d
TH
1759 /**
1760 * Get the latest value of a particular behaviour variable. That is,
1761 * get the value from the latest step that has it set. Return null if it is
1762 * not set in any step.
1763 *
1764 * @param string $name the name of the variable to get.
1765 * @param mixed default the value to return in the variable has never been set.
1766 * (Optional, defaults to null.)
1767 * @return mixed string value, or $default if it has never been set.
1768 */
1769 public function get_last_behaviour_var($name, $default = null) {
1770 foreach ($this->get_reverse_step_iterator() as $step) {
1771 if ($step->has_behaviour_var($name)) {
1772 return $step->get_behaviour_var($name);
1773 }
1774 }
1775 return $default;
1776 }
1777
1778 /**
1779 * Get the current state of this question attempt. That is, the state of the
1780 * latest step.
1781 * @return question_state
1782 */
1783 public function get_state() {
1784 return $this->get_last_step()->get_state();
1785 }
1786
1787 /**
f7970e3c 1788 * @param bool $showcorrectness Whether right/partial/wrong states should
d1b7e03d
TH
1789 * be distinguised.
1790 * @return string A brief textual description of the current state.
1791 */
1792 public function get_state_string($showcorrectness) {
1793 return $this->behaviour->get_state_string($showcorrectness);
1794 }
1795
1796 /**
f7970e3c 1797 * @return int the timestamp of the most recent step in this question attempt.
d1b7e03d
TH
1798 */
1799 public function get_last_action_time() {
1800 return $this->get_last_step()->get_timecreated();
1801 }
1802
1803 /**
1804 * Get the current fraction of this question attempt. That is, the fraction
1805 * of the latest step, or null if this question has not yet been graded.
1806 * @return number the current fraction.
1807 */
1808 public function get_fraction() {
1809 return $this->get_last_step()->get_fraction();
1810 }
1811
f7970e3c 1812 /** @return bool whether this question attempt has a non-zero maximum mark. */
d1b7e03d
TH
1813 public function has_marks() {
1814 // Since grades are stored in the database as NUMBER(12,7).
1815 return $this->maxmark >= 0.00000005;
1816 }
1817
1818 /**
1819 * @return number the current mark for this question.
1820 * {@link get_fraction()} * {@link get_max_mark()}.
1821 */
1822 public function get_mark() {
1823 return $this->fraction_to_mark($this->get_fraction());
1824 }
1825
1826 /**
1827 * This is used by the manual grading code, particularly in association with
1828 * validation. If there is a mark submitted in the request, then use that,
1829 * otherwise use the latest mark for this question.
1830 * @return number the current mark for this question.
1831 * {@link get_fraction()} * {@link get_max_mark()}.
1832 */
1833 public function get_current_manual_mark() {
217f9a61 1834 $mark = $this->get_submitted_var($this->get_behaviour_field_name('mark'), question_attempt::PARAM_MARK);
d1b7e03d
TH
1835 if (is_null($mark)) {
1836 return $this->get_mark();
1837 } else {
1838 return $mark;
1839 }
1840 }
1841
1842 /**
1843 * @param number|null $fraction a fraction.
1844 * @return number|null the corresponding mark.
1845 */
1846 public function fraction_to_mark($fraction) {
1847 if (is_null($fraction)) {
1848 return null;
1849 }
1850 return $fraction * $this->maxmark;
1851 }
1852
1853 /** @return number the maximum mark possible for this question attempt. */
1854 public function get_max_mark() {
1855 return $this->maxmark;
1856 }
1857
1858 /** @return number the maximum mark possible for this question attempt. */
1859 public function get_min_fraction() {
1860 if (is_null($this->minfraction)) {
88f0eb15 1861 throw new coding_exception('This question_attempt has not been started yet, the min fraction is not yet konwn.');
d1b7e03d
TH
1862 }
1863 return $this->minfraction;
1864 }
1865
1866 /**
1867 * The current mark, formatted to the stated number of decimal places. Uses
1868 * {@link format_float()} to format floats according to the current locale.
f7970e3c 1869 * @param int $dp number of decimal places.
d1b7e03d
TH
1870 * @return string formatted mark.
1871 */
1872 public function format_mark($dp) {
1873 return $this->format_fraction_as_mark($this->get_fraction(), $dp);
1874 }
1875
1876 /**
1877 * The current mark, formatted to the stated number of decimal places. Uses
1878 * {@link format_float()} to format floats according to the current locale.
f7970e3c 1879 * @param int $dp number of decimal places.
d1b7e03d
TH
1880 * @return string formatted mark.
1881 */
1882 public function format_fraction_as_mark($fraction, $dp) {
1883 return format_float($this->fraction_to_mark($fraction), $dp);
1884 }
1885
1886 /**
1887 * The maximum mark for this question attempt, formatted to the stated number
1888 * of decimal places. Uses {@link format_float()} to format floats according
1889 * to the current locale.
f7970e3c 1890 * @param int $dp number of decimal places.
d1b7e03d
TH
1891 * @return string formatted maximum mark.
1892 */
1893 public function format_max_mark($dp) {
1894 return format_float($this->maxmark, $dp);
1895 }
1896
1897 /**
1898 * Return the hint that applies to the question in its current state, or null.
1899 * @return question_hint|null
1900 */
1901 public function get_applicable_hint() {
1902 return $this->behaviour->get_applicable_hint();
1903 }
1904
1905 /**
1906 * Produce a plain-text summary of what the user did during a step.
1907 * @param question_attempt_step $step the step in quetsion.
1908 * @return string a summary of what was done during that step.
1909 */
1910 public function summarise_action(question_attempt_step $step) {
1911 return $this->behaviour->summarise_action($step);
1912 }
1913
48d9c17d
TH
1914 /**
1915 * Helper function used by {@link rewrite_pluginfile_urls()} and
1916 * {@link rewrite_response_pluginfile_urls()}.
1917 * @return array ids that need to go into the file paths.
1918 */
1919 protected function extra_file_path_components() {
1920 return array($this->get_usage_id(), $this->get_slot());
1921 }
1922
7a719748
TH
1923 /**
1924 * Calls {@link question_rewrite_question_urls()} with appropriate parameters
1925 * for content belonging to this question.
1926 * @param string $text the content to output.
1927 * @param string $component the component name (normally 'question' or 'qtype_...')
1928 * @param string $filearea the name of the file area.
f7970e3c 1929 * @param int $itemid the item id.
48d9c17d 1930 * @return srting the content with the URLs rewritten.
7a719748
TH
1931 */
1932 public function rewrite_pluginfile_urls($text, $component, $filearea, $itemid) {
48d9c17d
TH
1933 return question_rewrite_question_urls($text, 'pluginfile.php',
1934 $this->question->contextid, $component, $filearea,
1935 $this->extra_file_path_components(), $itemid);
1936 }
1937
1938 /**
1939 * Calls {@link question_rewrite_question_urls()} with appropriate parameters
1940 * for content belonging to responses to this question.
1941 *
1942 * @param string $text the text to update the URLs in.
1943 * @param int $contextid the id of the context the quba belongs to.
1944 * @param string $name the variable name the files belong to.
1945 * @param question_attempt_step $step the step the response is coming from.
1946 * @return srting the content with the URLs rewritten.
1947 */
1948 public function rewrite_response_pluginfile_urls($text, $contextid, $name,
1949 question_attempt_step $step) {
1950 return $step->rewrite_response_pluginfile_urls($text, $contextid, $name,
1951 $this->extra_file_path_components());
2b7da645
TH
1952 }
1953
d1b7e03d
TH
1954 /**
1955 * Get the {@link core_question_renderer}, in collaboration with appropriate
1956 * {@link qbehaviour_renderer} and {@link qtype_renderer} subclasses, to generate the
1957 * HTML to display this question attempt in its current state.
1958 * @param question_display_options $options controls how the question is rendered.
1959 * @param string|null $number The question number to display.
1960 * @return string HTML fragment representing the question.
1961 */
2daffca5
TH
1962 public function render($options, $number, $page = null) {
1963 if (is_null($page)) {
1964 global $PAGE;
1965 $page = $PAGE;
1966 }
1967 $qoutput = $page->get_renderer('core', 'question');
1968 $qtoutput = $this->question->get_renderer($page);
d1b7e03d
TH
1969 return $this->behaviour->render($options, $number, $qoutput, $qtoutput);
1970 }
1971
1972 /**
1973 * Generate any bits of HTML that needs to go in the <head> tag when this question
1974 * attempt is displayed in the body.
1975 * @return string HTML fragment.
1976 */
2daffca5
TH
1977 public function render_head_html($page = null) {
1978 if (is_null($page)) {
1979 global $PAGE;
1980 $page = $PAGE;
1981 }
1982 // TODO go via behaviour.
1983 return $this->question->get_renderer($page)->head_code($this) .
1984 $this->behaviour->get_renderer($page)->head_code($this);
d1b7e03d
TH
1985 }
1986
1987 /**
1988 * Like {@link render_question()} but displays the question at the past step
1989 * indicated by $seq, rather than showing the latest step.
1990 *
f7970e3c 1991 * @param int $seq the seq number of the past state to display.
d1b7e03d
TH
1992 * @param question_display_options $options controls how the question is rendered.
1993 * @param string|null $number The question number to display. 'i' is a special
1994 * value that gets displayed as Information. Null means no number is displayed.
1995 * @return string HTML fragment representing the question.
1996 */
1997 public function render_at_step($seq, $options, $number, $preferredbehaviour) {
1998 $restrictedqa = new question_attempt_with_restricted_history($this, $seq, $preferredbehaviour);
1999 return $restrictedqa->render($options, $number);
2000 }
2001
7a719748
TH
2002 /**
2003 * Checks whether the users is allow to be served a particular file.
2004 * @param question_display_options $options the options that control display of the question.
2005 * @param string $component the name of the component we are serving files for.
2006 * @param string $filearea the name of the file area.
2007 * @param array $args the remaining bits of the file path.
f7970e3c
TH
2008 * @param bool $forcedownload whether the user must be forced to download the file.
2009 * @return bool true if the user can access this file.
7a719748
TH
2010 */
2011 public function check_file_access($options, $component, $filearea, $args, $forcedownload) {
2012 return $this->behaviour->check_file_access($options, $component, $filearea, $args, $forcedownload);
2013 }
2014
d1b7e03d
TH
2015 /**
2016 * Add a step to this question attempt.
2017 * @param question_attempt_step $step the new step.
2018 */
2019 protected function add_step(question_attempt_step $step) {
2020 $this->steps[] = $step;
2021 end($this->steps);
2022 $this->observer->notify_step_added($step, $this, key($this->steps));
2023 }
2024
2025 /**
ef31a283 2026 * Start this question attempt.
d1b7e03d
TH
2027 *
2028 * You should not call this method directly. Call
2029 * {@link question_usage_by_activity::start_question()} instead.
2030 *
2031 * @param string|question_behaviour $preferredbehaviour the name of the
2032 * desired archetypal behaviour, or an actual model instance.
ef31a283
TH
2033 * @param array $submitteddata optional, used when re-starting to keep the same initial state.
2034 * @param int $timestamp optional, the timstamp to record for this action. Defaults to now.
2035 * @param int $userid optional, the user to attribute this action to. Defaults to the current user.
d1b7e03d
TH
2036 */
2037 public function start($preferredbehaviour, $submitteddata = array(), $timestamp = null, $userid = null) {
2038 // Initialise the behaviour.
2039 if (is_string($preferredbehaviour)) {
2040 $this->behaviour =
2041 $this->question->make_behaviour($this, $preferredbehaviour);
2042 } else {
2043 $class = get_class($preferredbehaviour);
2044 $this->behaviour = new $class($this, $preferredbehaviour);
2045 }
2046
2047 // Record the minimum fraction.
2048 $this->minfraction = $this->behaviour->get_min_fraction();
2049
2050 // Initialise the first step.
2051 $firststep = new question_attempt_step($submitteddata, $timestamp, $userid);
2052 $firststep->set_state(question_state::$todo);
ef31a283
TH
2053 if ($submitteddata) {
2054 $this->question->apply_attempt_state($firststep);
2055 } else {
2056 $this->behaviour->init_first_step($firststep);
2057 }
d1b7e03d
TH
2058 $this->add_step($firststep);
2059
2060 // Record questionline and correct answer.
d1b7e03d
TH
2061 $this->questionsummary = $this->behaviour->get_question_summary();
2062 $this->rightanswer = $this->behaviour->get_right_answer_summary();
2063 }
2064
2065 /**
2066 * Start this question attempt, starting from the point that the previous
2067 * attempt $oldqa had reached.
2068 *
2069 * You should not call this method directly. Call
2070 * {@link question_usage_by_activity::start_question_based_on()} instead.
2071 *
2072 * @param question_attempt $oldqa a previous attempt at this quetsion that
2073 * defines the starting point.
2074 */
2075 public function start_based_on(question_attempt $oldqa) {
2076 $this->start($oldqa->behaviour, $oldqa->get_resume_data());
2077 }
2078
2079 /**
2080 * Used by {@link start_based_on()} to get the data needed to start a new
2081 * attempt from the point this attempt has go to.
2082 * @return array name => value pairs.
2083 */
2084 protected function get_resume_data() {
2085 return $this->behaviour->get_resume_data();
2086 }
2087
2088 /**
2089 * Get a particular parameter from the current request. A wrapper round
2090 * {@link optional_param()}, except that the results is returned without
2091 * slashes.
2092 * @param string $name the paramter name.
48d9c17d
TH
2093 * @param int $type one of the standard PARAM_... constants, or one of the
2094 * special extra constands defined by this class.
d1b7e03d
TH
2095 * @param array $postdata (optional, only inteded for testing use) take the
2096 * data from this array, instead of from $_POST.
2097 * @return mixed the requested value.
2098 */
217f9a61 2099 public function get_submitted_var($name, $type, $postdata = null) {
48d9c17d
TH
2100 switch ($type) {
2101 case self::PARAM_MARK:
2102 // Special case to work around PARAM_NUMBER converting '' to 0.
2103 $mark = $this->get_submitted_var($name, PARAM_RAW_TRIMMED, $postdata);
2104 if ($mark === '') {
2105 return $mark;
2106 } else {
2107 return $this->get_submitted_var($name, PARAM_NUMBER, $postdata);
2108 }
d1b7e03d 2109
48d9c17d
TH
2110 case self::PARAM_FILES:
2111 return $this->process_response_files($name, $name, $postdata);
217f9a61 2112
48d9c17d
TH
2113 case self::PARAM_CLEANHTML_FILES:
2114 $var = $this->get_submitted_var($name, PARAM_CLEANHTML, $postdata);
2115 return $this->process_response_files($name, $name . ':itemid', $postdata, $var);
caeeff07 2116
48d9c17d
TH
2117 default:
2118 if (is_null($postdata)) {
2119 $var = optional_param($name, null, $type);
2120 } else if (array_key_exists($name, $postdata)) {
2121 $var = clean_param($postdata[$name], $type);
2122 } else {
2123 $var = null;
2124 }
caeeff07 2125
48d9c17d
TH
2126 return $var;
2127 }
d1b7e03d
TH
2128 }
2129
217f9a61
TH
2130 /**
2131 * Handle a submitted variable representing uploaded files.
2132 * @param string $name the field name.
48d9c17d 2133 * @param string $draftidname the field name holding the draft file area id.
217f9a61
TH
2134 * @param array $postdata (optional, only inteded for testing use) take the
2135 * data from this array, instead of from $_POST. At the moment, this
2136 * behaves as if there were no files.
48d9c17d
TH
2137 * @param string $text optional reponse text.
2138 * @return question_file_saver that can be used to save the files later.
217f9a61 2139 */
48d9c17d 2140 protected function process_response_files($name, $draftidname, $postdata = null, $text = null) {
217f9a61
TH
2141 if ($postdata) {
2142 // There can be no files with test data (at the moment).
2143 return null;
2144 }
2145
48d9c17d 2146 $draftitemid = file_get_submitted_draft_itemid($draftidname);
217f9a61
TH
2147 if (!$draftitemid) {
2148 return null;
2149 }
2150
217f9a61 2151 return new question_file_saver($draftitemid, 'question', 'response_' .
48d9c17d 2152 str_replace($this->get_field_prefix(), '', $name), $text);
217f9a61
TH
2153 }
2154
d1b7e03d
TH
2155 /**
2156 * Get any data from the request that matches the list of expected params.
2157 * @param array $expected variable name => PARAM_... constant.
2158 * @param string $extraprefix '-' or ''.
2159 * @return array name => value.
2160 */
2161 protected function get_expected_data($expected, $postdata, $extraprefix) {
2162 $submitteddata = array();
2163 foreach ($expected as $name => $type) {
217f9a61 2164 $value = $this->get_submitted_var(
d1b7e03d
TH
2165 $this->get_field_prefix() . $extraprefix . $name, $type, $postdata);
2166 if (!is_null($value)) {
2167 $submitteddata[$extraprefix . $name] = $value;
2168 }
2169 }
2170 return $submitteddata;
2171 }
2172
2173 /**
2174 * Get all the submitted question type data for this question, whithout checking
2175 * that it is valid or cleaning it in any way.
2176 * @return array name => value.
2177 */
2178 protected function get_all_submitted_qt_vars($postdata) {
2179 if (is_null($postdata)) {
2180 $postdata = $_POST;
2181 }
2182
2183 $pattern = '/^' . preg_quote($this->get_field_prefix()) . '[^-:]/';
2184 $prefixlen = strlen($this->get_field_prefix());
2185
2186 $submitteddata = array();
2187 foreach ($_POST as $name => $value) {
2188 if (preg_match($pattern, $name)) {
2189 $submitteddata[substr($name, $prefixlen)] = $value;
2190 }
2191 }
2192
2193 return $submitteddata;
2194 }
2195
2196 /**
2197 * Get all the sumbitted data belonging to this question attempt from the
2198 * current request.
2199 * @param array $postdata (optional, only inteded for testing use) take the
2200 * data from this array, instead of from $_POST.
2201 * @return array name => value pairs that could be passed to {@link process_action()}.
2202 */
2203 public function get_submitted_data($postdata = null) {
2204 $submitteddata = $this->get_expected_data(
2205 $this->behaviour->get_expected_data(), $postdata, '-');
2206
2207 $expected = $this->behaviour->get_expected_qt_data();
2208 if ($expected === self::USE_RAW_DATA) {
2209 $submitteddata += $this->get_all_submitted_qt_vars($postdata);
2210 } else {
2211 $submitteddata += $this->get_expected_data($expected, $postdata, '');
2212 }
2213 return $submitteddata;
2214 }
2215
2216 /**
2217 * Get a set of response data for this question attempt that would get the
2218 * best possible mark.
2219 * @return array name => value pairs that could be passed to {@link process_action()}.
2220 */
2221 public function get_correct_response() {
2222 $response = $this->question->get_correct_response();
2223 $imvars = $this->behaviour->get_correct_response();
2224 foreach ($imvars as $name => $value) {
2225 $response['-' . $name] = $value;
2226 }
2227 return $response;
2228 }
2229
2230 /**
2231 * Change the quetsion summary. Note, that this is almost never necessary.
2232 * This method was only added to work around a limitation of the Opaque
2233 * protocol, which only sends questionLine at the end of an attempt.
2234 * @param $questionsummary the new summary to set.
2235 */
2236 public function set_question_summary($questionsummary) {
2237 $this->questionsummary = $questionsummary;
2238 $this->observer->notify_attempt_modified($this);
2239 }
2240
2241 /**
2242 * @return string a simple textual summary of the question that was asked.
2243 */
2244 public function get_question_summary() {
2245 return $this->questionsummary;
2246 }
2247
2248 /**
2249 * @return string a simple textual summary of response given.
2250 */
2251 public function get_response_summary() {
2252 return $this->responsesummary;
2253 }
2254
2255 /**
2256 * @return string a simple textual summary of the correct resonse.
2257 */
2258 public function get_right_answer_summary() {
2259 return $this->rightanswer;
2260 }
2261
2262 /**
2263 * Perform the action described by $submitteddata.
2264 * @param array $submitteddata the submitted data the determines the action.
f7970e3c
TH
2265 * @param int $timestamp the time to record for the action. (If not given, use now.)
2266 * @param int $userid the user to attribute the aciton to. (If not given, use the current user.)
d1b7e03d
TH
2267 */
2268 public function process_action($submitteddata, $timestamp = null, $userid = null) {
2269 $pendingstep = new question_attempt_pending_step($submitteddata, $timestamp, $userid);
2270 if ($this->behaviour->process_action($pendingstep) == self::KEEP) {
2271 $this->add_step($pendingstep);
2272 if ($pendingstep->response_summary_changed()) {
2273 $this->responsesummary = $pendingstep->get_new_response_summary();
2274 }
2275 }
2276 }
2277
2278 /**
2279 * Perform a finish action on this question attempt. This corresponds to an
2280 * external finish action, for example the user pressing Submit all and finish
2281 * in the quiz, rather than using one of the controls that is part of the
2282 * question.
2283 *
f7970e3c
TH
2284 * @param int $timestamp the time to record for the action. (If not given, use now.)
2285 * @param int $userid the user to attribute the aciton to. (If not given, use the current user.)
d1b7e03d
TH
2286 */
2287 public function finish($timestamp = null, $userid = null) {
2288 $this->process_action(array('-finish' => 1), $timestamp, $userid);
2289 }
2290
2291 /**
2292 * Perform a regrade. This replays all the actions from $oldqa into this
2293 * attempt.
2294 * @param question_attempt $oldqa the attempt to regrade.
f7970e3c 2295 * @param bool $finished whether the question attempt should be forced to be finished
d1b7e03d
TH
2296 * after the regrade, or whether it may still be in progress (default false).
2297 */
2298 public function regrade(question_attempt $oldqa, $finished) {
2299 $first = true;
2300 foreach ($oldqa->get_step_iterator() as $step) {
2301 if ($first) {
2302 $first = false;
2303 $this->start($oldqa->behaviour, $step->get_all_data(),
2304 $step->get_timecreated(), $step->get_user_id());
2305 } else {
2306 $this->process_action($step->get_submitted_data(),
2307 $step->get_timecreated(), $step->get_user_id());
2308 }
2309 }
2310 if ($finished) {
2311 $this->finish();
2312 }
2313 }
2314
2315 /**
2316 * Perform a manual grading action on this attempt.
2317 * @param $comment the comment being added.
2318 * @param $mark the new mark. (Optional, if not given, then only a comment is added.)
f7970e3c
TH
2319 * @param int $timestamp the time to record for the action. (If not given, use now.)
2320 * @param int $userid the user to attribute the aciton to. (If not given, use the current user.)
d1b7e03d
TH
2321 * @return unknown_type
2322 */
2323 public function manual_grade($comment, $mark, $timestamp = null, $userid = null) {
2324 $submitteddata = array('-comment' => $comment);
2325 if (!is_null($mark)) {
2326 $submitteddata['-mark'] = $mark;
2327 $submitteddata['-maxmark'] = $this->maxmark;
2328 }
2329 $this->process_action($submitteddata, $timestamp, $userid);
2330 }
2331
f7970e3c 2332 /** @return bool Whether this question attempt has had a manual comment added. */
d1b7e03d
TH
2333 public function has_manual_comment() {
2334 foreach ($this->steps as $step) {
2335 if ($step->has_behaviour_var('comment')) {
2336 return true;
2337 }
2338 }
2339 return false;
2340 }
2341
2342 /**
7a719748
TH
2343 * @return array(string, int) the most recent manual comment that was added
2344 * to this question, and the FORMAT_... it is.
d1b7e03d
TH
2345 */
2346 public function get_manual_comment() {
2347 foreach ($this->get_reverse_step_iterator() as $step) {
2348 if ($step->has_behaviour_var('comment')) {
7a719748
TH
2349 return array($step->get_behaviour_var('comment'),
2350 $step->get_behaviour_var('commentformat'));
d1b7e03d
TH
2351 }
2352 }
7a719748 2353 return array(null, null);
d1b7e03d
TH
2354 }
2355
2356 /**
2357 * @return array subpartid => object with fields
f9b0500f 2358 * ->responseclassid matches one of the values returned from quetion_type::get_possible_responses.
d1b7e03d
TH
2359 * ->response the actual response the student gave to this part, as a string.
2360 * ->fraction the credit awarded for this subpart, may be null.
2361 * returns an empty array if no analysis is possible.
2362 */
2363 public function classify_response() {
2364 return $this->behaviour->classify_response();
2365 }
2366
2367 /**
2368 * Create a question_attempt_step from records loaded from the database.
2369 *
2370 * For internal use only.
2371 *
2372 * @param array $records Raw records loaded from the database.
f7970e3c 2373 * @param int $questionattemptid The id of the question_attempt to extract.
d1b7e03d
TH
2374 * @return question_attempt The newly constructed question_attempt_step.
2375 */
2376 public static function load_from_records(&$records, $questionattemptid,
2377 question_usage_observer $observer, $preferredbehaviour) {
2378 $record = current($records);
2379 while ($record->questionattemptid != $questionattemptid) {
2380 $record = next($records);
2381 if (!$record) {
88f0eb15 2382 throw new coding_exception("Question attempt $questionattemptid not found in the database.");
d1b7e03d
TH
2383 }
2384 }
2385
2386 try {
2387 $question = question_bank::load_question($record->questionid);
2388 } catch (Exception $e) {
2389 // The question must have been deleted somehow. Create a missing
2390 // question to use in its place.
2391 $question = question_bank::get_qtype('missingtype')->make_deleted_instance(
2392 $record->questionid, $record->maxmark + 0);
2393 }
2394
7a719748 2395 $qa = new question_attempt($question, $record->questionusageid,
2daffca5 2396 null, $record->maxmark + 0);
d1b7e03d
TH
2397 $qa->set_database_id($record->questionattemptid);
2398 $qa->set_number_in_usage($record->slot);
2399 $qa->minfraction = $record->minfraction + 0;
2400 $qa->set_flagged($record->flagged);
2401 $qa->questionsummary = $record->questionsummary;
2402 $qa->rightanswer = $record->rightanswer;
2403 $qa->responsesummary = $record->responsesummary;
2404 $qa->timemodified = $record->timemodified;
2405
2406 $qa->behaviour = question_engine::make_behaviour(
2407 $record->behaviour, $qa, $preferredbehaviour);
2408
2409 $i = 0;
2410 while ($record && $record->questionattemptid == $questionattemptid && !is_null($record->attemptstepid)) {
2411 $qa->steps[$i] = question_attempt_step::load_from_records($records, $record->attemptstepid);
2412 if ($i == 0) {
ef31a283 2413 $question->apply_attempt_state($qa->steps[0]);
d1b7e03d
TH
2414 }
2415 $i++;
2416 $record = current($records);
2417 }
2418
2419 $qa->observer = $observer;
2420
2421 return $qa;
2422 }
2423}
2424
2425
2426/**
2427 * This subclass of question_attempt pretends that only part of the step history
2428 * exists. It is used for rendering the question in past states.
2429 *
2430 * All methods that try to modify the question_attempt throw exceptions.
2431 *
f7970e3c 2432 * @copyright 2010 The Open University
017bc1d9 2433 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
2434 */
2435class question_attempt_with_restricted_history extends question_attempt {
2436 /**
2437 * @var question_attempt the underlying question_attempt.
2438 */
2439 protected $baseqa;
2440
2441 /**
2442 * Create a question_attempt_with_restricted_history
2443 * @param question_attempt $baseqa The question_attempt to make a restricted version of.
f7970e3c 2444 * @param int $lastseq the index of the last step to include.
d1b7e03d
TH
2445 * @param string $preferredbehaviour the preferred behaviour. It is slightly
2446 * annoyting that this needs to be passed, but unavoidable for now.
2447 */
2448 public function __construct(question_attempt $baseqa, $lastseq, $preferredbehaviour) {
2449 if ($lastseq < 0 || $lastseq >= $baseqa->get_num_steps()) {
2450 throw new coding_exception('$seq out of range', $seq);
2451 }
2452
2453 $this->baseqa = $baseqa;
2454 $this->steps = array_slice($baseqa->steps, 0, $lastseq + 1);
2455 $this->observer = new question_usage_null_observer();
2456
2457 // This should be a straight copy of all the remaining fields.
2458 $this->id = $baseqa->id;
2459 $this->usageid = $baseqa->usageid;
2460 $this->slot = $baseqa->slot;
2461 $this->question = $baseqa->question;
2462 $this->maxmark = $baseqa->maxmark;
2463 $this->minfraction = $baseqa->minfraction;
2464 $this->questionsummary = $baseqa->questionsummary;
2465 $this->responsesummary = $baseqa->responsesummary;
2466 $this->rightanswer = $baseqa->rightanswer;
2467 $this->flagged = $baseqa->flagged;
2468
2469 // Except behaviour, where we need to create a new one.
2470 $this->behaviour = question_engine::make_behaviour(
2471 $baseqa->get_behaviour_name(), $this, $preferredbehaviour);
2472 }
2473
2474 public function get_full_step_iterator() {
2475 return $this->baseqa->get_step_iterator();
2476 }
2477
2478 protected function add_step(question_attempt_step $step) {
2479 coding_exception('Cannot modify a question_attempt_with_restricted_history.');
2480 }
2481 public function process_action($submitteddata, $timestamp = null, $userid = null) {
2482 coding_exception('Cannot modify a question_attempt_with_restricted_history.');
2483 }
2484 public function start($preferredbehaviour, $submitteddata = array(), $timestamp = null, $userid = null) {
2485 coding_exception('Cannot modify a question_attempt_with_restricted_history.');
2486 }
2487
2488 public function set_database_id($id) {
2489 coding_exception('Cannot modify a question_attempt_with_restricted_history.');
2490 }
2491 public function set_flagged($flagged) {
2492 coding_exception('Cannot modify a question_attempt_with_restricted_history.');
2493 }
2494 public function set_number_in_usage($slot) {
2495 coding_exception('Cannot modify a question_attempt_with_restricted_history.');
2496 }
2497 public function set_question_summary($questionsummary) {
2498 coding_exception('Cannot modify a question_attempt_with_restricted_history.');
2499 }
2500 public function set_usage_id($usageid) {
2501 coding_exception('Cannot modify a question_attempt_with_restricted_history.');
2502 }
2503}
2504
2505
2506/**
2507 * A class abstracting access to the {@link question_attempt::$states} array.
2508 *
2509 * This is actively linked to question_attempt. If you add an new step
2510 * mid-iteration, then it will be included.
2511 *
017bc1d9
TH
2512 * @copyright 2009 The Open University
2513 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
2514 */
2515class question_attempt_step_iterator implements Iterator, ArrayAccess {
2516 /** @var question_attempt the question_attempt being iterated over. */
2517 protected $qa;
2518 /** @var integer records the current position in the iteration. */
2519 protected $i;
2520
2521 /**
2522 * Do not call this constructor directly.
2523 * Use {@link question_attempt::get_step_iterator()}.
2524 * @param question_attempt $qa the attempt to iterate over.
2525 */
2526 public function __construct(question_attempt $qa) {
2527 $this->qa = $qa;
2528 $this->rewind();
2529 }
2530
2531 /** @return question_attempt_step */
2532 public function current() {
2533 return $this->offsetGet($this->i);
2534 }
f7970e3c 2535 /** @return int */
d1b7e03d
TH
2536 public function key() {
2537 return $this->i;
2538 }
2539 public function next() {
2540 ++$this->i;
2541 }
2542 public function rewind() {
2543 $this->i = 0;
2544 }
f7970e3c 2545 /** @return bool */
d1b7e03d
TH
2546 public function valid() {
2547 return $this->offsetExists($this->i);
2548 }
2549
f7970e3c 2550 /** @return bool */
d1b7e03d
TH
2551 public function offsetExists($i) {
2552 return $i >= 0 && $i < $this->qa->get_num_steps();
2553 }
2554 /** @return question_attempt_step */
2555 public function offsetGet($i) {
2556 return $this->qa->get_step($i);
2557 }
2558 public function offsetSet($offset, $value) {
88f0eb15 2559 throw new coding_exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot set.');
d1b7e03d
TH
2560 }
2561 public function offsetUnset($offset) {
88f0eb15 2562 throw new coding_exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot unset.');
d1b7e03d
TH
2563 }
2564}
2565
2566
2567/**
2568 * A variant of {@link question_attempt_step_iterator} that iterates through the
2569 * steps in reverse order.
2570 *
017bc1d9
TH
2571 * @copyright 2009 The Open University
2572 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
2573 */
2574class question_attempt_reverse_step_iterator extends question_attempt_step_iterator {
2575 public function next() {
2576 --$this->i;
2577 }
2578
2579 public function rewind() {
2580 $this->i = $this->qa->get_num_steps() - 1;
2581 }
2582}
2583
2584
2585/**
2586 * Stores one step in a {@link question_attempt}.
2587 *
2588 * The most important attributes of a step are the state, which is one of the
2589 * {@link question_state} constants, the fraction, which may be null, or a
2590 * number bewteen the attempt's minfraction and 1.0, and the array of submitted
2591 * data, about which more later.
2592 *
2593 * A step also tracks the time it was created, and the user responsible for
2594 * creating it.
2595 *
2596 * The submitted data is basically just an array of name => value pairs, with
2597 * certain conventions about the to divide the variables into four = two times two
2598 * categories.
2599 *
2600 * Variables may either belong to the behaviour, in which case the
2601 * name starts with a '-', or they may belong to the question type in which case
2602 * they name does not start with a '-'.
2603 *
2604 * Second, variables may either be ones that came form the original request, in
2605 * which case the name does not start with an _, or they are cached values that
2606 * were created during processing, in which case the name does start with an _.
2607 *
2608 * That is, each name will start with one of '', '_'. '-' or '-_'. The remainder
2609 * of the name should match the regex [a-z][a-z0-9]*.
2610 *
2611 * These variables can be accessed with {@link get_behaviour_var()} and {@link get_qt_var()},
2612 * - to be clear, ->get_behaviour_var('x') gets the variable with name '-x' -
2613 * and values whose names start with '_' can be set using {@link set_behaviour_var()}
2614 * and {@link set_qt_var()}. There are some other methods like {@link has_behaviour_var()}
2615 * to check wether a varaible with a particular name is set, and {@link get_behaviour_data()}
2616 * to get all the behaviour data as an associative array.
2617 *
017bc1d9
TH
2618 * @copyright 2009 The Open University
2619 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
2620 */
2621class question_attempt_step {
2622 /** @var integer if this attempts is stored in the question_attempts table, the id of that row. */
2623 private $id = null;
2624
2625 /** @var question_state one of the {@link question_state} constants. The state after this step. */
2626 private $state;
2627
2628 /** @var null|number the fraction (grade on a scale of minfraction .. 1.0) or null. */
2629 private $fraction = null;
2630
2631 /** @var integer the timestamp when this step was created. */
2632 private $timecreated;
2633
2634 /** @var integer the id of the user resonsible for creating this step. */
2635 private $userid;
2636
2637 /** @var array name => value pairs. The submitted data. */
2638 private $data;
2639
caeeff07
TH
2640 /** @var array name => array of {@link stored_file}s. Caches the contents of file areas. */
2641 private $files = array();
2642
d1b7e03d
TH
2643 /**
2644 * You should not need to call this constructor in your own code. Steps are
2645 * normally created by {@link question_attempt} methods like
2646 * {@link question_attempt::process_action()}.
2647 * @param array $data the submitted data that defines this step.
f7970e3c
TH
2648 * @param int $timestamp the time to record for the action. (If not given, use now.)
2649 * @param int $userid the user to attribute the aciton to. (If not given, use the current user.)
d1b7e03d
TH
2650 */
2651 public function __construct($data = array(), $timecreated = null, $userid = null) {
2652 global $USER;
2653 $this->state = question_state::$unprocessed;
2654 $this->data = $data;
2655 if (is_null($timecreated)) {
2656 $this->timecreated = time();
2657 } else {
2658 $this->timecreated = $timecreated;
2659 }
2660 if (is_null($userid)) {
2661 $this->userid = $USER->id;
2662 } else {
2663 $this->userid = $userid;
2664 }
2665 }
2666
2667 /** @return question_state The state after this step. */
2668 public function get_state() {
2669 return $this->state;
2670 }
2671
2672 /**
2673 * Set the state. Normally only called by behaviours.
2674 * @param question_state $state one of the {@link question_state} constants.
2675 */
2676 public function set_state($state) {
2677 $this->state = $state;
2678 }
2679
2680 /**
2681 * @return null|number the fraction (grade on a scale of minfraction .. 1.0)
2682 * or null if this step has not been marked.
2683 */
2684 public function get_fraction() {
2685 return $this->fraction;
2686 }
2687
2688 /**
2689 * Set the fraction. Normally only called by behaviours.
2690 * @param null|number $fraction the fraction to set.
2691 */
2692 public function set_fraction($fraction) {
2693 $this->fraction = $fraction;
2694 }
2695
f7970e3c 2696 /** @return int the id of the user resonsible for creating this step. */
d1b7e03d
TH
2697 public function get_user_id() {
2698 return $this->userid;
2699 }
2700
f7970e3c 2701 /** @return int the timestamp when this step was created. */
d1b7e03d
TH
2702 public function get_timecreated() {
2703 return $this->timecreated;
2704 }
2705
2706 /**
2707 * @param string $name the name of a question type variable to look for in the submitted data.
f7970e3c 2708 * @return bool whether a variable with this name exists in the question type data.
d1b7e03d
TH
2709 */
2710 public function has_qt_var($name) {
2711 return array_key_exists($name, $this->data);
2712 }
2713
2714 /**
2715 * @param string $name the name of a question type variable to look for in the submitted data.
2716 * @return string the requested variable, or null if the variable is not set.
2717 */
2718 public function get_qt_var($name) {
2719 if (!$this->has_qt_var($name)) {
2720 return null;
2721 }
2722 return $this->data[$name];
2723 }
2724
2725 /**
2726 * Set a cached question type variable.
2727 * @param string $name the name of the variable to set. Must match _[a-z][a-z0-9]*.
2728 * @param string $value the value to set.
2729 */
2730 public function set_qt_var($name, $value) {
2731 if ($name[0] != '_') {
88f0eb15 2732 throw new coding_exception('Cannot set question type data ' . $name . ' on an attempt step. You can only set variables with names begining with _.');
d1b7e03d
TH
2733 }
2734 $this->data[$name] = $value;
2735 }
2736
caeeff07
TH
2737 /**
2738 * Get the latest set of files for a particular question type variable of
2739 * type question_attempt::PARAM_FILES.
2740 *
2741 * @param string $name the name of the associated variable.
2742 * @return array of {@link stored_files}.
2743 */
2744 public function get_qt_files($name, $contextid) {
2745 if (array_key_exists($name, $this->files)) {
2746 return $this->files[$name];
2747 }
2748
2749 if (!$this->has_qt_var($name)) {
2750 $this->files[$name] = array();
2751 return array();
2752 }
2753
2754 $fs = get_file_storage();
2755 $this->files[$name] = $fs->get_area_files($contextid, 'question',
2756 'response_' . $name, $this->id, 'sortorder', false);
2757
2758 return $this->files[$name];
2759 }
2760
217f9a61
TH
2761 /**
2762 * Prepare a draft file are for the files belonging the a response variable
2763 * of this step.
2764 *
2765 * @param string $name the variable name the files belong to.
2766 * @param int $contextid the id of the context the quba belongs to.
2767 * @return int the draft itemid.
2768 */
2769 public function prepare_response_files_draft_itemid($name, $contextid) {
48d9c17d
TH
2770 list($draftid, $notused) = $this->prepare_response_files_draft_itemid_with_text(
2771 $name, $contextid, null);
217f9a61
TH
2772 return $draftid;
2773 }
2774
48d9c17d
TH
2775 /**
2776 * Prepare a draft file are for the files belonging the a response variable
2777 * of this step, while rewriting the URLs in some text.
2778 *
2779 * @param string $name the variable name the files belong to.
2780 * @param int $contextid the id of the context the quba belongs to.
2781 * @param string $text the text to update the URLs in.
2782 * @return array(int, string) the draft itemid and the text with URLs rewritten.
2783 */
2784 public function prepare_response_files_draft_itemid_with_text($name, $contextid, $text) {
2785 $draftid = 0; // Will be filled in by file_prepare_draft_area.
2786 $newtext = file_prepare_draft_area($draftid, $contextid, 'question',
2787 'response_' . $name, $this->id, null, $text);
2788 return array($draftid, $newtext);
2789 }
2790
2791 /**
2792 * Rewrite the @@PLUGINFILE@@ tokens in a response variable from this step
2793 * that contains links to file. Normally you should probably call
2794 * {@link question_attempt::rewrite_response_pluginfile_urls()} instead of
2795 * calling this method directly.
2796 *
2797 * @param string $text the text to update the URLs in.
2798 * @param int $contextid the id of the context the quba belongs to.
2799 * @param string $name the variable name the files belong to.
2800 * @param array $extra extra file path components.
2801 * @return string the rewritten text.
2802 */
2803 public function rewrite_response_pluginfile_urls($text, $contextid, $name, $extras) {
2804 return question_rewrite_question_urls($text, 'pluginfile.php', $contextid,
2805 'question', 'response_' . $name, $extras, $this->id);
2806 }
2807
d1b7e03d
TH
2808 /**
2809 * Get all the question type variables.
2810 * @param array name => value pairs.
2811 */
2812 public function get_qt_data() {
2813 $result = array();
2814 foreach ($this->data as $name => $value) {
2815 if ($name[0] != '-' && $name[0] != ':') {
2816 $result[$name] = $value;
2817 }
2818 }
2819 return $result;
2820 }
2821
2822 /**
2823 * @param string $name the name of an behaviour variable to look for in the submitted data.
f7970e3c 2824 * @return bool whether a variable with this name exists in the question type data.
d1b7e03d
TH
2825 */
2826 public function has_behaviour_var($name) {
2827 return array_key_exists('-' . $name, $this->data);
2828 }
2829
2830 /**
2831 * @param string $name the name of an behaviour variable to look for in the submitted data.
2832 * @return string the requested variable, or null if the variable is not set.
2833 */
2834 public function get_behaviour_var($name) {
2835 if (!$this->has_behaviour_var($name)) {
2836 return null;
2837 }
2838 return $this->data['-' . $name];
2839 }
2840
2841 /**
2842 * Set a cached behaviour variable.
2843 * @param string $name the name of the variable to set. Must match _[a-z][a-z0-9]*.
2844 * @param string $value the value to set.
2845 */
2846 public function set_behaviour_var($name, $value) {
2847 if ($name[0] != '_') {
88f0eb15 2848 throw new coding_exception('Cannot set question type data ' . $name . ' on an attempt step. You can only set variables with names begining with _.');
d1b7e03d
TH
2849 }
2850 return $this->data['-' . $name] = $value;
2851 }
2852
2853 /**
2854 * Get all the behaviour variables.
2855 * @param array name => value pairs.
2856 */
2857 public function get_behaviour_data() {
2858 $result = array();
2859 foreach ($this->data as $name => $value) {
2860 if ($name[0] == '-') {
2861 $result[substr($name, 1)] = $value;
2862 }
2863 }
2864 return $result;
2865 }
2866
2867 /**
2868 * Get all the submitted data, but not the cached data. behaviour
2869 * variables have the - at the start of their name. This is only really
2870 * intended for use by {@link question_attempt::regrade()}, it should not
2871 * be considered part of the public API.
2872 * @param array name => value pairs.
2873 */
2874 public function get_submitted_data() {
2875 $result = array();
2876 foreach ($this->data as $name => $value) {
2877 if ($name[0] == '_' || ($name[0] == '-' && $name[1] == '_')) {
2878 continue;
2879 }
2880 $result[$name] = $value;
2881 }
2882 return $result;
2883 }
2884
2885 /**
217f9a61 2886 * Get all the data. behaviour variables have the - at the start of
d1b7e03d
TH
2887 * their name. This is only intended for internal use, for example by
2888 * {@link question_engine_data_mapper::insert_question_attempt_step()},
2889 * however, it can ocasionally be useful in test code. It should not be
2890 * considered part of the public API of this class.
2891 * @param array name => value pairs.
2892 */
2893 public function get_all_data() {
2894 return $this->data;
2895 }
2896
2897 /**
2898 * Create a question_attempt_step from records loaded from the database.
2899 * @param array $records Raw records loaded from the database.
f7970e3c 2900 * @param int $stepid The id of the records to extract.
d1b7e03d
TH
2901 * @return question_attempt_step The newly constructed question_attempt_step.
2902 */
2903 public static function load_from_records(&$records, $attemptstepid) {
2904 $currentrec = current($records);
2905 while ($currentrec->attemptstepid != $attemptstepid) {
2906 $currentrec = next($records);
2907 if (!$currentrec) {
88f0eb15 2908 throw new coding_exception("Question attempt step $attemptstepid not found in the database.");
d1b7e03d
TH
2909 }
2910 }
2911
2912 $record = $currentrec;
2913 $data = array();
2914 while ($currentrec && $currentrec->attemptstepid == $attemptstepid) {
2915 if ($currentrec->name) {
2916 $data[$currentrec->name] = $currentrec->value;
2917 }
2918 $currentrec = next($records);
2919 }
2920
2921 $step = new question_attempt_step_read_only($data, $record->timecreated, $record->userid);
2922 $step->state = question_state::get($record->state);
caeeff07 2923 $step->id = $record->attemptstepid;
d1b7e03d
TH
2924 if (!is_null($record->fraction)) {
2925 $step->fraction = $record->fraction + 0;
2926 }
2927 return $step;
2928 }
2929}
2930
2931
2932/**
2933 * A subclass with a bit of additional funcitonality, for pending steps.
2934 *
f7970e3c 2935 * @copyright 2010 The Open University
017bc1d9 2936 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
2937 */
2938class question_attempt_pending_step extends question_attempt_step {
2939 /** @var string . */
2940 protected $newresponsesummary = null;
2941
2942 /**
2943 * If as a result of processing this step, the response summary for the
2944 * question attempt should changed, you should call this method to set the
2945 * new summary.
2946 * @param string $responsesummary the new response summary.
2947 */
2948 public function set_new_response_summary($responsesummary) {
2949 $this->newresponsesummary = $responsesummary;
2950 }
2951
2952 /** @return string the new response summary, if any. */
2953 public function get_new_response_summary() {
2954 return $this->newresponsesummary;
2955 }
2956
2957 /** @return string whether this step changes the response summary. */
2958 public function response_summary_changed() {
2959 return !is_null($this->newresponsesummary);
2960 }
2961}
2962
2963
2964/**
2965 * A subclass of {@link question_attempt_step} that cannot be modified.
2966 *
017bc1d9
TH
2967 * @copyright 2009 The Open University
2968 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
2969 */
2970class question_attempt_step_read_only extends question_attempt_step {
2971 public function set_state($state) {
2972 throw new coding_exception('Cannot modify a question_attempt_step_read_only.');
2973 }
2974 public function set_fraction($fraction) {
2975 throw new coding_exception('Cannot modify a question_attempt_step_read_only.');
2976 }
2977 public function set_qt_var($name, $value) {
2978 throw new coding_exception('Cannot modify a question_attempt_step_read_only.');
2979 }
2980 public function set_behaviour_var($name, $value) {
2981 throw new coding_exception('Cannot modify a question_attempt_step_read_only.');
2982 }
2983}
2984
2985
2986/**
2987 * A null {@link question_attempt_step} returned from
2988 * {@link question_attempt::get_last_step()} etc. when a an attempt has just been
2989 * created and there is no acutal step.
2990 *
017bc1d9
TH
2991 * @copyright 2009 The Open University
2992 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
2993 */
2994class question_null_step {
2995 public function get_state() {
2996 return question_state::$notstarted;
2997 }
2998
2999 public function set_state($state) {
88f0eb15 3000 throw new coding_exception('This question has not been started.');
d1b7e03d
TH
3001 }
3002
3003 public function get_fraction() {
3004 return null;
3005 }
3006}
3007
3008
3009/**
3010 * Interface for things that want to be notified of signficant changes to a
3011 * {@link question_usage_by_activity}.
3012 *
3013 * A question behaviour controls the flow of actions a student can
3014 * take as they work through a question, and later, as a teacher manually grades it.
3015 *
017bc1d9
TH
3016 * @copyright 2009 The Open University
3017 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
3018 */
3019interface question_usage_observer {
3020 /** Called when a field of the question_usage_by_activity is changed. */
3021 public function notify_modified();
3022
3023 /**
3024 * Called when the fields of a question attempt in this usage are modified.
3025 * @param question_attempt $qa the newly added question attempt.
3026 */
3027 public function notify_attempt_modified(question_attempt $qa);
3028
3029 /**
3030 * Called when a new question attempt is added to this usage.
3031 * @param question_attempt $qa the newly added question attempt.
3032 */
3033 public function notify_attempt_added(question_attempt $qa);
3034
3035 /**
3036 * Called we want to delete the old step records for an attempt, prior to
3037 * inserting newones. This is used by regrading.
3038 * @param question_attempt $qa the question attempt to delete the steps for.
3039 */
3040 public function notify_delete_attempt_steps(question_attempt $qa);
3041
3042 /**
3043 * Called when a new step is added to a question attempt in this usage.
3044 * @param $step the new step.
3045 * @param $qa the usage it is being added to.
3046 * @param $seq the sequence number of the new step.
3047 */
3048 public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq);
3049}
3050
3051
3052/**
3053 * Null implmentation of the {@link question_usage_watcher} interface.
3054 * Does nothing.
3055 *
017bc1d9
TH
3056 * @copyright 2009 The Open University
3057 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
3058 */
3059class question_usage_null_observer implements question_usage_observer {
3060 public function notify_modified() {
3061 }
3062 public function notify_attempt_modified(question_attempt $qa) {
3063 }
3064 public function notify_attempt_added(question_attempt $qa) {
3065 }
3066 public function notify_delete_attempt_steps(question_attempt $qa) {
3067 }
3068 public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
3069 }
3070}
3071
3072
3073/**
3074 * Useful functions for writing question types and behaviours.
3075 *
3076 * @copyright 2010 The Open University
017bc1d9 3077 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
3078 */
3079abstract class question_utils {
3080 /**
3081 * Tests to see whether two arrays have the same keys, with the same values
3082 * (as compared by ===) for each key. However, the order of the arrays does
3083 * not have to be the same.
3084 * @param array $array1 the first array.
3085 * @param array $array2 the second array.
f7970e3c 3086 * @return bool whether the two arrays have the same keys with the same
d1b7e03d
TH
3087 * corresponding values.
3088 */
3089 public static function arrays_have_same_keys_and_values(array $array1, array $array2) {
3090 if (count($array1) != count($array2)) {
3091 return false;
3092 }
3093 foreach ($array1 as $key => $value1) {
3094 if (!array_key_exists($key, $array2)) {
3095 return false;
3096 }
3097 if (((string) $value1) !== ((string) $array2[$key])) {
3098 return false;
3099 }
3100 }
3101 return true;
3102 }
3103
3104 /**
3105 * Tests to see whether two arrays have the same value at a particular key.
3106 * This method will return true if:
3107 * 1. Neither array contains the key; or
3108 * 2. Both arrays contain the key, and the corresponding values compare
3109 * identical when cast to strings and compared with ===.
3110 * @param array $array1 the first array.
3111 * @param array $array2 the second array.
3112 * @param string $key an array key.
f7970e3c 3113 * @return bool whether the two arrays have the same value (or lack of
d1b7e03d
TH
3114 * one) for a given key.
3115 */
3116 public static function arrays_same_at_key(array $array1, array $array2, $key) {
3117 if (array_key_exists($key, $array1) && array_key_exists($key, $array2)) {
3118 return ((string) $array1[$key]) === ((string) $array2[$key]);
3119 }
3120 if (!array_key_exists($key, $array1) && !array_key_exists($key, $array2)) {
3121 return true;
3122 }
3123 return false;
3124 }
3125
3126 /**
3127 * Tests to see whether two arrays have the same value at a particular key.
3128 * Missing values are replaced by '', and then the values are cast to
3129 * strings and compared with ===.
3130 * @param array $array1 the first array.
3131 * @param array $array2 the second array.
3132 * @param string $key an array key.
f7970e3c 3133 * @return bool whether the two arrays have the same value (or lack of
d1b7e03d
TH
3134 * one) for a given key.
3135 */
3136 public static function arrays_same_at_key_missing_is_blank(
3137 array $array1, array $array2, $key) {
3138 if (array_key_exists($key, $array1)) {
3139 $value1 = $array1[$key];
3140 } else {
3141 $value1 = '';
3142 }
3143 if (array_key_exists($key, $array2)) {
3144 $value2 = $array2[$key];
3145 } else {
3146 $value2 = '';
3147 }
3148 return ((string) $value1) === ((string) $value2);
3149 }
3150
3151 /**
3152 * Tests to see whether two arrays have the same value at a particular key.
3153 * Missing values are replaced by 0, and then the values are cast to
3154 * integers and compared with ===.
3155 * @param array $array1 the first array.
3156 * @param array $array2 the second array.
3157 * @param string $key an array key.
f7970e3c 3158 * @return bool whether the two arrays have the same value (or lack of
d1b7e03d
TH
3159 * one) for a given key.
3160 */
3161 public static function arrays_same_at_key_integer(
3162 array $array1, array $array2, $key) {
3163 if (array_key_exists($key, $array1)) {
3164 $value1 = $array1[$key];
3165 } else {
3166 $value1 = 0;
3167 }
3168 if (array_key_exists($key, $array2)) {
3169 $value2 = $array2[$key];
3170 } else {
3171 $value2 = 0;
3172 }
3173 return ((integer) $value1) === ((integer) $value2);
3174 }
3175
3176 private static $units = array('', 'i', 'ii', 'iii', 'iv', 'v', 'vi', 'vii', 'viii', 'ix');
3177 private static $tens = array('', 'x', 'xx', 'xxx', 'xl', 'l', 'lx', 'lxx', 'lxxx', 'xc');
3178 private static $hundreds = array('', 'c', 'cc', 'ccc', 'cd', 'd', 'dc', 'dcc', 'dccc', 'cm');
3179 private static $thousands = array('', 'm', 'mm', 'mmm');
3180
3181 /**
3182 * Convert an integer to roman numerals.
f7970e3c 3183 * @param int $number an integer between 1 and 3999 inclusive. Anything else will throw an exception.
d1b7e03d
TH
3184 * @return string the number converted to lower case roman numerals.
3185 */
3186 public static function int_to_roman($number) {
3187 if (!is_integer($number) || $number < 1 || $number > 3999) {
3188 throw new coding_exception('Only integers between 0 and 3999 can be ' .
3189 'converted to roman numerals.', $number);
3190 }
3191
3192 return self::$thousands[$number / 1000 % 10] . self::$hundreds[$number / 100 % 10] .
3193 self::$tens[$number / 10 % 10] . self::$units[$number % 10];
3194 }
3195}