Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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/>.
16
 
17
/**
18
 * This file defines the question attempt class, and a few related classes.
19
 *
20
 * @package    moodlecore
21
 * @subpackage questionengine
22
 * @copyright  2009 The Open University
23
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24
 */
25
 
26
use core_question\local\bank\question_edit_contexts;
27
 
28
defined('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
 */
45
class 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 Should not longer be used.
54
     * @deprecated since Moodle 3.0
55
     */
56
    const PARAM_MARK = PARAM_RAW_TRIMMED;
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_RAW_FILES = 'paramrawfiles';
69
 
70
    /**
71
     * @var string means first try at a question during an attempt by a user.
72
     * Constant used when calling classify response.
73
     */
74
    const FIRST_TRY = 'firsttry';
75
 
76
    /**
77
     * @var string means last try at a question during an attempt by a user.
78
     * Constant used when calling classify response.
79
     */
80
    const LAST_TRY = 'lasttry';
81
 
82
    /**
83
     * @var string means all tries at a question during an attempt by a user.
84
     * Constant used when calling classify response.
85
     */
86
    const ALL_TRIES = 'alltries';
87
 
88
    /**
89
     * @var bool used to manage the lazy-initialisation of question objects.
90
     */
91
    const QUESTION_STATE_NOT_APPLIED = false;
92
 
93
    /**
94
     * @var bool used to manage the lazy-initialisation of question objects.
95
     */
96
    const QUESTION_STATE_APPLIED = true;
97
 
98
    /** @var integer if this attempts is stored in the question_attempts table, the id of that row. */
99
    protected $id = null;
100
 
101
    /** @var integer|string the id of the question_usage_by_activity we belong to. */
102
    protected $usageid;
103
 
104
    /** @var integer the number used to identify this question_attempt within the usage. */
105
    protected $slot = null;
106
 
107
    /**
108
     * @var question_behaviour the behaviour controlling this attempt.
109
     * null until {@link start()} is called.
110
     */
111
    protected $behaviour = null;
112
 
113
    /** @var question_definition the question this is an attempt at. */
114
    protected $question;
115
 
116
    /**
117
     * @var bool tracks whether $question has had {@link question_definition::start_attempt()} or
118
     * {@link question_definition::apply_attempt_state()} called.
119
     */
120
    protected $questioninitialised;
121
 
122
    /** @var int which variant of the question to use. */
123
    protected $variant;
124
 
125
    /**
126
     * @var float the maximum mark that can be scored at this question.
127
     * Actually, this is only really a nominal maximum. It might be better thought
128
     * of as the question weight.
129
     */
130
    protected $maxmark;
131
 
132
    /**
133
     * @var float the minimum fraction that can be scored at this question, so
134
     * the minimum mark is $this->minfraction * $this->maxmark.
135
     */
136
    protected $minfraction = null;
137
 
138
    /**
139
     * @var float the maximum fraction that can be scored at this question, so
140
     * the maximum mark is $this->maxfraction * $this->maxmark.
141
     */
142
    protected $maxfraction = null;
143
 
144
    /**
145
     * @var string plain text summary of the variant of the question the
146
     * student saw. Intended for reporting purposes.
147
     */
148
    protected $questionsummary = null;
149
 
150
    /**
151
     * @var string plain text summary of the response the student gave.
152
     * Intended for reporting purposes.
153
     */
154
    protected $responsesummary = null;
155
 
156
    /**
157
     * @var int last modified time.
158
     */
159
    public $timemodified = null;
160
 
161
    /**
162
     * @var string plain text summary of the correct response to this question
163
     * variant the student saw. The format should be similar to responsesummary.
164
     * Intended for reporting purposes.
165
     */
166
    protected $rightanswer = null;
167
 
168
    /** @var array of {@link question_attempt_step}s. The steps in this attempt. */
169
    protected $steps = array();
170
 
171
    /**
172
     * @var question_attempt_step if, when we loaded the step from the DB, there was
173
     * an autosaved step, we save a pointer to it here. (It is also added to the $steps array.)
174
     */
175
    protected $autosavedstep = null;
176
 
177
    /** @var boolean whether the user has flagged this attempt within the usage. */
178
    protected $flagged = false;
179
 
180
    /** @var question_usage_observer tracks changes to the useage this attempt is part of.*/
181
    protected $observer;
182
 
183
    /**#@+
184
     * Constants used by the intereaction models to indicate whether the current
185
     * pending step should be kept or discarded.
186
     */
187
    const KEEP = true;
188
    const DISCARD = false;
189
    /**#@-*/
190
 
191
    /**
192
     * Create a new {@link question_attempt}. Normally you should create question_attempts
193
     * indirectly, by calling {@link question_usage_by_activity::add_question()}.
194
     *
195
     * @param question_definition $question the question this is an attempt at.
196
     * @param int|string $usageid The id of the
197
     *      {@link question_usage_by_activity} we belong to. Used by {@link get_field_prefix()}.
198
     * @param question_usage_observer $observer tracks changes to the useage this
199
     *      attempt is part of. (Optional, a {@link question_usage_null_observer} is
200
     *      used if one is not passed.
201
     * @param number $maxmark the maximum grade for this question_attempt. If not
202
     * passed, $question->defaultmark is used.
203
     */
204
    public function __construct(question_definition $question, $usageid,
1441 ariadna 205
            ?question_usage_observer $observer = null, $maxmark = null) {
1 efrain 206
        $this->question = $question;
207
        $this->questioninitialised = self::QUESTION_STATE_NOT_APPLIED;
208
        $this->usageid = $usageid;
209
        if (is_null($observer)) {
210
            $observer = new question_usage_null_observer();
211
        }
212
        $this->observer = $observer;
213
        if (!is_null($maxmark)) {
214
            $this->maxmark = $maxmark;
215
        } else {
216
            $this->maxmark = $question->defaultmark;
217
        }
218
    }
219
 
220
    /**
221
     * This method exists so that {@link question_attempt_with_restricted_history}
222
     * can override it. You should not normally need to call it.
223
     * @return question_attempt return ourself.
224
     */
225
    public function get_full_qa() {
226
        return $this;
227
    }
228
 
229
    /**
230
     * Get the question that is being attempted.
231
     *
232
     * @param bool $requirequestioninitialised set this to false if you don't need
233
     *      the behaviour initialised, which may improve performance.
234
     * @return question_definition the question this is an attempt at.
235
     */
236
    public function get_question($requirequestioninitialised = true) {
237
        if ($requirequestioninitialised && !empty($this->steps)) {
238
            $this->ensure_question_initialised();
239
        }
240
        return $this->question;
241
    }
242
 
243
    /**
244
     * Get the id of the question being attempted.
245
     *
246
     * @return int question id.
247
     */
248
    public function get_question_id() {
249
        return $this->question->id;
250
    }
251
 
252
    /**
253
     * Get the variant of the question being used in a given slot.
254
     * @return int the variant number.
255
     */
256
    public function get_variant() {
257
        return $this->variant;
258
    }
259
 
260
    /**
261
     * Set the number used to identify this question_attempt within the usage.
262
     * For internal use only.
263
     * @param int $slot
264
     */
265
    public function set_slot($slot) {
266
        $this->slot = $slot;
267
    }
268
 
269
    /** @return int the number used to identify this question_attempt within the usage. */
270
    public function get_slot() {
271
        return $this->slot;
272
    }
273
 
274
    /**
275
     * @return int the id of row for this question_attempt, if it is stored in the
276
     * database. null if not.
277
     */
278
    public function get_database_id() {
279
        return $this->id;
280
    }
281
 
282
    /**
283
     * For internal use only. Set the id of the corresponding database row.
284
     * @param int $id the id of row for this question_attempt, if it is
285
     * stored in the database.
286
     */
287
    public function set_database_id($id) {
288
        $this->id = $id;
289
    }
290
 
291
    /**
292
     * You should almost certainly not call this method from your code. It is for
293
     * internal use only.
294
     * @param question_usage_observer that should be used to tracking changes made to this qa.
295
     */
296
    public function set_observer($observer) {
297
        $this->observer = $observer;
298
    }
299
 
300
    /** @return int|string the id of the {@link question_usage_by_activity} we belong to. */
301
    public function get_usage_id() {
302
        return $this->usageid;
303
    }
304
 
305
    /**
306
     * Set the id of the {@link question_usage_by_activity} we belong to.
307
     * For internal use only.
308
     * @param int|string the new id.
309
     */
310
    public function set_usage_id($usageid) {
311
        $this->usageid = $usageid;
312
    }
313
 
314
    /** @return string the name of the behaviour that is controlling this attempt. */
315
    public function get_behaviour_name() {
316
        return $this->behaviour->get_name();
317
    }
318
 
319
    /**
320
     * For internal use only.
321
     *
322
     * @param bool $requirequestioninitialised set this to false if you don't need
323
     *      the behaviour initialised, which may improve performance.
324
     * @return question_behaviour the behaviour that is controlling this attempt.
325
     */
326
    public function get_behaviour($requirequestioninitialised = true) {
327
        if ($requirequestioninitialised && !empty($this->steps)) {
328
            $this->ensure_question_initialised();
329
        }
330
        return $this->behaviour;
331
    }
332
 
333
    /**
334
     * Set the flagged state of this question.
335
     * @param bool $flagged the new state.
336
     */
337
    public function set_flagged($flagged) {
338
        $this->flagged = $flagged;
339
        $this->observer->notify_attempt_modified($this);
340
    }
341
 
342
    /** @return bool whether this question is currently flagged. */
343
    public function is_flagged() {
344
        return $this->flagged;
345
    }
346
 
347
    /**
348
     * Get the name (in the sense a HTML name="" attribute, or a $_POST variable
349
     * name) to use for the field that indicates whether this question is flagged.
350
     *
351
     * @return string The field name to use.
352
     */
353
    public function get_flag_field_name() {
354
        return $this->get_control_field_name('flagged');
355
    }
356
 
357
    /**
358
     * Get the name (in the sense a HTML name="" attribute, or a $_POST variable
359
     * name) to use for a question_type variable belonging to this question_attempt.
360
     *
361
     * See the comment on {@link question_attempt_step} for an explanation of
362
     * question type and behaviour variables.
363
     *
364
     * @param string $varname The short form of the variable name.
365
     * @return string The field name to use.
366
     */
367
    public function get_qt_field_name($varname) {
368
        return $this->get_field_prefix() . $varname;
369
    }
370
 
371
    /**
372
     * Get the name (in the sense a HTML name="" attribute, or a $_POST variable
373
     * name) to use for a question_type variable belonging to this question_attempt.
374
     *
375
     * See the comment on {@link question_attempt_step} for an explanation of
376
     * question type and behaviour variables.
377
     *
378
     * @param string $varname The short form of the variable name.
379
     * @return string The field name to use.
380
     */
381
    public function get_behaviour_field_name($varname) {
382
        return $this->get_field_prefix() . '-' . $varname;
383
    }
384
 
385
    /**
386
     * Get the name (in the sense a HTML name="" attribute, or a $_POST variable
387
     * name) to use for a control variables belonging to this question_attempt.
388
     *
389
     * Examples are :sequencecheck and :flagged
390
     *
391
     * @param string $varname The short form of the variable name.
392
     * @return string The field name to use.
393
     */
394
    public function get_control_field_name($varname) {
395
        return $this->get_field_prefix() . ':' . $varname;
396
    }
397
 
398
    /**
399
     * Get the prefix added to variable names to give field names for this
400
     * question attempt.
401
     *
402
     * You should not use this method directly. This is an implementation detail
403
     * anyway, but if you must access it, use {@link question_usage_by_activity::get_field_prefix()}.
404
     *
405
     * @return string The field name to use.
406
     */
407
    public function get_field_prefix() {
408
        return 'q' . $this->usageid . ':' . $this->slot . '_';
409
    }
410
 
411
    /**
412
     * When the question is rendered, this unique id is added to the
413
     * outer div of the question. It can be used to uniquely reference
414
     * the question from JavaScript.
415
     *
416
     * @return string id added to the outer <div class="que ..."> when the question is rendered.
417
     */
418
    public function get_outer_question_div_unique_id() {
419
        return 'question-' . $this->usageid . '-' . $this->slot;
420
    }
421
 
422
    /**
423
     * Get one of the steps in this attempt.
424
     *
425
     * @param int $i the step number, which counts from 0.
426
     * @return question_attempt_step
427
     */
428
    public function get_step($i) {
429
        if ($i < 0 || $i >= count($this->steps)) {
430
            throw new coding_exception('Index out of bounds in question_attempt::get_step.');
431
        }
432
        return $this->steps[$i];
433
    }
434
 
435
    /**
436
     * Get the number of real steps in this attempt.
437
     * This is put as a hidden field in the HTML, so that when we receive some
438
     * data to process, then we can check that it came from the question
439
     * in the state we are now it.
440
     * @return int a number that summarises the current state of this question attempt.
441
     */
442
    public function get_sequence_check_count() {
443
        $numrealsteps = $this->get_num_steps();
444
        if ($this->has_autosaved_step()) {
445
            $numrealsteps -= 1;
446
        }
447
        return $numrealsteps;
448
    }
449
 
450
    /**
451
     * Get the number of steps in this attempt.
452
     * For internal/test code use only.
453
     * @return int the number of steps we currently have.
454
     */
455
    public function get_num_steps() {
456
        return count($this->steps);
457
    }
458
 
459
    /**
460
     * Return the latest step in this question_attempt.
461
     * For internal/test code use only.
462
     * @return question_attempt_step
463
     */
464
    public function get_last_step() {
465
        if (count($this->steps) == 0) {
466
            return new question_null_step();
467
        }
468
        return end($this->steps);
469
    }
470
 
471
    /**
472
     * @return boolean whether this question_attempt has autosaved data from
473
     * some time in the past.
474
     */
475
    public function has_autosaved_step() {
476
        return !is_null($this->autosavedstep);
477
    }
478
 
479
    /**
480
     * @return question_attempt_step_iterator for iterating over the steps in
481
     * this attempt, in order.
482
     */
483
    public function get_step_iterator() {
484
        return new question_attempt_step_iterator($this);
485
    }
486
 
487
    /**
488
     * The same as {@link get_step_iterator()}. However, for a
489
     * {@link question_attempt_with_restricted_history} this returns the full
490
     * list of steps, while {@link get_step_iterator()} returns only the
491
     * limited history.
492
     * @return question_attempt_step_iterator for iterating over the steps in
493
     * this attempt, in order.
494
     */
495
    public function get_full_step_iterator() {
496
        return $this->get_step_iterator();
497
    }
498
 
499
    /**
500
     * @return question_attempt_reverse_step_iterator for iterating over the steps in
501
     * this attempt, in reverse order.
502
     */
503
    public function get_reverse_step_iterator() {
504
        return new question_attempt_reverse_step_iterator($this);
505
    }
506
 
507
    /**
508
     * Get the qt data from the latest step that has any qt data. Return $default
509
     * array if it is no step has qt data.
510
     *
511
     * @param mixed default the value to return no step has qt data.
512
     *      (Optional, defaults to an empty array.)
513
     * @return array|mixed the data, or $default if there is not any.
514
     */
515
    public function get_last_qt_data($default = array()) {
516
        foreach ($this->get_reverse_step_iterator() as $step) {
517
            $response = $step->get_qt_data();
518
            if (!empty($response)) {
519
                return $response;
520
            }
521
        }
522
        return $default;
523
    }
524
 
525
    /**
526
     * Get the last step with a particular question type varialbe set.
527
     * @param string $name the name of the variable to get.
528
     * @return question_attempt_step the last step, or a step with no variables
529
     * if there was not a real step.
530
     */
531
    public function get_last_step_with_qt_var($name) {
532
        foreach ($this->get_reverse_step_iterator() as $step) {
533
            if ($step->has_qt_var($name)) {
534
                return $step;
535
            }
536
        }
537
        return new question_attempt_step_read_only();
538
    }
539
 
540
    /**
541
     * Get the last step with a particular behaviour variable set.
542
     * @param string $name the name of the variable to get.
543
     * @return question_attempt_step the last step, or a step with no variables
544
     * if there was not a real step.
545
     */
546
    public function get_last_step_with_behaviour_var($name) {
547
        foreach ($this->get_reverse_step_iterator() as $step) {
548
            if ($step->has_behaviour_var($name)) {
549
                return $step;
550
            }
551
        }
552
        return new question_attempt_step_read_only();
553
    }
554
 
555
    /**
556
     * Get the latest value of a particular question type variable. That is, get
557
     * the value from the latest step that has it set. Return null if it is not
558
     * set in any step.
559
     *
560
     * @param string $name the name of the variable to get.
561
     * @param mixed default the value to return in the variable has never been set.
562
     *      (Optional, defaults to null.)
563
     * @return mixed string value, or $default if it has never been set.
564
     */
565
    public function get_last_qt_var($name, $default = null) {
566
        $step = $this->get_last_step_with_qt_var($name);
567
        if ($step->has_qt_var($name)) {
568
            return $step->get_qt_var($name);
569
        } else {
570
            return $default;
571
        }
572
    }
573
 
574
    /**
575
     * Get the latest set of files for a particular question type variable of
576
     * type question_attempt::PARAM_FILES.
577
     *
578
     * @param string $name the name of the associated variable.
579
     * @param int $contextid the context to which the files are linked.
580
     * @return array of {@link stored_files}.
581
     */
582
    public function get_last_qt_files($name, $contextid) {
583
        foreach ($this->get_reverse_step_iterator() as $step) {
584
            if ($step->has_qt_var($name)) {
585
                return $step->get_qt_files($name, $contextid);
586
            }
587
        }
588
        return array();
589
    }
590
 
591
    /**
592
     * Get the URL of a file that belongs to a response variable of this
593
     * question_attempt.
594
     * @param stored_file $file the file to link to.
595
     * @return string the URL of that file.
596
     */
597
    public function get_response_file_url(stored_file $file) {
598
        return file_encode_url(new moodle_url('/pluginfile.php'), '/' . implode('/', array(
599
                $file->get_contextid(),
600
                $file->get_component(),
601
                $file->get_filearea(),
602
                $this->usageid,
603
                $this->slot,
604
                $file->get_itemid())) .
605
                $file->get_filepath() . $file->get_filename(), true);
606
    }
607
 
608
    /**
609
     * Prepare a draft file are for the files belonging the a response variable
610
     * of this question attempt. The draft area is populated with the files from
611
     * the most recent step having files.
612
     *
613
     * @param string $name the variable name the files belong to.
614
     * @param int $contextid the id of the context the quba belongs to.
615
     * @return int the draft itemid.
616
     */
617
    public function prepare_response_files_draft_itemid($name, $contextid) {
618
        foreach ($this->get_reverse_step_iterator() as $step) {
619
            if ($step->has_qt_var($name)) {
620
                return $step->prepare_response_files_draft_itemid($name, $contextid);
621
            }
622
        }
623
 
624
        // No files yet.
625
        $draftid = 0; // Will be filled in by file_prepare_draft_area.
626
        $filearea = question_file_saver::clean_file_area_name('response_' . $name);
627
        file_prepare_draft_area($draftid, $contextid, 'question', $filearea, null);
628
        return $draftid;
629
    }
630
 
631
    /**
632
     * Get the latest value of a particular behaviour variable. That is,
633
     * get the value from the latest step that has it set. Return null if it is
634
     * not set in any step.
635
     *
636
     * @param string $name the name of the variable to get.
637
     * @param mixed default the value to return in the variable has never been set.
638
     *      (Optional, defaults to null.)
639
     * @return mixed string value, or $default if it has never been set.
640
     */
641
    public function get_last_behaviour_var($name, $default = null) {
642
        foreach ($this->get_reverse_step_iterator() as $step) {
643
            if ($step->has_behaviour_var($name)) {
644
                return $step->get_behaviour_var($name);
645
            }
646
        }
647
        return $default;
648
    }
649
 
650
    /**
651
     * Get the current state of this question attempt. That is, the state of the
652
     * latest step.
653
     * @return question_state
654
     */
655
    public function get_state() {
656
        return $this->get_last_step()->get_state();
657
    }
658
 
659
    /**
660
     * @param bool $showcorrectness Whether right/partial/wrong states should
661
     * be distinguised.
662
     * @return string A brief textual description of the current state.
663
     */
664
    public function get_state_string($showcorrectness) {
665
        // Special case when attempt is based on previous one, see MDL-31226.
666
        if ($this->get_num_steps() == 1 && $this->get_state() == question_state::$complete) {
667
            return get_string('notchanged', 'question');
668
        }
669
        return $this->behaviour->get_state_string($showcorrectness);
670
    }
671
 
672
    /**
673
     * @param bool $showcorrectness Whether right/partial/wrong states should
674
     * be distinguised.
675
     * @return string a CSS class name for the current state.
676
     */
677
    public function get_state_class($showcorrectness) {
678
        return $this->get_state()->get_state_class($showcorrectness);
679
    }
680
 
681
    /**
682
     * @return int the timestamp of the most recent step in this question attempt.
683
     */
684
    public function get_last_action_time() {
685
        return $this->get_last_step()->get_timecreated();
686
    }
687
 
688
    /**
689
     * Get the current fraction of this question attempt. That is, the fraction
690
     * of the latest step, or null if this question has not yet been graded.
691
     * @return number the current fraction.
692
     */
693
    public function get_fraction() {
694
        return $this->get_last_step()->get_fraction();
695
    }
696
 
697
    /** @return bool whether this question attempt has a non-zero maximum mark. */
698
    public function has_marks() {
699
        // Since grades are stored in the database as NUMBER(12,7).
700
        return $this->maxmark >= question_utils::MARK_TOLERANCE;
701
    }
702
 
703
    /**
704
     * @return number the current mark for this question.
705
     * {@link get_fraction()} * {@link get_max_mark()}.
706
     */
707
    public function get_mark() {
708
        return $this->fraction_to_mark($this->get_fraction());
709
    }
710
 
711
    /**
712
     * This is used by the manual grading code, particularly in association with
713
     * validation. It gets the current manual mark for a question, in exactly the string
714
     * form that the teacher entered it, if possible. This may come from the current
715
     * POST request, if there is one, otherwise from the database.
716
     *
717
     * @return string the current manual mark for this question, in the format the teacher typed,
718
     *     if possible.
719
     */
720
    public function get_current_manual_mark() {
721
        // Is there a current value in the current POST data? If so, use that.
722
        $mark = $this->get_submitted_var($this->get_behaviour_field_name('mark'), PARAM_RAW_TRIMMED);
723
        if ($mark !== null) {
724
            return $mark;
725
        }
726
 
727
        // Otherwise, use the stored value.
728
        // If the question max mark has not changed, use the stored value that was input.
729
        $storedmaxmark = $this->get_last_behaviour_var('maxmark');
730
        if ($storedmaxmark !== null && ($storedmaxmark - $this->get_max_mark()) < 0.0000005) {
731
            return $this->get_last_behaviour_var('mark');
732
        }
733
 
734
        // The max mark for this question has changed so we must re-scale the current mark.
735
        return format_float($this->get_mark(), 7, true, true);
736
    }
737
 
738
    /**
739
     * @param number|null $fraction a fraction.
740
     * @return number|null the corresponding mark.
741
     */
742
    public function fraction_to_mark($fraction) {
743
        if (is_null($fraction)) {
744
            return null;
745
        }
746
        return $fraction * $this->maxmark;
747
    }
748
 
749
    /**
750
     * @return float the maximum mark possible for this question attempt.
751
     * In fact, this is not strictly the maximum, becuase get_max_fraction may
752
     * return a number greater than 1. It might be better to think of this as a
753
     * question weight.
754
     */
755
    public function get_max_mark() {
756
        return $this->maxmark;
757
    }
758
 
759
    /** @return float the maximum mark possible for this question attempt. */
760
    public function get_min_fraction() {
761
        if (is_null($this->minfraction)) {
762
            throw new coding_exception('This question_attempt has not been started yet, the min fraction is not yet known.');
763
        }
764
        return $this->minfraction;
765
    }
766
 
767
    /** @return float the maximum mark possible for this question attempt. */
768
    public function get_max_fraction() {
769
        if (is_null($this->maxfraction)) {
770
            throw new coding_exception('This question_attempt has not been started yet, the max fraction is not yet known.');
771
        }
772
        return $this->maxfraction;
773
    }
774
 
775
    /**
776
     * The current mark, formatted to the stated number of decimal places. Uses
777
     * {@link format_float()} to format floats according to the current locale.
778
     * @param int $dp number of decimal places.
779
     * @return string formatted mark.
780
     */
781
    public function format_mark($dp) {
782
        return $this->format_fraction_as_mark($this->get_fraction(), $dp);
783
    }
784
 
785
    /**
786
     * The a mark, formatted to the stated number of decimal places. Uses
787
     * {@link format_float()} to format floats according to the current locale.
788
     *
789
     * @param number $fraction a fraction.
790
     * @param int $dp number of decimal places.
791
     * @return string formatted mark.
792
     */
793
    public function format_fraction_as_mark($fraction, $dp) {
794
        return format_float($this->fraction_to_mark($fraction), $dp);
795
    }
796
 
797
    /**
798
     * The maximum mark for this question attempt, formatted to the stated number
799
     * of decimal places. Uses {@link format_float()} to format floats according
800
     * to the current locale.
801
     * @param int $dp number of decimal places.
802
     * @return string formatted maximum mark.
803
     */
804
    public function format_max_mark($dp) {
805
        return format_float($this->maxmark, $dp);
806
    }
807
 
808
    /**
809
     * Return the hint that applies to the question in its current state, or null.
810
     * @return question_hint|null
811
     */
812
    public function get_applicable_hint() {
813
        return $this->behaviour->get_applicable_hint();
814
    }
815
 
816
    /**
817
     * Produce a plain-text summary of what the user did during a step.
818
     * @param question_attempt_step $step the step in question.
819
     * @return string a summary of what was done during that step.
820
     */
821
    public function summarise_action(question_attempt_step $step) {
822
        $this->ensure_question_initialised();
823
        return $this->behaviour->summarise_action($step);
824
    }
825
 
826
    /**
827
     * Return one of the bits of metadata for a this question attempt.
828
     * @param string $name the name of the metadata variable to return.
829
     * @return string the value of that metadata variable.
830
     */
831
    public function get_metadata($name) {
832
        return $this->get_step(0)->get_metadata_var($name);
833
    }
834
 
835
    /**
836
     * Set some metadata for this question attempt.
837
     * @param string $name the name of the metadata variable to return.
838
     * @param string $value the value to set that metadata variable to.
839
     */
840
    public function set_metadata($name, $value) {
841
        $firststep = $this->get_step(0);
842
        if (!$firststep->has_metadata_var($name)) {
843
            $this->observer->notify_metadata_added($this, $name);
844
        } else if ($value !== $firststep->get_metadata_var($name)) {
845
            $this->observer->notify_metadata_modified($this, $name);
846
        }
847
        $firststep->set_metadata_var($name, $value);
848
    }
849
 
850
    /**
851
     * Helper function used by {@link rewrite_pluginfile_urls()} and
852
     * {@link rewrite_response_pluginfile_urls()}.
853
     * @return array ids that need to go into the file paths.
854
     */
855
    protected function extra_file_path_components() {
856
        return array($this->get_usage_id(), $this->get_slot());
857
    }
858
 
859
    /**
860
     * Calls {@link question_rewrite_question_urls()} with appropriate parameters
861
     * for content belonging to this question.
862
     * @param string $text the content to output.
863
     * @param string $component the component name (normally 'question' or 'qtype_...')
864
     * @param string $filearea the name of the file area.
865
     * @param int $itemid the item id.
866
     * @return string the content with the URLs rewritten.
867
     */
868
    public function rewrite_pluginfile_urls($text, $component, $filearea, $itemid) {
869
        return question_rewrite_question_urls($text, 'pluginfile.php',
870
                $this->question->contextid, $component, $filearea,
871
                $this->extra_file_path_components(), $itemid);
872
    }
873
 
874
    /**
875
     * Calls {@link question_rewrite_question_urls()} with appropriate parameters
876
     * for content belonging to responses to this question.
877
     *
878
     * @param string $text the text to update the URLs in.
879
     * @param int $contextid the id of the context the quba belongs to.
880
     * @param string $name the variable name the files belong to.
881
     * @param question_attempt_step $step the step the response is coming from.
882
     * @return string the content with the URLs rewritten.
883
     */
884
    public function rewrite_response_pluginfile_urls($text, $contextid, $name,
885
            question_attempt_step $step) {
886
        return $step->rewrite_response_pluginfile_urls($text, $contextid, $name,
887
                $this->extra_file_path_components());
888
    }
889
 
890
    /**
891
     * Get the {@link core_question_renderer}, in collaboration with appropriate
892
     * {@link qbehaviour_renderer} and {@link qtype_renderer} subclasses, to generate the
893
     * HTML to display this question attempt in its current state.
894
     *
895
     * @param question_display_options $options controls how the question is rendered.
896
     * @param string|null $number The question number to display.
897
     * @param moodle_page|null $page the page the question is being redered to.
898
     *      (Optional. Defaults to $PAGE.)
899
     * @return string HTML fragment representing the question.
900
     */
901
    public function render($options, $number, $page = null) {
902
        $this->ensure_question_initialised();
1441 ariadna 903
        $this->set_first_step_timecreated();
1 efrain 904
        if (is_null($page)) {
905
            global $PAGE;
906
            $page = $PAGE;
907
        }
908
        if (is_null($options->versioninfo)) {
909
            $options->versioninfo = (new question_edit_contexts($page->context))->have_one_edit_tab_cap('questions');
910
        }
911
        $qoutput = $page->get_renderer('core', 'question');
912
        $qtoutput = $this->question->get_renderer($page);
913
        return $this->behaviour->render($options, $number, $qoutput, $qtoutput);
914
    }
915
 
916
    /**
917
     * Generate any bits of HTML that needs to go in the <head> tag when this question
918
     * attempt is displayed in the body.
919
     * @return string HTML fragment.
920
     */
921
    public function render_head_html($page = null) {
922
        $this->ensure_question_initialised();
923
        if (is_null($page)) {
924
            global $PAGE;
925
            $page = $PAGE;
926
        }
927
        // TODO go via behaviour.
928
        return $this->question->get_renderer($page)->head_code($this) .
929
                $this->behaviour->get_renderer($page)->head_code($this);
930
    }
931
 
932
    /**
933
     * Like {@link render_question()} but displays the question at the past step
934
     * indicated by $seq, rather than showing the latest step.
935
     *
936
     * @param int $seq the seq number of the past state to display.
937
     * @param question_display_options $options controls how the question is rendered.
938
     * @param string|null $number The question number to display. 'i' is a special
939
     *      value that gets displayed as Information. Null means no number is displayed.
940
     * @param string $preferredbehaviour the preferred behaviour. It is slightly
941
     *      annoying that this needs to be passed, but unavoidable for now.
942
     * @return string HTML fragment representing the question.
943
     */
944
    public function render_at_step($seq, $options, $number, $preferredbehaviour) {
945
        $this->ensure_question_initialised();
946
        $restrictedqa = new question_attempt_with_restricted_history($this, $seq, $preferredbehaviour);
947
        return $restrictedqa->render($options, $number);
948
    }
949
 
950
    /**
951
     * Checks whether the users is allow to be served a particular file.
952
     * @param question_display_options $options the options that control display of the question.
953
     * @param string $component the name of the component we are serving files for.
954
     * @param string $filearea the name of the file area.
955
     * @param array $args the remaining bits of the file path.
956
     * @param bool $forcedownload whether the user must be forced to download the file.
957
     * @return bool true if the user can access this file.
958
     */
959
    public function check_file_access($options, $component, $filearea, $args, $forcedownload) {
960
        $this->ensure_question_initialised();
961
        return $this->behaviour->check_file_access($options, $component, $filearea, $args, $forcedownload);
962
    }
963
 
964
    /**
965
     * Add a step to this question attempt.
966
     * @param question_attempt_step $step the new step.
967
     */
968
    protected function add_step(question_attempt_step $step) {
969
        $this->steps[] = $step;
970
        end($this->steps);
971
        $this->observer->notify_step_added($step, $this, key($this->steps));
972
    }
973
 
974
    /**
975
     * Add an auto-saved step to this question attempt. We mark auto-saved steps by
976
     * changing saving the step number with a - sign.
977
     * @param question_attempt_step $step the new step.
978
     */
979
    protected function add_autosaved_step(question_attempt_step $step) {
980
        $this->steps[] = $step;
981
        $this->autosavedstep = $step;
982
        end($this->steps);
983
        $this->observer->notify_step_added($step, $this, -key($this->steps));
984
    }
985
 
986
    /**
987
     * Discard any auto-saved data belonging to this question attempt.
988
     */
989
    public function discard_autosaved_step() {
990
        if (!$this->has_autosaved_step()) {
991
            return;
992
        }
993
 
994
        $autosaved = array_pop($this->steps);
995
        $this->autosavedstep = null;
996
        $this->observer->notify_step_deleted($autosaved, $this);
997
    }
998
 
999
    /**
1000
     * If there is an autosaved step, convert it into a real save, so that it
1001
     * is preserved.
1002
     */
1003
    protected function convert_autosaved_step_to_real_step() {
1004
        if ($this->autosavedstep === null) {
1005
            return;
1006
        }
1007
 
1008
        $laststep = end($this->steps);
1009
        if ($laststep !== $this->autosavedstep) {
1010
            throw new coding_exception('Cannot convert autosaved step to real step, since other steps have been added.');
1011
        }
1012
 
1013
        $this->observer->notify_step_modified($this->autosavedstep, $this, key($this->steps));
1014
        $this->autosavedstep = null;
1015
    }
1016
 
1017
    /**
1018
     * Use a strategy to pick a variant.
1019
     * @param question_variant_selection_strategy $variantstrategy a strategy.
1020
     * @return int the selected variant.
1021
     */
1022
    public function select_variant(question_variant_selection_strategy $variantstrategy) {
1023
        return $variantstrategy->choose_variant($this->get_question()->get_num_variants(),
1024
                $this->get_question()->get_variants_selection_seed());
1025
    }
1026
 
1027
    /**
1028
     * Start this question attempt.
1029
     *
1030
     * You should not call this method directly. Call
1031
     * {@link question_usage_by_activity::start_question()} instead.
1032
     *
1033
     * @param string|question_behaviour $preferredbehaviour the name of the
1034
     *      desired archetypal behaviour, or an actual behaviour instance.
1035
     * @param int $variant the variant of the question to start. Between 1 and
1036
     *      $this->get_question()->get_num_variants() inclusive.
1037
     * @param array $submitteddata optional, used when re-starting to keep the same initial state.
1038
     * @param int $timestamp optional, the timstamp to record for this action. Defaults to now.
1039
     * @param int $userid optional, the user to attribute this action to. Defaults to the current user.
1040
     * @param int $existingstepid optional, if this step is going to replace an existing step
1041
     *      (for example, during a regrade) this is the id of the previous step we are replacing.
1042
     */
1043
    public function start($preferredbehaviour, $variant, $submitteddata = array(),
1044
            $timestamp = null, $userid = null, $existingstepid = null) {
1045
 
1046
        if ($this->get_num_steps() > 0) {
1047
            throw new coding_exception('Cannot start a question that is already started.');
1048
        }
1049
 
1050
        // Initialise the behaviour.
1051
        $this->variant = $variant;
1052
        if (is_string($preferredbehaviour)) {
1053
            $this->behaviour =
1054
                    $this->question->make_behaviour($this, $preferredbehaviour);
1055
        } else {
1056
            $class = get_class($preferredbehaviour);
1057
            $this->behaviour = new $class($this, $preferredbehaviour);
1058
        }
1059
 
1060
        // Record the minimum and maximum fractions.
1061
        $this->minfraction = $this->behaviour->get_min_fraction();
1062
        $this->maxfraction = $this->behaviour->get_max_fraction();
1063
 
1064
        // Initialise the first step.
1065
        $firststep = new question_attempt_step($submitteddata, $timestamp, $userid, $existingstepid);
1066
        if ($submitteddata) {
1067
            $firststep->set_state(question_state::$complete);
1068
            $this->behaviour->apply_attempt_state($firststep);
1069
        } else {
1070
            $this->behaviour->init_first_step($firststep, $variant);
1071
        }
1072
        $this->questioninitialised = self::QUESTION_STATE_APPLIED;
1073
        $this->add_step($firststep);
1074
 
1075
        // Record questionline and correct answer.
1076
        $this->questionsummary = $this->behaviour->get_question_summary();
1077
        $this->rightanswer = $this->behaviour->get_right_answer_summary();
1078
    }
1079
 
1080
    /**
1081
     * Start this question attempt, starting from the point that the previous
1082
     * attempt $oldqa had reached.
1083
     *
1084
     * You should not call this method directly. Call
1085
     * {@link question_usage_by_activity::start_question_based_on()} instead.
1086
     *
1087
     * @param question_attempt $oldqa a previous attempt at this quetsion that
1088
     *      defines the starting point.
1089
     */
1090
    public function start_based_on(question_attempt $oldqa) {
1091
        $this->start($oldqa->behaviour, $oldqa->get_variant(), $oldqa->get_resume_data());
1092
    }
1093
 
1094
    /**
1095
     * Used by {@link start_based_on()} to get the data needed to start a new
1096
     * attempt from the point this attempt has go to.
1097
     * @return array name => value pairs.
1098
     */
1099
    protected function get_resume_data() {
1100
        $this->ensure_question_initialised();
1101
        $resumedata = $this->behaviour->get_resume_data();
1102
        foreach ($resumedata as $name => $value) {
1103
            if ($value instanceof question_file_loader) {
1104
                $resumedata[$name] = $value->get_question_file_saver();
1105
            }
1106
        }
1107
        return $resumedata;
1108
    }
1109
 
1110
    /**
1111
     * Get a particular parameter from the current request. A wrapper round
1112
     * {@link optional_param()}, except that the results is returned without
1113
     * slashes.
1114
     * @param string $name the paramter name.
1115
     * @param int $type one of the standard PARAM_... constants, or one of the
1116
     *      special extra constands defined by this class.
1117
     * @param array $postdata (optional, only inteded for testing use) take the
1118
     *      data from this array, instead of from $_POST.
1119
     * @return mixed the requested value.
1120
     */
1121
    public function get_submitted_var($name, $type, $postdata = null) {
1122
        switch ($type) {
1123
 
1124
            case self::PARAM_FILES:
1125
                return $this->process_response_files($name, $name, $postdata);
1126
 
1127
            case self::PARAM_RAW_FILES:
1128
                $var = $this->get_submitted_var($name, PARAM_RAW, $postdata);
1129
                return $this->process_response_files($name, $name . ':itemid', $postdata, $var);
1130
 
1131
            default:
1132
                if (is_null($postdata)) {
1133
                    $var = optional_param($name, null, $type);
1134
                } else if (array_key_exists($name, $postdata)) {
1135
                    $var = clean_param($postdata[$name], $type);
1136
                } else {
1137
                    $var = null;
1138
                }
1139
 
1140
                if ($var !== null) {
1141
                    // Ensure that, if set, $var is a string. This is because later, after
1142
                    // it has been saved to the database and loaded back it will be a string,
1143
                    // so better if the type is predictably always a string.
1144
                    $var = (string) $var;
1145
                }
1146
 
1147
                return $var;
1148
        }
1149
    }
1150
 
1151
    /**
1152
     * Validate the manual mark for a question.
1153
     * @param string $currentmark the user input (e.g. '1,0', '1,0' or 'invalid'.
1154
     * @return string any errors with the value, or '' if it is OK.
1155
     */
1156
    public function validate_manual_mark($currentmark) {
1157
        if ($currentmark === null || $currentmark === '') {
1158
            return '';
1159
        }
1160
 
1161
        $mark = question_utils::clean_param_mark($currentmark);
1162
        if ($mark === null) {
1163
            return get_string('manualgradeinvalidformat', 'question');
1164
        }
1165
 
1166
        $maxmark = $this->get_max_mark();
1167
        if ($mark > $maxmark * $this->get_max_fraction() + question_utils::MARK_TOLERANCE ||
1168
                $mark < $maxmark * $this->get_min_fraction() - question_utils::MARK_TOLERANCE) {
1169
            return get_string('manualgradeoutofrange', 'question');
1170
        }
1171
 
1172
        return '';
1173
    }
1174
 
1175
    /**
1176
     * Handle a submitted variable representing uploaded files.
1177
     * @param string $name the field name.
1178
     * @param string $draftidname the field name holding the draft file area id.
1179
     * @param array $postdata (optional, only inteded for testing use) take the
1180
     *      data from this array, instead of from $_POST. At the moment, this
1181
     *      behaves as if there were no files.
1182
     * @param string $text optional reponse text.
1183
     * @return question_file_saver that can be used to save the files later.
1184
     */
1185
    protected function process_response_files($name, $draftidname, $postdata = null, $text = null) {
1186
        if ($postdata) {
1187
            // For simulated posts, get the draft itemid from there.
1188
            $draftitemid = $this->get_submitted_var($draftidname, PARAM_INT, $postdata);
1189
        } else {
1190
            $draftitemid = file_get_submitted_draft_itemid($draftidname);
1191
        }
1192
 
1193
        if (!$draftitemid) {
1194
            return null;
1195
        }
1196
 
1197
        $filearea = str_replace($this->get_field_prefix(), '', $name);
1198
        $filearea = str_replace('-', 'bf_', $filearea);
1199
        $filearea = 'response_' . $filearea;
1200
        return new question_file_saver($draftitemid, 'question', $filearea, $text);
1201
    }
1202
 
1203
    /**
1204
     * Get any data from the request that matches the list of expected params.
1205
     *
1206
     * @param array $expected variable name => PARAM_... constant.
1207
     * @param null|array $postdata null to use real post data, otherwise an array of data to use.
1208
     * @param string $extraprefix '-' or ''.
1209
     * @return array name => value.
1210
     */
1211
    protected function get_expected_data($expected, $postdata, $extraprefix) {
1212
        $submitteddata = array();
1213
        foreach ($expected as $name => $type) {
1214
            $value = $this->get_submitted_var(
1215
                    $this->get_field_prefix() . $extraprefix . $name, $type, $postdata);
1216
            if (!is_null($value)) {
1217
                $submitteddata[$extraprefix . $name] = $value;
1218
            }
1219
        }
1220
        return $submitteddata;
1221
    }
1222
 
1223
    /**
1224
     * Get all the submitted question type data for this question, whithout checking
1225
     * that it is valid or cleaning it in any way.
1226
     *
1227
     * @param null|array $postdata null to use real post data, otherwise an array of data to use.
1228
     * @return array name => value.
1229
     */
1230
    public function get_all_submitted_qt_vars($postdata) {
1231
        if (is_null($postdata)) {
1232
            $postdata = $_POST;
1233
        }
1234
 
1235
        $pattern = '/^' . preg_quote($this->get_field_prefix(), '/') . '[^-:]/';
1236
        $prefixlen = strlen($this->get_field_prefix());
1237
 
1238
        $submitteddata = array();
1239
        foreach ($postdata as $name => $value) {
1240
            if (preg_match($pattern, $name)) {
1241
                $submitteddata[substr($name, $prefixlen)] = $value;
1242
            }
1243
        }
1244
 
1245
        return $submitteddata;
1246
    }
1247
 
1248
    /**
1249
     * Get all the sumbitted data belonging to this question attempt from the
1250
     * current request.
1251
     * @param array $postdata (optional, only inteded for testing use) take the
1252
     *      data from this array, instead of from $_POST.
1253
     * @return array name => value pairs that could be passed to {@link process_action()}.
1254
     */
1255
    public function get_submitted_data($postdata = null) {
1256
        $this->ensure_question_initialised();
1257
 
1258
        $submitteddata = $this->get_expected_data(
1259
                $this->behaviour->get_expected_data(), $postdata, '-');
1260
 
1261
        $expected = $this->behaviour->get_expected_qt_data();
1262
        $this->check_qt_var_name_restrictions($expected);
1263
 
1264
        if ($expected === self::USE_RAW_DATA) {
1265
            $submitteddata += $this->get_all_submitted_qt_vars($postdata);
1266
        } else {
1267
            $submitteddata += $this->get_expected_data($expected, $postdata, '');
1268
        }
1269
        return $submitteddata;
1270
    }
1271
 
1272
    /**
1273
     * Ensure that no reserved prefixes are being used by installed
1274
     * question types.
1275
     * @param array $expected An array of question type variables
1276
     */
1277
    protected function check_qt_var_name_restrictions($expected) {
1278
        global $CFG;
1279
 
1280
        if ($CFG->debugdeveloper && $expected !== self::USE_RAW_DATA) {
1281
            foreach ($expected as $key => $value) {
1282
                if (strpos($key, 'bf_') !== false) {
1283
                    debugging('The bf_ prefix is reserved and cannot be used by question types', DEBUG_DEVELOPER);
1284
                }
1285
            }
1286
        }
1287
    }
1288
 
1289
    /**
1290
     * Get a set of response data for this question attempt that would get the
1291
     * best possible mark. If it is not possible to compute a correct
1292
     * response, this method should return null.
1293
     * @return array|null name => value pairs that could be passed to {@link process_action()}.
1294
     */
1295
    public function get_correct_response() {
1296
        $this->ensure_question_initialised();
1297
        $response = $this->question->get_correct_response();
1298
        if (is_null($response)) {
1299
            return null;
1300
        }
1301
        $imvars = $this->behaviour->get_correct_response();
1302
        foreach ($imvars as $name => $value) {
1303
            $response['-' . $name] = $value;
1304
        }
1305
        return $response;
1306
    }
1307
 
1308
    /**
1309
     * Change the quetsion summary. Note, that this is almost never necessary.
1310
     * This method was only added to work around a limitation of the Opaque
1311
     * protocol, which only sends questionLine at the end of an attempt.
1312
     * @param string $questionsummary the new summary to set.
1313
     */
1314
    public function set_question_summary($questionsummary) {
1315
        $this->questionsummary = $questionsummary;
1316
        $this->observer->notify_attempt_modified($this);
1317
    }
1318
 
1319
    /**
1320
     * @return string a simple textual summary of the question that was asked.
1321
     */
1322
    public function get_question_summary() {
1323
        return $this->questionsummary;
1324
    }
1325
 
1326
    /**
1327
     * @return string a simple textual summary of response given.
1328
     */
1329
    public function get_response_summary() {
1330
        return $this->responsesummary;
1331
    }
1332
 
1333
    /**
1334
     * @return string a simple textual summary of the correct resonse.
1335
     */
1336
    public function get_right_answer_summary() {
1337
        return $this->rightanswer;
1338
    }
1339
 
1340
    /**
1341
     * Whether this attempt at this question could be completed just by the
1342
     * student interacting with the question, before {@link finish()} is called.
1343
     *
1344
     * @return boolean whether this attempt can finish naturally.
1345
     */
1346
    public function can_finish_during_attempt() {
1347
        $this->ensure_question_initialised();
1348
        return $this->behaviour->can_finish_during_attempt();
1349
    }
1350
 
1351
    /**
1352
     * Perform the action described by $submitteddata.
1353
     * @param array $submitteddata the submitted data the determines the action.
1354
     * @param int $timestamp the time to record for the action. (If not given, use now.)
1355
     * @param int $userid the user to attribute the action to. (If not given, use the current user.)
1356
     * @param int $existingstepid used by the regrade code.
1357
     */
1358
    public function process_action($submitteddata, $timestamp = null, $userid = null, $existingstepid = null) {
1359
        $this->ensure_question_initialised();
1360
        $pendingstep = new question_attempt_pending_step($submitteddata, $timestamp, $userid, $existingstepid);
1361
        $this->discard_autosaved_step();
1362
        if ($this->behaviour->process_action($pendingstep) == self::KEEP) {
1363
            $this->add_step($pendingstep);
1364
            if ($pendingstep->response_summary_changed()) {
1365
                $this->responsesummary = $pendingstep->get_new_response_summary();
1366
            }
1367
            if ($pendingstep->variant_number_changed()) {
1368
                $this->variant = $pendingstep->get_new_variant_number();
1369
            }
1370
        }
1371
    }
1372
 
1373
    /**
1374
     * Process an autosave.
1375
     * @param array $submitteddata the submitted data the determines the action.
1376
     * @param int $timestamp the time to record for the action. (If not given, use now.)
1377
     * @param int $userid the user to attribute the action to. (If not given, use the current user.)
1378
     * @return bool whether anything was saved.
1379
     */
1380
    public function process_autosave($submitteddata, $timestamp = null, $userid = null) {
1381
        $this->ensure_question_initialised();
1382
        $pendingstep = new question_attempt_pending_step($submitteddata, $timestamp, $userid);
1383
        if ($this->behaviour->process_autosave($pendingstep) == self::KEEP) {
1384
            $this->add_autosaved_step($pendingstep);
1385
            return true;
1386
        }
1387
        return false;
1388
    }
1389
 
1390
    /**
1391
     * Perform a finish action on this question attempt. This corresponds to an
1392
     * external finish action, for example the user pressing Submit all and finish
1393
     * in the quiz, rather than using one of the controls that is part of the
1394
     * question.
1395
     *
1396
     * @param int $timestamp the time to record for the action. (If not given, use now.)
1397
     * @param int $userid the user to attribute the aciton to. (If not given, use the current user.)
1398
     */
1399
    public function finish($timestamp = null, $userid = null) {
1400
        $this->ensure_question_initialised();
1401
        $this->convert_autosaved_step_to_real_step();
1402
        $this->process_action(array('-finish' => 1), $timestamp, $userid);
1403
    }
1404
 
1405
    /**
1406
     * Verify if this question_attempt in can be regraded with that other question version.
1407
     *
1408
     * @param question_definition $otherversion a different version of the question to use in the regrade.
1409
     * @return string|null null if the regrade can proceed, else a reason why not.
1410
     */
1411
    public function validate_can_regrade_with_other_version(question_definition $otherversion): ?string {
1412
        return $this->get_question(false)->validate_can_regrade_with_other_version($otherversion);
1413
    }
1414
 
1415
    /**
1416
     * Perform a regrade. This replays all the actions from $oldqa into this
1417
     * attempt.
1418
     * @param question_attempt $oldqa the attempt to regrade.
1419
     * @param bool $finished whether the question attempt should be forced to be finished
1420
     *      after the regrade, or whether it may still be in progress (default false).
1421
     */
1422
    public function regrade(question_attempt $oldqa, $finished) {
1423
        $oldqa->ensure_question_initialised();
1424
        $first = true;
1425
        foreach ($oldqa->get_step_iterator() as $step) {
1426
            $this->observer->notify_step_deleted($step, $this);
1427
 
1428
            if ($first) {
1429
                // First step of the attempt.
1430
                $first = false;
1431
                $this->start($oldqa->behaviour, $oldqa->get_variant(),
1432
                        $this->get_attempt_state_data_to_regrade_with_version($step, $oldqa->get_question()),
1433
                        $step->get_timecreated(), $step->get_user_id(), $step->get_id());
1434
 
1435
            } else if ($step->has_behaviour_var('finish') && count($step->get_submitted_data()) > 1) {
1436
                // This case relates to MDL-32062. The upgrade code from 2.0
1437
                // generates attempts where the final submit of the question
1438
                // data, and the finish action, are in the same step. The system
1439
                // cannot cope with that, so convert the single old step into
1440
                // two new steps.
1441
                $submitteddata = $step->get_submitted_data();
1442
                unset($submitteddata['-finish']);
1443
                $this->process_action($submitteddata,
1444
                        $step->get_timecreated(), $step->get_user_id(), $step->get_id());
1445
                $this->finish($step->get_timecreated(), $step->get_user_id());
1446
 
1447
            } else {
1448
                // This is the normal case. Replay the next step of the attempt.
1449
                if ($step === $oldqa->autosavedstep) {
1450
                    $this->process_autosave($step->get_submitted_data(),
1451
                            $step->get_timecreated(), $step->get_user_id());
1452
                } else {
1453
                    $this->process_action($step->get_submitted_data(),
1454
                            $step->get_timecreated(), $step->get_user_id(), $step->get_id());
1455
                }
1456
            }
1457
        }
1458
 
1459
        if ($finished) {
1460
            $this->finish();
1461
        }
1462
 
1463
        $this->set_flagged($oldqa->is_flagged());
1464
    }
1465
 
1466
    /**
1467
     * Helper used by regrading.
1468
     *
1469
     * Get the data from the first step of the old attempt and, if necessary,
1470
     * update it to be suitable for use with the other version of the question.
1471
     *
1472
     * @param question_attempt_step $oldstep First step at an attempt at $otherversion of this question.
1473
     * @param question_definition $otherversion Another version of the question being attempted.
1474
     * @return array updated data required to restart an attempt with the current version of this question.
1475
     */
1476
    protected function get_attempt_state_data_to_regrade_with_version(question_attempt_step $oldstep,
1477
            question_definition $otherversion): array {
1478
        if ($this->get_question(false) === $otherversion) {
1479
            return $oldstep->get_all_data();
1480
        } else {
1481
            // Update the data belonging to the question type by asking the question to do it.
1482
            $attemptstatedata = $this->get_question(false)->update_attempt_state_data_for_new_version(
1483
                    $oldstep, $otherversion);
1484
 
1485
            // Then copy over all the behaviour and metadata variables.
1486
            // This terminology is explained in the class comment on {@see question_attempt_step}.
1487
            foreach ($oldstep->get_all_data() as $name => $value) {
1488
                if (substr($name, 0, 1) === '-' || substr($name, 0, 2) === ':_') {
1489
                    $attemptstatedata[$name] = $value;
1490
                }
1491
            }
1492
            return $attemptstatedata;
1493
        }
1494
    }
1495
 
1496
    /**
1497
     * Change the max mark for this question_attempt.
1498
     * @param float $maxmark the new max mark.
1499
     */
1500
    public function set_max_mark($maxmark) {
1501
        $this->maxmark = $maxmark;
1502
        $this->observer->notify_attempt_modified($this);
1503
    }
1504
 
1505
    /**
1506
     * Perform a manual grading action on this attempt.
1507
     * @param string $comment the comment being added.
1508
     * @param float $mark the new mark. If null, then only a comment is added.
1509
     * @param int $commentformat the FORMAT_... for $comment. Must be given.
1510
     * @param int $timestamp the time to record for the action. (If not given, use now.)
1511
     * @param int $userid the user to attribute the aciton to. (If not given, use the current user.)
1512
     */
1513
    public function manual_grade($comment, $mark, $commentformat = null, $timestamp = null, $userid = null) {
1514
        $this->ensure_question_initialised();
1515
        $submitteddata = array('-comment' => $comment);
1516
        if (is_null($commentformat)) {
1517
            debugging('You should pass $commentformat to manual_grade.', DEBUG_DEVELOPER);
1518
            $commentformat = FORMAT_HTML;
1519
        }
1520
        $submitteddata['-commentformat'] = $commentformat;
1521
        if (!is_null($mark)) {
1522
            $submitteddata['-mark'] = $mark;
1523
            $submitteddata['-maxmark'] = $this->maxmark;
1524
        }
1525
        $this->process_action($submitteddata, $timestamp, $userid);
1526
    }
1527
 
1528
    /** @return bool Whether this question attempt has had a manual comment added. */
1529
    public function has_manual_comment() {
1530
        foreach ($this->steps as $step) {
1531
            if ($step->has_behaviour_var('comment')) {
1532
                return true;
1533
            }
1534
        }
1535
        return false;
1536
    }
1537
 
1538
    /**
1539
     * @return array(string, int) the most recent manual comment that was added
1540
     * to this question, the FORMAT_... it is and the step itself.
1541
     */
1542
    public function get_manual_comment() {
1543
        foreach ($this->get_reverse_step_iterator() as $step) {
1544
            if ($step->has_behaviour_var('comment')) {
1545
                return array($step->get_behaviour_var('comment'),
1546
                        $step->get_behaviour_var('commentformat'),
1547
                        $step);
1548
            }
1549
        }
1550
        return array(null, null, null);
1551
    }
1552
 
1553
    /**
1554
     * This is used by the manual grading code, particularly in association with
1555
     * validation. If there is a comment submitted in the request, then use that,
1556
     * otherwise use the latest comment for this question.
1557
     *
1558
     * @return array with three elements, comment, commentformat and mark.
1559
     */
1560
    public function get_current_manual_comment() {
1561
        $comment = $this->get_submitted_var($this->get_behaviour_field_name('comment'), PARAM_RAW);
1562
        if (is_null($comment)) {
1563
            return $this->get_manual_comment();
1564
        } else {
1565
            $commentformat = $this->get_submitted_var(
1566
                    $this->get_behaviour_field_name('commentformat'), PARAM_INT);
1567
            if ($commentformat === null) {
1568
                $commentformat = FORMAT_HTML;
1569
            }
1570
            return array($comment, $commentformat, null);
1571
        }
1572
    }
1573
 
1574
    /**
1575
     * Break down a student response by sub part and classification. See also {@link question::classify_response}.
1576
     * Used for response analysis.
1577
     *
1578
     * @param string $whichtries which tries to analyse for response analysis. Will be one of
1579
     *      question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. Defaults to question_attempt::LAST_TRY.
1580
     * @return question_classified_response[]|question_classified_response[][] If $whichtries is
1581
     *      question_attempt::FIRST_TRY or LAST_TRY index is subpartid and values are
1582
     *      question_classified_response instances.
1583
     *      If $whichtries is question_attempt::ALL_TRIES then first key is submitted response no
1584
     *      and the second key is subpartid.
1585
     */
1586
    public function classify_response($whichtries = self::LAST_TRY) {
1587
        $this->ensure_question_initialised();
1588
        return $this->behaviour->classify_response($whichtries);
1589
    }
1590
 
1591
    /**
1592
     * Create a question_attempt_step from records loaded from the database.
1593
     *
1594
     * For internal use only.
1595
     *
1596
     * @param Iterator $records Raw records loaded from the database.
1597
     * @param int $questionattemptid The id of the question_attempt to extract.
1598
     * @param question_usage_observer $observer the observer that will be monitoring changes in us.
1599
     * @param string $preferredbehaviour the preferred behaviour under which we are operating.
1600
     * @return question_attempt The newly constructed question_attempt.
1601
     */
1602
    public static function load_from_records($records, $questionattemptid,
1603
            question_usage_observer $observer, $preferredbehaviour) {
1604
        $record = $records->current();
1605
        while ($record->questionattemptid != $questionattemptid) {
1606
            $records->next();
1607
            if (!$records->valid()) {
1608
                throw new coding_exception("Question attempt {$questionattemptid} not found in the database.");
1609
            }
1610
            $record = $records->current();
1611
        }
1612
 
1613
        try {
1614
            $question = question_bank::load_question($record->questionid);
1615
        } catch (Exception $e) {
1616
            // The question must have been deleted somehow. Create a missing
1617
            // question to use in its place.
1618
            $question = question_bank::get_qtype('missingtype')->make_deleted_instance(
1619
                    $record->questionid, $record->maxmark + 0);
1620
        }
1621
 
1622
        $qa = new question_attempt($question, $record->questionusageid,
1623
                null, $record->maxmark + 0);
1624
        $qa->set_database_id($record->questionattemptid);
1625
        $qa->set_slot($record->slot);
1626
        $qa->variant = $record->variant + 0;
1627
        $qa->minfraction = $record->minfraction + 0;
1628
        $qa->maxfraction = $record->maxfraction + 0;
1629
        $qa->set_flagged($record->flagged);
1630
        $qa->questionsummary = $record->questionsummary;
1631
        $qa->rightanswer = $record->rightanswer;
1632
        $qa->responsesummary = $record->responsesummary;
1633
        $qa->timemodified = $record->timemodified;
1634
 
1635
        $qa->behaviour = question_engine::make_behaviour(
1636
                $record->behaviour, $qa, $preferredbehaviour);
1637
        $qa->observer = $observer;
1638
 
1639
        // If attemptstepid is null (which should not happen, but has happened
1640
        // due to corrupt data, see MDL-34251) then the current pointer in $records
1641
        // will not be advanced in the while loop below, and we get stuck in an
1642
        // infinite loop, since this method is supposed to always consume at
1643
        // least one record. Therefore, in this case, advance the record here.
1644
        if (is_null($record->attemptstepid)) {
1645
            $records->next();
1646
        }
1647
 
1648
        $i = 0;
1649
        $autosavedstep = null;
1650
        $autosavedsequencenumber = null;
1651
        while ($record && $record->questionattemptid == $questionattemptid && !is_null($record->attemptstepid)) {
1652
            $sequencenumber = $record->sequencenumber;
1653
            $nextstep = question_attempt_step::load_from_records($records, $record->attemptstepid,
1654
                    $qa->get_question(false)->get_type_name());
1655
 
1656
            if ($sequencenumber < 0) {
1657
                if (!$autosavedstep) {
1658
                    $autosavedstep = $nextstep;
1659
                    $autosavedsequencenumber = -$sequencenumber;
1660
                } else {
1661
                    // Old redundant data. Mark it for deletion.
1662
                    $qa->observer->notify_step_deleted($nextstep, $qa);
1663
                }
1664
            } else {
1665
                $qa->steps[$i] = $nextstep;
1666
                $i++;
1667
            }
1668
 
1669
            if ($records->valid()) {
1670
                $record = $records->current();
1671
            } else {
1672
                $record = false;
1673
            }
1674
        }
1675
 
1676
        if ($autosavedstep) {
1677
            if ($autosavedsequencenumber >= $i) {
1678
                $qa->autosavedstep = $autosavedstep;
1679
                $qa->steps[$i] = $qa->autosavedstep;
1680
            } else {
1681
                $qa->observer->notify_step_deleted($autosavedstep, $qa);
1682
            }
1683
        }
1684
 
1685
        return $qa;
1686
    }
1687
 
1688
    /**
1689
     * This method is part of the lazy-initialisation of question objects.
1690
     *
1691
     * Methods which require $this->question to be fully initialised
1692
     * (to have had init_first_step or apply_attempt_state called on it)
1693
     * should call this method before proceeding.
1694
     */
1695
    protected function ensure_question_initialised() {
1696
        if ($this->questioninitialised === self::QUESTION_STATE_APPLIED) {
1697
            return; // Already done.
1698
        }
1699
 
1700
        if (empty($this->steps)) {
1701
            throw new coding_exception('You must call start() before doing anything to a question_attempt().');
1702
        }
1703
 
1704
        $this->question->apply_attempt_state($this->steps[0]);
1705
        $this->questioninitialised = self::QUESTION_STATE_APPLIED;
1706
    }
1707
 
1708
    /**
1709
     * Allow access to steps with responses submitted by students for grading in a question attempt.
1710
     *
1711
     * @return question_attempt_steps_with_submitted_response_iterator to access all steps with submitted data for questions that
1712
     *                                                      allow multiple submissions that count towards grade, per attempt.
1713
     */
1714
    public function get_steps_with_submitted_response_iterator() {
1715
        return new question_attempt_steps_with_submitted_response_iterator($this);
1716
    }
1441 ariadna 1717
 
1718
    /**
1719
     * If the first step has a timecreated set to TIMECREATED_ON_FIRST_RENDER, set it to the current time.
1720
     *
1721
     * @return void
1722
     */
1723
    protected function set_first_step_timecreated(): void {
1724
        global $DB;
1725
        $firststep = $this->get_step(0);
1726
        if ((int)$firststep->get_timecreated() === question_attempt_step::TIMECREATED_ON_FIRST_RENDER) {
1727
            $timenow = time();
1728
            $firststep->set_timecreated($timenow);
1729
            $this->observer->notify_step_modified($firststep, $this, 0);
1730
            $DB->set_field('question_attempt_steps', 'timecreated', $timenow, ['id' => $firststep->get_id()]);
1731
        }
1732
    }
1 efrain 1733
}
1734
 
