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