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