1735
 
1736
/**
1737
 * This subclass of question_attempt pretends that only part of the step history
1738
 * exists. It is used for rendering the question in past states.
1739
 *
1740
 * All methods that try to modify the question_attempt throw exceptions.
1741
 *
1742
 * @copyright  2010 The Open University
1743
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1744
 */
1745
class question_attempt_with_restricted_history extends question_attempt {
1746
    /**
1747
     * @var question_attempt the underlying question_attempt.
1748
     */
1749
    protected $baseqa;
1750
 
1751
    /**
1752
     * Create a question_attempt_with_restricted_history
1753
     * @param question_attempt $baseqa The question_attempt to make a restricted version of.
1754
     * @param int $lastseq the index of the last step to include.
1755
     * @param string $preferredbehaviour the preferred behaviour. It is slightly
1756
     *      annoying that this needs to be passed, but unavoidable for now.
1757
     */
1758
    public function __construct(question_attempt $baseqa, $lastseq, $preferredbehaviour) {
1759
        $this->baseqa = $baseqa->get_full_qa();
1760
 
1761
        if ($lastseq < 0 || $lastseq >= $this->baseqa->get_num_steps()) {
1762
            throw new coding_exception('$lastseq out of range', $lastseq);
1763
        }
1764
 
1765
        $this->steps = array_slice($this->baseqa->steps, 0, $lastseq + 1);
1766
        $this->observer = new question_usage_null_observer();
1767
 
1768
        // This should be a straight copy of all the remaining fields.
1769
        $this->id = $this->baseqa->id;
1770
        $this->usageid = $this->baseqa->usageid;
1771
        $this->slot = $this->baseqa->slot;
1772
        $this->question = $this->baseqa->question;
1773
        $this->maxmark = $this->baseqa->maxmark;
1774
        $this->minfraction = $this->baseqa->minfraction;
1775
        $this->maxfraction = $this->baseqa->maxfraction;
1776
        $this->questionsummary = $this->baseqa->questionsummary;
1777
        $this->responsesummary = $this->baseqa->responsesummary;
1778
        $this->rightanswer = $this->baseqa->rightanswer;
1779
        $this->flagged = $this->baseqa->flagged;
1780
 
1781
        // Except behaviour, where we need to create a new one.
1782
        $this->behaviour = question_engine::make_behaviour(
1783
                $this->baseqa->get_behaviour_name(), $this, $preferredbehaviour);
1784
    }
1785
 
1786
    public function get_full_qa() {
1787
        return $this->baseqa;
1788
    }
1789
 
1790
    public function get_full_step_iterator() {
1791
        return $this->baseqa->get_step_iterator();
1792
    }
1793
 
1794
    protected function add_step(question_attempt_step $step) {
1795
        throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1796
    }
1797
    public function process_action($submitteddata, $timestamp = null, $userid = null, $existingstepid = null) {
1798
        throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1799
    }
1800
    public function start($preferredbehaviour, $variant, $submitteddata = array(), $timestamp = null, $userid = null, $existingstepid = null) {
1801
        throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1802
    }
1803
 
1804
    public function set_database_id($id) {
1805
        throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1806
    }
1807
    public function set_flagged($flagged) {
1808
        throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1809
    }
1810
    public function set_slot($slot) {
1811
        throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1812
    }
1813
    public function set_question_summary($questionsummary) {
1814
        throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1815
    }
1816
    public function set_usage_id($usageid) {
1817
        throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1818
    }
1819
}
1820
 
