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