MDL-38538 question unit tests: improve things a bit.
[moodle.git] / question / engine / questionattempt.php
CommitLineData
dcd03928 1<?php
dcd03928
TH
2// This file is part of Moodle - http://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16
17/**
18 * This file defines the question attempt class, and a few related classes.
19 *
20 * @package moodlecore
21 * @subpackage questionengine
22 * @copyright 2009 The Open University
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 */
25
26
27defined('MOODLE_INTERNAL') || die();
28
29
30/**
31 * Tracks an attempt at one particular question in a {@link question_usage_by_activity}.
32 *
33 * Most calling code should need to access objects of this class. They should be
34 * able to do everything through the usage interface. This class is an internal
35 * implementation detail of the question engine.
36 *
37 * Instances of this class correspond to rows in the question_attempts table, and
38 * a collection of {@link question_attempt_steps}. Question inteaction models and
39 * question types do work with question_attempt objects.
40 *
41 * @copyright 2009 The Open University
42 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
43 */
44class question_attempt {
45 /**
46 * @var string this is a magic value that question types can return from
47 * {@link question_definition::get_expected_data()}.
48 */
49 const USE_RAW_DATA = 'use raw data';
50
51 /**
61cca0b7 52 * @var string special value used by manual grading because {@link PARAM_FLOAT}
dcd03928
TH
53 * converts '' to 0.
54 */
55 const PARAM_MARK = 'parammark';
56
57 /**
58 * @var string special value to indicate a response variable that is uploaded
59 * files.
60 */
61 const PARAM_FILES = 'paramfiles';
62
63 /**
64 * @var string special value to indicate a response variable that is uploaded
65 * files.
66 */
2d2018ab 67 const PARAM_RAW_FILES = 'paramrawfiles';
dcd03928
TH
68
69 /** @var integer if this attempts is stored in the question_attempts table, the id of that row. */
70 protected $id = null;
71
72 /** @var integer|string the id of the question_usage_by_activity we belong to. */
73 protected $usageid;
74
75 /** @var integer the number used to identify this question_attempt within the usage. */
76 protected $slot = null;
77
78 /**
79 * @var question_behaviour the behaviour controlling this attempt.
80 * null until {@link start()} is called.
81 */
82 protected $behaviour = null;
83
84 /** @var question_definition the question this is an attempt at. */
85 protected $question;
86
1da821bb
TH
87 /** @var int which variant of the question to use. */
88 protected $variant;
89
dcd03928
TH
90 /** @var number the maximum mark that can be scored at this question. */
91 protected $maxmark;
92
93 /**
94 * @var number the minimum fraction that can be scored at this question, so
95 * the minimum mark is $this->minfraction * $this->maxmark.
96 */
97 protected $minfraction = null;
98
99 /**
100 * @var string plain text summary of the variant of the question the
101 * student saw. Intended for reporting purposes.
102 */
103 protected $questionsummary = null;
104
105 /**
106 * @var string plain text summary of the response the student gave.
107 * Intended for reporting purposes.
108 */
109 protected $responsesummary = null;
110
111 /**
112 * @var string plain text summary of the correct response to this question
113 * variant the student saw. The format should be similar to responsesummary.
114 * Intended for reporting purposes.
115 */
116 protected $rightanswer = null;
117
118 /** @var array of {@link question_attempt_step}s. The steps in this attempt. */
119 protected $steps = array();
120
121 /** @var boolean whether the user has flagged this attempt within the usage. */
122 protected $flagged = false;
123
124 /** @var question_usage_observer tracks changes to the useage this attempt is part of.*/
125 protected $observer;
126
127 /**#@+
128 * Constants used by the intereaction models to indicate whether the current
129 * pending step should be kept or discarded.
130 */
131 const KEEP = true;
132 const DISCARD = false;
133 /**#@-*/
134
135 /**
136 * Create a new {@link question_attempt}. Normally you should create question_attempts
137 * indirectly, by calling {@link question_usage_by_activity::add_question()}.
138 *
139 * @param question_definition $question the question this is an attempt at.
140 * @param int|string $usageid The id of the
141 * {@link question_usage_by_activity} we belong to. Used by {@link get_field_prefix()}.
142 * @param question_usage_observer $observer tracks changes to the useage this
143 * attempt is part of. (Optional, a {@link question_usage_null_observer} is
144 * used if one is not passed.
145 * @param number $maxmark the maximum grade for this question_attempt. If not
146 * passed, $question->defaultmark is used.
147 */
148 public function __construct(question_definition $question, $usageid,
149 question_usage_observer $observer = null, $maxmark = null) {
150 $this->question = $question;
151 $this->usageid = $usageid;
152 if (is_null($observer)) {
153 $observer = new question_usage_null_observer();
154 }
155 $this->observer = $observer;
156 if (!is_null($maxmark)) {
157 $this->maxmark = $maxmark;
158 } else {
159 $this->maxmark = $question->defaultmark;
160 }
161 }
162
f123d67f
TH
163 /**
164 * This method exists so that {@link question_attempt_with_restricted_history}
165 * can override it. You should not normally need to call it.
166 * @return question_attempt return ourself.
167 */
168 public function get_full_qa() {
169 return $this;
170 }
171
dcd03928
TH
172 /** @return question_definition the question this is an attempt at. */
173 public function get_question() {
174 return $this->question;
175 }
176
1da821bb
TH
177 /**
178 * Get the variant of the question being used in a given slot.
179 * @return int the variant number.
180 */
181 public function get_variant() {
182 return $this->variant;
183 }
184
dcd03928
TH
185 /**
186 * Set the number used to identify this question_attempt within the usage.
187 * For internal use only.
188 * @param int $slot
189 */
94815ccf 190 public function set_slot($slot) {
dcd03928
TH
191 $this->slot = $slot;
192 }
193
194 /** @return int the number used to identify this question_attempt within the usage. */
195 public function get_slot() {
196 return $this->slot;
197 }
198
199 /**
200 * @return int the id of row for this question_attempt, if it is stored in the
201 * database. null if not.
202 */
203 public function get_database_id() {
204 return $this->id;
205 }
206
207 /**
208 * For internal use only. Set the id of the corresponding database row.
209 * @param int $id the id of row for this question_attempt, if it is
210 * stored in the database.
211 */
212 public function set_database_id($id) {
213 $this->id = $id;
214 }
215
94815ccf
TH
216 /**
217 * You should almost certainly not call this method from your code. It is for
218 * internal use only.
219 * @param question_usage_observer that should be used to tracking changes made to this qa.
220 */
221 public function set_observer($observer) {
222 $this->observer = $observer;
223 }
224
dcd03928
TH
225 /** @return int|string the id of the {@link question_usage_by_activity} we belong to. */
226 public function get_usage_id() {
227 return $this->usageid;
228 }
229
230 /**
231 * Set the id of the {@link question_usage_by_activity} we belong to.
232 * For internal use only.
233 * @param int|string the new id.
234 */
235 public function set_usage_id($usageid) {
236 $this->usageid = $usageid;
237 }
238
239 /** @return string the name of the behaviour that is controlling this attempt. */
240 public function get_behaviour_name() {
241 return $this->behaviour->get_name();
242 }
243
244 /**
245 * For internal use only.
246 * @return question_behaviour the behaviour that is controlling this attempt.
247 */
248 public function get_behaviour() {
249 return $this->behaviour;
250 }
251
252 /**
253 * Set the flagged state of this question.
254 * @param bool $flagged the new state.
255 */
256 public function set_flagged($flagged) {
257 $this->flagged = $flagged;
258 $this->observer->notify_attempt_modified($this);
259 }
260
261 /** @return bool whether this question is currently flagged. */
262 public function is_flagged() {
263 return $this->flagged;
264 }
265
266 /**
267 * Get the name (in the sense a HTML name="" attribute, or a $_POST variable
268 * name) to use for the field that indicates whether this question is flagged.
269 *
270 * @return string The field name to use.
271 */
272 public function get_flag_field_name() {
273 return $this->get_control_field_name('flagged');
274 }
275
276 /**
277 * Get the name (in the sense a HTML name="" attribute, or a $_POST variable
278 * name) to use for a question_type variable belonging to this question_attempt.
279 *
280 * See the comment on {@link question_attempt_step} for an explanation of
281 * question type and behaviour variables.
282 *
283 * @param $varname The short form of the variable name.
284 * @return string The field name to use.
285 */
286 public function get_qt_field_name($varname) {
287 return $this->get_field_prefix() . $varname;
288 }
289
290 /**
291 * Get the name (in the sense a HTML name="" attribute, or a $_POST variable
292 * name) to use for a question_type variable belonging to this question_attempt.
293 *
294 * See the comment on {@link question_attempt_step} for an explanation of
295 * question type and behaviour variables.
296 *
297 * @param $varname The short form of the variable name.
298 * @return string The field name to use.
299 */
300 public function get_behaviour_field_name($varname) {
301 return $this->get_field_prefix() . '-' . $varname;
302 }
303
304 /**
305 * Get the name (in the sense a HTML name="" attribute, or a $_POST variable
306 * name) to use for a control variables belonging to this question_attempt.
307 *
308 * Examples are :sequencecheck and :flagged
309 *
310 * @param $varname The short form of the variable name.
311 * @return string The field name to use.
312 */
313 public function get_control_field_name($varname) {
314 return $this->get_field_prefix() . ':' . $varname;
315 }
316
317 /**
318 * Get the prefix added to variable names to give field names for this
319 * question attempt.
320 *
321 * You should not use this method directly. This is an implementation detail
322 * anyway, but if you must access it, use {@link question_usage_by_activity::get_field_prefix()}.
323 *
324 * @param $varname The short form of the variable name.
325 * @return string The field name to use.
326 */
327 public function get_field_prefix() {
328 return 'q' . $this->usageid . ':' . $this->slot . '_';
329 }
330
331 /**
332 * Get one of the steps in this attempt.
333 * For internal/test code use only.
334 * @param int $i the step number.
335 * @return question_attempt_step
336 */
337 public function get_step($i) {
338 if ($i < 0 || $i >= count($this->steps)) {
339 throw new coding_exception('Index out of bounds in question_attempt::get_step.');
340 }
341 return $this->steps[$i];
342 }
343
344 /**
345 * Get the number of steps in this attempt.
346 * For internal/test code use only.
347 * @return int the number of steps we currently have.
348 */
349 public function get_num_steps() {
350 return count($this->steps);
351 }
352
353 /**
354 * Return the latest step in this question_attempt.
355 * For internal/test code use only.
356 * @return question_attempt_step
357 */
358 public function get_last_step() {
359 if (count($this->steps) == 0) {
360 return new question_null_step();
361 }
362 return end($this->steps);
363 }
364
365 /**
366 * @return question_attempt_step_iterator for iterating over the steps in
367 * this attempt, in order.
368 */
369 public function get_step_iterator() {
370 return new question_attempt_step_iterator($this);
371 }
372
373 /**
374 * The same as {@link get_step_iterator()}. However, for a
375 * {@link question_attempt_with_restricted_history} this returns the full
376 * list of steps, while {@link get_step_iterator()} returns only the
377 * limited history.
378 * @return question_attempt_step_iterator for iterating over the steps in
379 * this attempt, in order.
380 */
381 public function get_full_step_iterator() {
382 return $this->get_step_iterator();
383 }
384
385 /**
386 * @return question_attempt_reverse_step_iterator for iterating over the steps in
387 * this attempt, in reverse order.
388 */
389 public function get_reverse_step_iterator() {
390 return new question_attempt_reverse_step_iterator($this);
391 }
392
393 /**
394 * Get the qt data from the latest step that has any qt data. Return $default
395 * array if it is no step has qt data.
396 *
397 * @param string $name the name of the variable to get.
398 * @param mixed default the value to return no step has qt data.
399 * (Optional, defaults to an empty array.)
400 * @return array|mixed the data, or $default if there is not any.
401 */
402 public function get_last_qt_data($default = array()) {
403 foreach ($this->get_reverse_step_iterator() as $step) {
404 $response = $step->get_qt_data();
405 if (!empty($response)) {
406 return $response;
407 }
408 }
409 return $default;
410 }
411
412 /**
413 * Get the last step with a particular question type varialbe set.
414 * @param string $name the name of the variable to get.
415 * @return question_attempt_step the last step, or a step with no variables
416 * if there was not a real step.
417 */
418 public function get_last_step_with_qt_var($name) {
419 foreach ($this->get_reverse_step_iterator() as $step) {
420 if ($step->has_qt_var($name)) {
421 return $step;
422 }
cc9fc649
HB
423 }
424 return new question_attempt_step_read_only();
425 }
426
427 /**
428 * Get the last step with a particular behaviour variable set.
429 * @param string $name the name of the variable to get.
430 * @return question_attempt_step the last step, or a step with no variables
431 * if there was not a real step.
432 */
433 public function get_last_step_with_behaviour_var($name) {
434 foreach ($this->get_reverse_step_iterator() as $step) {
435 if ($step->has_behaviour_var($name)) {
436 return $step;
437 }
dcd03928
TH
438 }
439 return new question_attempt_step_read_only();
440 }
441
442 /**
443 * Get the latest value of a particular question type variable. That is, get
444 * the value from the latest step that has it set. Return null if it is not
445 * set in any step.
446 *
447 * @param string $name the name of the variable to get.
448 * @param mixed default the value to return in the variable has never been set.
449 * (Optional, defaults to null.)
450 * @return mixed string value, or $default if it has never been set.
451 */
452 public function get_last_qt_var($name, $default = null) {
453 $step = $this->get_last_step_with_qt_var($name);
454 if ($step->has_qt_var($name)) {
455 return $step->get_qt_var($name);
456 } else {
457 return $default;
458 }
459 }
460
461 /**
462 * Get the latest set of files for a particular question type variable of
463 * type question_attempt::PARAM_FILES.
464 *
465 * @param string $name the name of the associated variable.
466 * @return array of {@link stored_files}.
467 */
468 public function get_last_qt_files($name, $contextid) {
469 foreach ($this->get_reverse_step_iterator() as $step) {
470 if ($step->has_qt_var($name)) {
471 return $step->get_qt_files($name, $contextid);
472 }
473 }
474 return array();
475 }
476
477 /**
478 * Get the URL of a file that belongs to a response variable of this
479 * question_attempt.
480 * @param stored_file $file the file to link to.
481 * @return string the URL of that file.
482 */
483 public function get_response_file_url(stored_file $file) {
484 return file_encode_url(new moodle_url('/pluginfile.php'), '/' . implode('/', array(
485 $file->get_contextid(),
486 $file->get_component(),
487 $file->get_filearea(),
488 $this->usageid,
489 $this->slot,
490 $file->get_itemid())) .
491 $file->get_filepath() . $file->get_filename(), true);
492 }
493
494 /**
495 * Prepare a draft file are for the files belonging the a response variable
496 * of this question attempt. The draft area is populated with the files from
497 * the most recent step having files.
498 *
499 * @param string $name the variable name the files belong to.
500 * @param int $contextid the id of the context the quba belongs to.
501 * @return int the draft itemid.
502 */
503 public function prepare_response_files_draft_itemid($name, $contextid) {
504 foreach ($this->get_reverse_step_iterator() as $step) {
505 if ($step->has_qt_var($name)) {
506 return $step->prepare_response_files_draft_itemid($name, $contextid);
507 }
508 }
509
510 // No files yet.
511 $draftid = 0; // Will be filled in by file_prepare_draft_area.
512 file_prepare_draft_area($draftid, $contextid, 'question', 'response_' . $name, null);
513 return $draftid;
514 }
515
516 /**
517 * Get the latest value of a particular behaviour variable. That is,
518 * get the value from the latest step that has it set. Return null if it is
519 * not set in any step.
520 *
521 * @param string $name the name of the variable to get.
522 * @param mixed default the value to return in the variable has never been set.
523 * (Optional, defaults to null.)
524 * @return mixed string value, or $default if it has never been set.
525 */
526 public function get_last_behaviour_var($name, $default = null) {
527 foreach ($this->get_reverse_step_iterator() as $step) {
528 if ($step->has_behaviour_var($name)) {
529 return $step->get_behaviour_var($name);
530 }
531 }
532 return $default;
533 }
534
535 /**
536 * Get the current state of this question attempt. That is, the state of the
537 * latest step.
538 * @return question_state
539 */
540 public function get_state() {
541 return $this->get_last_step()->get_state();
542 }
543
544 /**
545 * @param bool $showcorrectness Whether right/partial/wrong states should
546 * be distinguised.
547 * @return string A brief textual description of the current state.
548 */
549 public function get_state_string($showcorrectness) {
550 return $this->behaviour->get_state_string($showcorrectness);
551 }
552
97cdc1de
TH
553 /**
554 * @param bool $showcorrectness Whether right/partial/wrong states should
555 * be distinguised.
556 * @return string a CSS class name for the current state.
557 */
558 public function get_state_class($showcorrectness) {
559 return $this->get_state()->get_state_class($showcorrectness);
560 }
561
dcd03928
TH
562 /**
563 * @return int the timestamp of the most recent step in this question attempt.
564 */
565 public function get_last_action_time() {
566 return $this->get_last_step()->get_timecreated();
567 }
568
569 /**
570 * Get the current fraction of this question attempt. That is, the fraction
571 * of the latest step, or null if this question has not yet been graded.
572 * @return number the current fraction.
573 */
574 public function get_fraction() {
575 return $this->get_last_step()->get_fraction();
576 }
577
578 /** @return bool whether this question attempt has a non-zero maximum mark. */
579 public function has_marks() {
580 // Since grades are stored in the database as NUMBER(12,7).
581 return $this->maxmark >= 0.00000005;
582 }
583
584 /**
585 * @return number the current mark for this question.
586 * {@link get_fraction()} * {@link get_max_mark()}.
587 */
588 public function get_mark() {
589 return $this->fraction_to_mark($this->get_fraction());
590 }
591
592 /**
593 * This is used by the manual grading code, particularly in association with
594 * validation. If there is a mark submitted in the request, then use that,
595 * otherwise use the latest mark for this question.
596 * @return number the current mark for this question.
597 * {@link get_fraction()} * {@link get_max_mark()}.
598 */
599 public function get_current_manual_mark() {
600 $mark = $this->get_submitted_var($this->get_behaviour_field_name('mark'), question_attempt::PARAM_MARK);
601 if (is_null($mark)) {
602 return $this->get_mark();
603 } else {
604 return $mark;
605 }
606 }
607
608 /**
609 * @param number|null $fraction a fraction.
610 * @return number|null the corresponding mark.
611 */
612 public function fraction_to_mark($fraction) {
613 if (is_null($fraction)) {
614 return null;
615 }
616 return $fraction * $this->maxmark;
617 }
618
619 /** @return number the maximum mark possible for this question attempt. */
620 public function get_max_mark() {
621 return $this->maxmark;
622 }
623
624 /** @return number the maximum mark possible for this question attempt. */
625 public function get_min_fraction() {
626 if (is_null($this->minfraction)) {
627 throw new coding_exception('This question_attempt has not been started yet, the min fraction is not yet konwn.');
628 }
629 return $this->minfraction;
630 }
631
632 /**
633 * The current mark, formatted to the stated number of decimal places. Uses
634 * {@link format_float()} to format floats according to the current locale.
635 * @param int $dp number of decimal places.
636 * @return string formatted mark.
637 */
638 public function format_mark($dp) {
639 return $this->format_fraction_as_mark($this->get_fraction(), $dp);
640 }
641
642 /**
643 * The current mark, formatted to the stated number of decimal places. Uses
644 * {@link format_float()} to format floats according to the current locale.
645 * @param int $dp number of decimal places.
646 * @return string formatted mark.
647 */
648 public function format_fraction_as_mark($fraction, $dp) {
649 return format_float($this->fraction_to_mark($fraction), $dp);
650 }
651
652 /**
653 * The maximum mark for this question attempt, formatted to the stated number
654 * of decimal places. Uses {@link format_float()} to format floats according
655 * to the current locale.
656 * @param int $dp number of decimal places.
657 * @return string formatted maximum mark.
658 */
659 public function format_max_mark($dp) {
660 return format_float($this->maxmark, $dp);
661 }
662
663 /**
664 * Return the hint that applies to the question in its current state, or null.
665 * @return question_hint|null
666 */
667 public function get_applicable_hint() {
668 return $this->behaviour->get_applicable_hint();
669 }
670
671 /**
672 * Produce a plain-text summary of what the user did during a step.
673 * @param question_attempt_step $step the step in quetsion.
674 * @return string a summary of what was done during that step.
675 */
676 public function summarise_action(question_attempt_step $step) {
677 return $this->behaviour->summarise_action($step);
678 }
679
680 /**
681 * Helper function used by {@link rewrite_pluginfile_urls()} and
682 * {@link rewrite_response_pluginfile_urls()}.
683 * @return array ids that need to go into the file paths.
684 */
685 protected function extra_file_path_components() {
686 return array($this->get_usage_id(), $this->get_slot());
687 }
688
689 /**
690 * Calls {@link question_rewrite_question_urls()} with appropriate parameters
691 * for content belonging to this question.
692 * @param string $text the content to output.
693 * @param string $component the component name (normally 'question' or 'qtype_...')
694 * @param string $filearea the name of the file area.
695 * @param int $itemid the item id.
696 * @return srting the content with the URLs rewritten.
697 */
698 public function rewrite_pluginfile_urls($text, $component, $filearea, $itemid) {
699 return question_rewrite_question_urls($text, 'pluginfile.php',
700 $this->question->contextid, $component, $filearea,
701 $this->extra_file_path_components(), $itemid);
702 }
703
704 /**
705 * Calls {@link question_rewrite_question_urls()} with appropriate parameters
706 * for content belonging to responses to this question.
707 *
708 * @param string $text the text to update the URLs in.
709 * @param int $contextid the id of the context the quba belongs to.
710 * @param string $name the variable name the files belong to.
711 * @param question_attempt_step $step the step the response is coming from.
712 * @return srting the content with the URLs rewritten.
713 */
714 public function rewrite_response_pluginfile_urls($text, $contextid, $name,
715 question_attempt_step $step) {
716 return $step->rewrite_response_pluginfile_urls($text, $contextid, $name,
717 $this->extra_file_path_components());
718 }
719
720 /**
721 * Get the {@link core_question_renderer}, in collaboration with appropriate
722 * {@link qbehaviour_renderer} and {@link qtype_renderer} subclasses, to generate the
723 * HTML to display this question attempt in its current state.
724 * @param question_display_options $options controls how the question is rendered.
725 * @param string|null $number The question number to display.
726 * @return string HTML fragment representing the question.
727 */
728 public function render($options, $number, $page = null) {
729 if (is_null($page)) {
730 global $PAGE;
731 $page = $PAGE;
732 }
733 $qoutput = $page->get_renderer('core', 'question');
734 $qtoutput = $this->question->get_renderer($page);
735 return $this->behaviour->render($options, $number, $qoutput, $qtoutput);
736 }
737
738 /**
739 * Generate any bits of HTML that needs to go in the <head> tag when this question
740 * attempt is displayed in the body.
741 * @return string HTML fragment.
742 */
743 public function render_head_html($page = null) {
744 if (is_null($page)) {
745 global $PAGE;
746 $page = $PAGE;
747 }
748 // TODO go via behaviour.
749 return $this->question->get_renderer($page)->head_code($this) .
750 $this->behaviour->get_renderer($page)->head_code($this);
751 }
752
753 /**
754 * Like {@link render_question()} but displays the question at the past step
755 * indicated by $seq, rather than showing the latest step.
756 *
757 * @param int $seq the seq number of the past state to display.
758 * @param question_display_options $options controls how the question is rendered.
759 * @param string|null $number The question number to display. 'i' is a special
760 * value that gets displayed as Information. Null means no number is displayed.
761 * @return string HTML fragment representing the question.
762 */
763 public function render_at_step($seq, $options, $number, $preferredbehaviour) {
764 $restrictedqa = new question_attempt_with_restricted_history($this, $seq, $preferredbehaviour);
765 return $restrictedqa->render($options, $number);
766 }
767
768 /**
769 * Checks whether the users is allow to be served a particular file.
770 * @param question_display_options $options the options that control display of the question.
771 * @param string $component the name of the component we are serving files for.
772 * @param string $filearea the name of the file area.
773 * @param array $args the remaining bits of the file path.
774 * @param bool $forcedownload whether the user must be forced to download the file.
775 * @return bool true if the user can access this file.
776 */
777 public function check_file_access($options, $component, $filearea, $args, $forcedownload) {
778 return $this->behaviour->check_file_access($options, $component, $filearea, $args, $forcedownload);
779 }
780
781 /**
782 * Add a step to this question attempt.
783 * @param question_attempt_step $step the new step.
784 */
785 protected function add_step(question_attempt_step $step) {
786 $this->steps[] = $step;
787 end($this->steps);
788 $this->observer->notify_step_added($step, $this, key($this->steps));
789 }
790
1da821bb
TH
791 /**
792 * Use a strategy to pick a variant.
793 * @param question_variant_selection_strategy $variantstrategy a strategy.
794 * @return int the selected variant.
795 */
796 public function select_variant(question_variant_selection_strategy $variantstrategy) {
797 return $variantstrategy->choose_variant($this->get_question()->get_num_variants(),
798 $this->get_question()->get_variants_selection_seed());
799 }
800
dcd03928
TH
801 /**
802 * Start this question attempt.
803 *
804 * You should not call this method directly. Call
805 * {@link question_usage_by_activity::start_question()} instead.
806 *
807 * @param string|question_behaviour $preferredbehaviour the name of the
808 * desired archetypal behaviour, or an actual model instance.
1da821bb
TH
809 * @param int $variant the variant of the question to start. Between 1 and
810 * $this->get_question()->get_num_variants() inclusive.
dcd03928
TH
811 * @param array $submitteddata optional, used when re-starting to keep the same initial state.
812 * @param int $timestamp optional, the timstamp to record for this action. Defaults to now.
813 * @param int $userid optional, the user to attribute this action to. Defaults to the current user.
94815ccf
TH
814 * @param int $existingstepid optional, if this step is going to replace an existing step
815 * (for example, during a regrade) this is the id of the previous step we are replacing.
dcd03928 816 */
1da821bb 817 public function start($preferredbehaviour, $variant, $submitteddata = array(),
94815ccf 818 $timestamp = null, $userid = null, $existingstepid = null) {
1da821bb 819
dcd03928 820 // Initialise the behaviour.
1da821bb 821 $this->variant = $variant;
dcd03928
TH
822 if (is_string($preferredbehaviour)) {
823 $this->behaviour =
824 $this->question->make_behaviour($this, $preferredbehaviour);
825 } else {
826 $class = get_class($preferredbehaviour);
827 $this->behaviour = new $class($this, $preferredbehaviour);
828 }
829
830 // Record the minimum fraction.
831 $this->minfraction = $this->behaviour->get_min_fraction();
832
833 // Initialise the first step.
94815ccf 834 $firststep = new question_attempt_step($submitteddata, $timestamp, $userid, $existingstepid);
dcd03928
TH
835 $firststep->set_state(question_state::$todo);
836 if ($submitteddata) {
837 $this->question->apply_attempt_state($firststep);
838 } else {
1da821bb 839 $this->behaviour->init_first_step($firststep, $variant);
dcd03928
TH
840 }
841 $this->add_step($firststep);
842
843 // Record questionline and correct answer.
844 $this->questionsummary = $this->behaviour->get_question_summary();
845 $this->rightanswer = $this->behaviour->get_right_answer_summary();
846 }
847
848 /**
849 * Start this question attempt, starting from the point that the previous
850 * attempt $oldqa had reached.
851 *
852 * You should not call this method directly. Call
853 * {@link question_usage_by_activity::start_question_based_on()} instead.
854 *
855 * @param question_attempt $oldqa a previous attempt at this quetsion that
856 * defines the starting point.
857 */
858 public function start_based_on(question_attempt $oldqa) {
1da821bb 859 $this->start($oldqa->behaviour, $oldqa->get_variant(), $oldqa->get_resume_data());
dcd03928
TH
860 }
861
862 /**
863 * Used by {@link start_based_on()} to get the data needed to start a new
864 * attempt from the point this attempt has go to.
865 * @return array name => value pairs.
866 */
867 protected function get_resume_data() {
868 return $this->behaviour->get_resume_data();
869 }
870
871 /**
872 * Get a particular parameter from the current request. A wrapper round
873 * {@link optional_param()}, except that the results is returned without
874 * slashes.
875 * @param string $name the paramter name.
876 * @param int $type one of the standard PARAM_... constants, or one of the
877 * special extra constands defined by this class.
878 * @param array $postdata (optional, only inteded for testing use) take the
879 * data from this array, instead of from $_POST.
880 * @return mixed the requested value.
881 */
882 public function get_submitted_var($name, $type, $postdata = null) {
883 switch ($type) {
884 case self::PARAM_MARK:
61cca0b7 885 // Special case to work around PARAM_FLOAT converting '' to 0.
2c76c894 886 return question_utils::clean_param_mark($this->get_submitted_var($name, PARAM_RAW_TRIMMED, $postdata));
dcd03928
TH
887
888 case self::PARAM_FILES:
889 return $this->process_response_files($name, $name, $postdata);
890
2d2018ab
TH
891 case self::PARAM_RAW_FILES:
892 $var = $this->get_submitted_var($name, PARAM_RAW, $postdata);
dcd03928
TH
893 return $this->process_response_files($name, $name . ':itemid', $postdata, $var);
894
895 default:
896 if (is_null($postdata)) {
897 $var = optional_param($name, null, $type);
898 } else if (array_key_exists($name, $postdata)) {
899 $var = clean_param($postdata[$name], $type);
900 } else {
901 $var = null;
902 }
903
904 return $var;
905 }
906 }
907
908 /**
909 * Handle a submitted variable representing uploaded files.
910 * @param string $name the field name.
911 * @param string $draftidname the field name holding the draft file area id.
912 * @param array $postdata (optional, only inteded for testing use) take the
913 * data from this array, instead of from $_POST. At the moment, this
914 * behaves as if there were no files.
915 * @param string $text optional reponse text.
916 * @return question_file_saver that can be used to save the files later.
917 */
918 protected function process_response_files($name, $draftidname, $postdata = null, $text = null) {
919 if ($postdata) {
920 // There can be no files with test data (at the moment).
921 return null;
922 }
923
924 $draftitemid = file_get_submitted_draft_itemid($draftidname);
925 if (!$draftitemid) {
926 return null;
927 }
928
929 return new question_file_saver($draftitemid, 'question', 'response_' .
930 str_replace($this->get_field_prefix(), '', $name), $text);
931 }
932
933 /**
934 * Get any data from the request that matches the list of expected params.
935 * @param array $expected variable name => PARAM_... constant.
936 * @param string $extraprefix '-' or ''.
937 * @return array name => value.
938 */
939 protected function get_expected_data($expected, $postdata, $extraprefix) {
940 $submitteddata = array();
941 foreach ($expected as $name => $type) {
942 $value = $this->get_submitted_var(
943 $this->get_field_prefix() . $extraprefix . $name, $type, $postdata);
944 if (!is_null($value)) {
945 $submitteddata[$extraprefix . $name] = $value;
946 }
947 }
948 return $submitteddata;
949 }
950
951 /**
952 * Get all the submitted question type data for this question, whithout checking
953 * that it is valid or cleaning it in any way.
954 * @return array name => value.
955 */
956 protected function get_all_submitted_qt_vars($postdata) {
957 if (is_null($postdata)) {
958 $postdata = $_POST;
959 }
960
03cefcc9 961 $pattern = '/^' . preg_quote($this->get_field_prefix(), '/') . '[^-:]/';
dcd03928
TH
962 $prefixlen = strlen($this->get_field_prefix());
963
964 $submitteddata = array();
965 foreach ($_POST as $name => $value) {
966 if (preg_match($pattern, $name)) {
967 $submitteddata[substr($name, $prefixlen)] = $value;
968 }
969 }
970
971 return $submitteddata;
972 }
973
974 /**
975 * Get all the sumbitted data belonging to this question attempt from the
976 * current request.
977 * @param array $postdata (optional, only inteded for testing use) take the
978 * data from this array, instead of from $_POST.
979 * @return array name => value pairs that could be passed to {@link process_action()}.
980 */
981 public function get_submitted_data($postdata = null) {
982 $submitteddata = $this->get_expected_data(
983 $this->behaviour->get_expected_data(), $postdata, '-');
984
985 $expected = $this->behaviour->get_expected_qt_data();
986 if ($expected === self::USE_RAW_DATA) {
987 $submitteddata += $this->get_all_submitted_qt_vars($postdata);
988 } else {
989 $submitteddata += $this->get_expected_data($expected, $postdata, '');
990 }
991 return $submitteddata;
992 }
993
994 /**
995 * Get a set of response data for this question attempt that would get the
da22c012
K
996 * best possible mark. If it is not possible to compute a correct
997 * response, this method should return null.
998 * @return array|null name => value pairs that could be passed to {@link process_action()}.
dcd03928
TH
999 */
1000 public function get_correct_response() {
1001 $response = $this->question->get_correct_response();
da22c012
K
1002 if (is_null($response)) {
1003 return null;
1004 }
dcd03928
TH
1005 $imvars = $this->behaviour->get_correct_response();
1006 foreach ($imvars as $name => $value) {
1007 $response['-' . $name] = $value;
1008 }
1009 return $response;
1010 }
1011
1012 /**
1013 * Change the quetsion summary. Note, that this is almost never necessary.
1014 * This method was only added to work around a limitation of the Opaque
1015 * protocol, which only sends questionLine at the end of an attempt.
1016 * @param $questionsummary the new summary to set.
1017 */
1018 public function set_question_summary($questionsummary) {
1019 $this->questionsummary = $questionsummary;
1020 $this->observer->notify_attempt_modified($this);
1021 }
1022
1023 /**
1024 * @return string a simple textual summary of the question that was asked.
1025 */
1026 public function get_question_summary() {
1027 return $this->questionsummary;
1028 }
1029
1030 /**
1031 * @return string a simple textual summary of response given.
1032 */
1033 public function get_response_summary() {
1034 return $this->responsesummary;
1035 }
1036
1037 /**
1038 * @return string a simple textual summary of the correct resonse.
1039 */
1040 public function get_right_answer_summary() {
1041 return $this->rightanswer;
1042 }
1043
1044 /**
1045 * Perform the action described by $submitteddata.
1046 * @param array $submitteddata the submitted data the determines the action.
1047 * @param int $timestamp the time to record for the action. (If not given, use now.)
1048 * @param int $userid the user to attribute the aciton to. (If not given, use the current user.)
1049 */
94815ccf
TH
1050 public function process_action($submitteddata, $timestamp = null, $userid = null, $existingstepid = null) {
1051 $pendingstep = new question_attempt_pending_step($submitteddata, $timestamp, $userid, $existingstepid);
dcd03928
TH
1052 if ($this->behaviour->process_action($pendingstep) == self::KEEP) {
1053 $this->add_step($pendingstep);
1054 if ($pendingstep->response_summary_changed()) {
1055 $this->responsesummary = $pendingstep->get_new_response_summary();
1056 }
1057 }
1058 }
1059
1060 /**
1061 * Perform a finish action on this question attempt. This corresponds to an
1062 * external finish action, for example the user pressing Submit all and finish
1063 * in the quiz, rather than using one of the controls that is part of the
1064 * question.
1065 *
1066 * @param int $timestamp the time to record for the action. (If not given, use now.)
1067 * @param int $userid the user to attribute the aciton to. (If not given, use the current user.)
1068 */
1069 public function finish($timestamp = null, $userid = null) {
1070 $this->process_action(array('-finish' => 1), $timestamp, $userid);
1071 }
1072
1073 /**
1074 * Perform a regrade. This replays all the actions from $oldqa into this
1075 * attempt.
1076 * @param question_attempt $oldqa the attempt to regrade.
1077 * @param bool $finished whether the question attempt should be forced to be finished
1078 * after the regrade, or whether it may still be in progress (default false).
1079 */
1080 public function regrade(question_attempt $oldqa, $finished) {
1081 $first = true;
1082 foreach ($oldqa->get_step_iterator() as $step) {
94815ccf 1083 $this->observer->notify_step_deleted($step, $this);
9212fe7a 1084
dcd03928 1085 if ($first) {
9212fe7a 1086 // First step of the attempt.
dcd03928 1087 $first = false;
1da821bb 1088 $this->start($oldqa->behaviour, $oldqa->get_variant(), $step->get_all_data(),
94815ccf 1089 $step->get_timecreated(), $step->get_user_id(), $step->get_id());
9212fe7a
TH
1090
1091 } else if ($step->has_behaviour_var('finish') && count($step->get_submitted_data()) > 1) {
1092 // This case relates to MDL-32062. The upgrade code from 2.0
1093 // generates attempts where the final submit of the question
1094 // data, and the finish action, are in the same step. The system
1095 // cannot cope with that, so convert the single old step into
1096 // two new steps.
1097 $submitteddata = $step->get_submitted_data();
1098 unset($submitteddata['-finish']);
1099 $this->process_action($submitteddata,
1100 $step->get_timecreated(), $step->get_user_id(), $step->get_id());
1101 $this->finish($step->get_timecreated(), $step->get_user_id());
1102
dcd03928 1103 } else {
9212fe7a 1104 // This is the normal case. Replay the next step of the attempt.
dcd03928 1105 $this->process_action($step->get_submitted_data(),
94815ccf 1106 $step->get_timecreated(), $step->get_user_id(), $step->get_id());
dcd03928
TH
1107 }
1108 }
9212fe7a 1109
dcd03928
TH
1110 if ($finished) {
1111 $this->finish();
1112 }
1113 }
1114
1115 /**
1116 * Perform a manual grading action on this attempt.
53b8e256
TH
1117 * @param string $comment the comment being added.
1118 * @param float $mark the new mark. If null, then only a comment is added.
1119 * @param int $commentformat the FORMAT_... for $comment. Must be given.
dcd03928
TH
1120 * @param int $timestamp the time to record for the action. (If not given, use now.)
1121 * @param int $userid the user to attribute the aciton to. (If not given, use the current user.)
dcd03928 1122 */
53b8e256 1123 public function manual_grade($comment, $mark, $commentformat = null, $timestamp = null, $userid = null) {
dcd03928 1124 $submitteddata = array('-comment' => $comment);
53b8e256
TH
1125 if (is_null($commentformat)) {
1126 debugging('You should pass $commentformat to manual_grade.', DEBUG_DEVELOPER);
1127 $commentformat = FORMAT_HTML;
1128 }
1129 $submitteddata['-commentformat'] = $commentformat;
dcd03928
TH
1130 if (!is_null($mark)) {
1131 $submitteddata['-mark'] = $mark;
1132 $submitteddata['-maxmark'] = $this->maxmark;
1133 }
1134 $this->process_action($submitteddata, $timestamp, $userid);
1135 }
1136
1137 /** @return bool Whether this question attempt has had a manual comment added. */
1138 public function has_manual_comment() {
1139 foreach ($this->steps as $step) {
1140 if ($step->has_behaviour_var('comment')) {
1141 return true;
1142 }
1143 }
1144 return false;
1145 }
1146
1147 /**
1148 * @return array(string, int) the most recent manual comment that was added
1149 * to this question, and the FORMAT_... it is.
1150 */
1151 public function get_manual_comment() {
1152 foreach ($this->get_reverse_step_iterator() as $step) {
1153 if ($step->has_behaviour_var('comment')) {
1154 return array($step->get_behaviour_var('comment'),
1155 $step->get_behaviour_var('commentformat'));
1156 }
1157 }
1158 return array(null, null);
1159 }
1160
1161 /**
1162 * @return array subpartid => object with fields
1163 * ->responseclassid matches one of the values returned from quetion_type::get_possible_responses.
1164 * ->response the actual response the student gave to this part, as a string.
1165 * ->fraction the credit awarded for this subpart, may be null.
1166 * returns an empty array if no analysis is possible.
1167 */
1168 public function classify_response() {
1169 return $this->behaviour->classify_response();
1170 }
1171
1172 /**
1173 * Create a question_attempt_step from records loaded from the database.
1174 *
1175 * For internal use only.
1176 *
35d5f1c2 1177 * @param Iterator $records Raw records loaded from the database.
dcd03928 1178 * @param int $questionattemptid The id of the question_attempt to extract.
94815ccf 1179 * @return question_attempt The newly constructed question_attempt.
dcd03928 1180 */
35d5f1c2 1181 public static function load_from_records($records, $questionattemptid,
dcd03928 1182 question_usage_observer $observer, $preferredbehaviour) {
35d5f1c2 1183 $record = $records->current();
dcd03928 1184 while ($record->questionattemptid != $questionattemptid) {
35d5f1c2
TH
1185 $record = $records->next();
1186 if (!$records->valid()) {
dcd03928
TH
1187 throw new coding_exception("Question attempt $questionattemptid not found in the database.");
1188 }
35d5f1c2 1189 $record = $records->current();
dcd03928
TH
1190 }
1191
1192 try {
1193 $question = question_bank::load_question($record->questionid);
1194 } catch (Exception $e) {
1195 // The question must have been deleted somehow. Create a missing
1196 // question to use in its place.
1197 $question = question_bank::get_qtype('missingtype')->make_deleted_instance(
1198 $record->questionid, $record->maxmark + 0);
1199 }
1200
1201 $qa = new question_attempt($question, $record->questionusageid,
1202 null, $record->maxmark + 0);
1203 $qa->set_database_id($record->questionattemptid);
94815ccf 1204 $qa->set_slot($record->slot);
1da821bb 1205 $qa->variant = $record->variant + 0;
dcd03928
TH
1206 $qa->minfraction = $record->minfraction + 0;
1207 $qa->set_flagged($record->flagged);
1208 $qa->questionsummary = $record->questionsummary;
1209 $qa->rightanswer = $record->rightanswer;
1210 $qa->responsesummary = $record->responsesummary;
1211 $qa->timemodified = $record->timemodified;
1212
1213 $qa->behaviour = question_engine::make_behaviour(
1214 $record->behaviour, $qa, $preferredbehaviour);
1215
dd7aa583
TH
1216 // If attemptstepid is null (which should not happen, but has happened
1217 // due to corrupt data, see MDL-34251) then the current pointer in $records
1218 // will not be advanced in the while loop below, and we get stuck in an
1219 // infinite loop, since this method is supposed to always consume at
1220 // least one record. Therefore, in this case, advance the record here.
1221 if (is_null($record->attemptstepid)) {
1222 $records->next();
1223 }
1224
dcd03928
TH
1225 $i = 0;
1226 while ($record && $record->questionattemptid == $questionattemptid && !is_null($record->attemptstepid)) {
1227 $qa->steps[$i] = question_attempt_step::load_from_records($records, $record->attemptstepid);
1228 if ($i == 0) {
1229 $question->apply_attempt_state($qa->steps[0]);
1230 }
1231 $i++;
35d5f1c2
TH
1232 if ($records->valid()) {
1233 $record = $records->current();
1234 } else {
1235 $record = false;
1236 }
dcd03928
TH
1237 }
1238
1239 $qa->observer = $observer;
1240
1241 return $qa;
1242 }
1243}
1244
1245
1246/**
1247 * This subclass of question_attempt pretends that only part of the step history
1248 * exists. It is used for rendering the question in past states.
1249 *
1250 * All methods that try to modify the question_attempt throw exceptions.
1251 *
1252 * @copyright 2010 The Open University
1253 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1254 */
1255class question_attempt_with_restricted_history extends question_attempt {
1256 /**
1257 * @var question_attempt the underlying question_attempt.
1258 */
1259 protected $baseqa;
1260
1261 /**
1262 * Create a question_attempt_with_restricted_history
1263 * @param question_attempt $baseqa The question_attempt to make a restricted version of.
1264 * @param int $lastseq the index of the last step to include.
1265 * @param string $preferredbehaviour the preferred behaviour. It is slightly
1266 * annoyting that this needs to be passed, but unavoidable for now.
1267 */
1268 public function __construct(question_attempt $baseqa, $lastseq, $preferredbehaviour) {
f123d67f
TH
1269 $this->baseqa = $baseqa->get_full_qa();
1270
1271 if ($lastseq < 0 || $lastseq >= $this->baseqa->get_num_steps()) {
1272 throw new coding_exception('$lastseq out of range', $lastseq);
dcd03928
TH
1273 }
1274
f123d67f 1275 $this->steps = array_slice($this->baseqa->steps, 0, $lastseq + 1);
dcd03928
TH
1276 $this->observer = new question_usage_null_observer();
1277
1278 // This should be a straight copy of all the remaining fields.
f123d67f
TH
1279 $this->id = $this->baseqa->id;
1280 $this->usageid = $this->baseqa->usageid;
1281 $this->slot = $this->baseqa->slot;
1282 $this->question = $this->baseqa->question;
1283 $this->maxmark = $this->baseqa->maxmark;
1284 $this->minfraction = $this->baseqa->minfraction;
1285 $this->questionsummary = $this->baseqa->questionsummary;
1286 $this->responsesummary = $this->baseqa->responsesummary;
1287 $this->rightanswer = $this->baseqa->rightanswer;
1288 $this->flagged = $this->baseqa->flagged;
dcd03928
TH
1289
1290 // Except behaviour, where we need to create a new one.
1291 $this->behaviour = question_engine::make_behaviour(
f123d67f
TH
1292 $this->baseqa->get_behaviour_name(), $this, $preferredbehaviour);
1293 }
1294
1295 public function get_full_qa() {
1296 return $this->baseqa;
dcd03928
TH
1297 }
1298
1299 public function get_full_step_iterator() {
1300 return $this->baseqa->get_step_iterator();
1301 }
1302
1303 protected function add_step(question_attempt_step $step) {
1304 coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1305 }
072db71c 1306 public function process_action($submitteddata, $timestamp = null, $userid = null, $existingstepid = null) {
dcd03928
TH
1307 coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1308 }
072db71c 1309 public function start($preferredbehaviour, $variant, $submitteddata = array(), $timestamp = null, $userid = null, $existingstepid = null) {
dcd03928
TH
1310 coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1311 }
1312
1313 public function set_database_id($id) {
1314 coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1315 }
1316 public function set_flagged($flagged) {
1317 coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1318 }
94815ccf 1319 public function set_slot($slot) {
dcd03928
TH
1320 coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1321 }
1322 public function set_question_summary($questionsummary) {
1323 coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1324 }
1325 public function set_usage_id($usageid) {
1326 coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1327 }
1328}
1329
1330
1331/**
1332 * A class abstracting access to the {@link question_attempt::$states} array.
1333 *
1334 * This is actively linked to question_attempt. If you add an new step
1335 * mid-iteration, then it will be included.
1336 *
1337 * @copyright 2009 The Open University
1338 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1339 */
1340class question_attempt_step_iterator implements Iterator, ArrayAccess {
1341 /** @var question_attempt the question_attempt being iterated over. */
1342 protected $qa;
1343 /** @var integer records the current position in the iteration. */
1344 protected $i;
1345
1346 /**
1347 * Do not call this constructor directly.
1348 * Use {@link question_attempt::get_step_iterator()}.
1349 * @param question_attempt $qa the attempt to iterate over.
1350 */
1351 public function __construct(question_attempt $qa) {
1352 $this->qa = $qa;
1353 $this->rewind();
1354 }
1355
1356 /** @return question_attempt_step */
1357 public function current() {
1358 return $this->offsetGet($this->i);
1359 }
1360 /** @return int */
1361 public function key() {
1362 return $this->i;
1363 }
1364 public function next() {
1365 ++$this->i;
1366 }
1367 public function rewind() {
1368 $this->i = 0;
1369 }
1370 /** @return bool */
1371 public function valid() {
1372 return $this->offsetExists($this->i);
1373 }
1374
1375 /** @return bool */
1376 public function offsetExists($i) {
1377 return $i >= 0 && $i < $this->qa->get_num_steps();
1378 }
1379 /** @return question_attempt_step */
1380 public function offsetGet($i) {
1381 return $this->qa->get_step($i);
1382 }
1383 public function offsetSet($offset, $value) {
1384 throw new coding_exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot set.');
1385 }
1386 public function offsetUnset($offset) {
1387 throw new coding_exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot unset.');
1388 }
1389}
1390
1391
1392/**
1393 * A variant of {@link question_attempt_step_iterator} that iterates through the
1394 * steps in reverse order.
1395 *
1396 * @copyright 2009 The Open University
1397 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1398 */
1399class question_attempt_reverse_step_iterator extends question_attempt_step_iterator {
1400 public function next() {
1401 --$this->i;
1402 }
1403
1404 public function rewind() {
1405 $this->i = $this->qa->get_num_steps() - 1;
1406 }
1407}