1821
 
1822
/**
1823
 * A class abstracting access to the {@link question_attempt::$states} array.
1824
 *
1825
 * This is actively linked to question_attempt. If you add an new step
1826
 * mid-iteration, then it will be included.
1827
 *
1828
 * @copyright  2009 The Open University
1829
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1830
 */
1831
class question_attempt_step_iterator implements Iterator, ArrayAccess {
1832
    /** @var question_attempt the question_attempt being iterated over. */
1833
    protected $qa;
1834
    /** @var integer records the current position in the iteration. */
1835
    protected $i;
1836
 
1837
    /**
1838
     * Do not call this constructor directly.
1839
     * Use {@link question_attempt::get_step_iterator()}.
1840
     * @param question_attempt $qa the attempt to iterate over.
1841
     */
1842
    public function __construct(question_attempt $qa) {
1843
        $this->qa = $qa;
1844
        $this->rewind();
1845
    }
1846
 
1847
    /** @return question_attempt_step */
1848
    #[\ReturnTypeWillChange]
1849
    public function current() {
1850
        return $this->offsetGet($this->i);
1851
    }
1852
    /** @return int */
1853
    #[\ReturnTypeWillChange]
1854
    public function key() {
1855
        return $this->i;
1856
    }
1857
    public function next(): void {
1858
        ++$this->i;
1859
    }
1860
    public function rewind(): void {
1861
        $this->i = 0;
1862
    }
1863
    /** @return bool */
1864
    public function valid(): bool {
1865
        return $this->offsetExists($this->i);
1866
    }
1867
 
1868
    /** @return bool */
1869
    public function offsetExists($i): bool {
1870
        return $i >= 0 && $i < $this->qa->get_num_steps();
1871
    }
1872
    /** @return question_attempt_step */
1873
    #[\ReturnTypeWillChange]
1874
    public function offsetGet($i) {
1875
        return $this->qa->get_step($i);
1876
    }
1877
    public function offsetSet($offset, $value): void {
1878
        throw new coding_exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot set.');
1879
    }
1880
    public function offsetUnset($offset): void {
1881
        throw new coding_exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot unset.');
1882
    }
1883
}
1884
 
