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