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