1885
 
1886
/**
1887
 * A variant of {@link question_attempt_step_iterator} that iterates through the
1888
 * steps in reverse order.
1889
 *
1890
 * @copyright  2009 The Open University
1891
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1892
 */
1893
class question_attempt_reverse_step_iterator extends question_attempt_step_iterator {
1894
    public function next(): void {
1895
        --$this->i;
1896
    }
1897
 
1898
    public function rewind(): void {
1899
        $this->i = $this->qa->get_num_steps() - 1;
1900
    }
1901
}
1902
 
1903
/**
1904
 * A variant of {@link question_attempt_step_iterator} that iterates through the
1905
 * steps with submitted tries.
1906
 *
1907
 * @copyright  2014 The Open University
1908
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1909
 */
1910
class question_attempt_steps_with_submitted_response_iterator extends question_attempt_step_iterator implements Countable {
1911
 
1912
    /** @var question_attempt the question_attempt being iterated over. */
1913
    protected $qa;
1914
 
1915
    /** @var integer records the current position in the iteration. */
1916
    protected $submittedresponseno;
1917
 
1918
    /**
1919
     * Index is the submitted response number and value is the step no.
1920
     *
1921
     * @var int[]
1922
     */
1923
    protected $stepswithsubmittedresponses;
1924
 
1925
    /**
1926
     * Do not call this constructor directly.
1927
     * Use {@link question_attempt::get_submission_step_iterator()}.
1928
     * @param question_attempt $qa the attempt to iterate over.
1929
     */
1930
    public function __construct(question_attempt $qa) {
1931
        $this->qa = $qa;
1932
        $this->find_steps_with_submitted_response();
1933
        $this->rewind();
1934
    }
1935
 
1936
    /**
1937
     * Find the step nos  in which a student has submitted a response. Including any step with a response that is saved before
1938
     * the question attempt finishes.
1939
     *
1940
     * Called from constructor, should not be called from elsewhere.
1941
     *
1942
     */
1943
    protected function find_steps_with_submitted_response() {
1944
        $stepnos = array();
1945
        $lastsavedstep = null;
1946
        foreach ($this->qa->get_step_iterator() as $stepno => $step) {
1947
            if ($this->qa->get_behaviour()->step_has_a_submitted_response($step)) {
1948
                $stepnos[] = $stepno;
1949
                $lastsavedstep = null;
1950
            } else {
1951
                $qtdata = $step->get_qt_data();
1952
                if (count($qtdata)) {
1953
                    $lastsavedstep = $stepno;
1954
                }
1955
            }
1956
        }
1957
 
1958
        if (!is_null($lastsavedstep)) {
1959
            $stepnos[] = $lastsavedstep;
1960
        }
1961
        if (empty($stepnos)) {
1962
            $this->stepswithsubmittedresponses = array();
1963
        } else {
1964
            // Re-index array so index starts with 1.
1965
            $this->stepswithsubmittedresponses = array_combine(range(1, count($stepnos)), $stepnos);
1966
        }
1967
    }
1968
 
1969
    /** @return question_attempt_step */
1970
    #[\ReturnTypeWillChange]
1971
    public function current() {
1972
        return $this->offsetGet($this->submittedresponseno);
1973
    }
1974
    /** @return int */
1975
    #[\ReturnTypeWillChange]
1976
    public function key() {
1977
        return $this->submittedresponseno;
1978
    }
1979
    public function next(): void {
1980
        ++$this->submittedresponseno;
1981
    }
1982
    public function rewind(): void {
1983
        $this->submittedresponseno = 1;
1984
    }
1985
    /** @return bool */
1986
    public function valid(): bool {
1987
        return $this->submittedresponseno >= 1 && $this->submittedresponseno <= count($this->stepswithsubmittedresponses);
1988
    }
1989
 
1990
    /**
1991
     * @param int $submittedresponseno
1992
     * @return bool
1993
     */
1994
    public function offsetExists($submittedresponseno): bool {
1995
        return $submittedresponseno >= 1;
1996
    }
1997
 
1998
    /**
1999
     * @param int $submittedresponseno
2000
     * @return question_attempt_step
2001
     */
2002
    #[\ReturnTypeWillChange]
2003
    public function offsetGet($submittedresponseno) {
2004
        if ($submittedresponseno > count($this->stepswithsubmittedresponses)) {
2005
            return null;
2006
        } else {
2007
            return $this->qa->get_step($this->step_no_for_try($submittedresponseno));
2008
        }
2009
    }
2010
 
2011
    /**
2012
     * @return int the count of steps with tries.
2013
     */
2014
    public function count(): int {
2015
        return count($this->stepswithsubmittedresponses);
2016
    }
2017
 
2018
    /**
2019
     * @param int $submittedresponseno
2020
     * @throws coding_exception
2021
     * @return int|null the step number or null if there is no such submitted response.
2022
     */
2023
    public function step_no_for_try($submittedresponseno) {
2024
        if (isset($this->stepswithsubmittedresponses[$submittedresponseno])) {
2025
            return $this->stepswithsubmittedresponses[$submittedresponseno];
2026
        } else if ($submittedresponseno > count($this->stepswithsubmittedresponses)) {
2027
            return null;
2028
        } else {
2029
            throw new coding_exception('Try number not found. It should be 1 or more.');
2030
        }
2031
    }
2032
 
2033
    public function offsetSet($offset, $value): void {
2034
        throw new coding_exception('You are only allowed read-only access to question_attempt::states '.
2035
                                   'through a question_attempt_step_iterator. Cannot set.');
2036
    }
2037
    public function offsetUnset($offset): void {
2038
        throw new coding_exception('You are only allowed read-only access to question_attempt::states '.
2039
                                   'through a question_attempt_step_iterator. Cannot unset.');
2040
    }
2041
 
2042
}