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