Proyectos de Subversion Moodle

Rev

| 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,
205
            question_usage_observer $observer = null, $maxmark = null) {
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();
903
        if (is_null($page)) {
904
            global $PAGE;
905
            $page = $PAGE;
906
        }
907
        if (is_null($options->versioninfo)) {
908
            $options->versioninfo = (new question_edit_contexts($page->context))->have_one_edit_tab_cap('questions');
909
        }
910
        $qoutput = $page->get_renderer('core', 'question');
911
        $qtoutput = $this->question->get_renderer($page);
912
        return $this->behaviour->render($options, $number, $qoutput, $qtoutput);
913
    }
914
 
915
    /**
916
     * Generate any bits of HTML that needs to go in the <head> tag when this question
917
     * attempt is displayed in the body.
918
     * @return string HTML fragment.
919
     */
920
    public function render_head_html($page = null) {
921
        $this->ensure_question_initialised();
922
        if (is_null($page)) {
923
            global $PAGE;
924
            $page = $PAGE;
925
        }
926
        // TODO go via behaviour.
927
        return $this->question->get_renderer($page)->head_code($this) .
928
                $this->behaviour->get_renderer($page)->head_code($this);
929
    }
930
 
931
    /**
932
     * Like {@link render_question()} but displays the question at the past step
933
     * indicated by $seq, rather than showing the latest step.
934
     *
935
     * @param int $seq the seq number of the past state to display.
936
     * @param question_display_options $options controls how the question is rendered.
937
     * @param string|null $number The question number to display. 'i' is a special
938
     *      value that gets displayed as Information. Null means no number is displayed.
939
     * @param string $preferredbehaviour the preferred behaviour. It is slightly
940
     *      annoying that this needs to be passed, but unavoidable for now.
941
     * @return string HTML fragment representing the question.
942
     */
943
    public function render_at_step($seq, $options, $number, $preferredbehaviour) {
944
        $this->ensure_question_initialised();
945
        $restrictedqa = new question_attempt_with_restricted_history($this, $seq, $preferredbehaviour);
946
        return $restrictedqa->render($options, $number);
947
    }
948
 
949
    /**
950
     * Checks whether the users is allow to be served a particular file.
951
     * @param question_display_options $options the options that control display of the question.
952
     * @param string $component the name of the component we are serving files for.
953
     * @param string $filearea the name of the file area.
954
     * @param array $args the remaining bits of the file path.
955
     * @param bool $forcedownload whether the user must be forced to download the file.
956
     * @return bool true if the user can access this file.
957
     */
958
    public function check_file_access($options, $component, $filearea, $args, $forcedownload) {
959
        $this->ensure_question_initialised();
960
        return $this->behaviour->check_file_access($options, $component, $filearea, $args, $forcedownload);
961
    }
962
 
963
    /**
964
     * Add a step to this question attempt.
965
     * @param question_attempt_step $step the new step.
966
     */
967
    protected function add_step(question_attempt_step $step) {
968
        $this->steps[] = $step;
969
        end($this->steps);
970
        $this->observer->notify_step_added($step, $this, key($this->steps));
971
    }
972
 
973
    /**
974
     * Add an auto-saved step to this question attempt. We mark auto-saved steps by
975
     * changing saving the step number with a - sign.
976
     * @param question_attempt_step $step the new step.
977
     */
978
    protected function add_autosaved_step(question_attempt_step $step) {
979
        $this->steps[] = $step;
980
        $this->autosavedstep = $step;
981
        end($this->steps);
982
        $this->observer->notify_step_added($step, $this, -key($this->steps));
983
    }
984
 
985
    /**
986
     * Discard any auto-saved data belonging to this question attempt.
987
     */
988
    public function discard_autosaved_step() {
989
        if (!$this->has_autosaved_step()) {
990
            return;
991
        }
992
 
993
        $autosaved = array_pop($this->steps);
994
        $this->autosavedstep = null;
995
        $this->observer->notify_step_deleted($autosaved, $this);
996
    }
997
 
998
    /**
999
     * If there is an autosaved step, convert it into a real save, so that it
1000
     * is preserved.
1001
     */
1002
    protected function convert_autosaved_step_to_real_step() {
1003
        if ($this->autosavedstep === null) {
1004
            return;
1005
        }
1006
 
1007
        $laststep = end($this->steps);
1008
        if ($laststep !== $this->autosavedstep) {
1009
            throw new coding_exception('Cannot convert autosaved step to real step, since other steps have been added.');
1010
        }
1011
 
1012
        $this->observer->notify_step_modified($this->autosavedstep, $this, key($this->steps));
1013
        $this->autosavedstep = null;
1014
    }
1015
 
1016
    /**
1017
     * Use a strategy to pick a variant.
1018
     * @param question_variant_selection_strategy $variantstrategy a strategy.
1019
     * @return int the selected variant.
1020
     */
1021
    public function select_variant(question_variant_selection_strategy $variantstrategy) {
1022
        return $variantstrategy->choose_variant($this->get_question()->get_num_variants(),
1023
                $this->get_question()->get_variants_selection_seed());
1024
    }
1025
 
1026
    /**
1027
     * Start this question attempt.
1028
     *
1029
     * You should not call this method directly. Call
1030
     * {@link question_usage_by_activity::start_question()} instead.
1031
     *
1032
     * @param string|question_behaviour $preferredbehaviour the name of the
1033
     *      desired archetypal behaviour, or an actual behaviour instance.
1034
     * @param int $variant the variant of the question to start. Between 1 and
1035
     *      $this->get_question()->get_num_variants() inclusive.
1036
     * @param array $submitteddata optional, used when re-starting to keep the same initial state.
1037
     * @param int $timestamp optional, the timstamp to record for this action. Defaults to now.
1038
     * @param int $userid optional, the user to attribute this action to. Defaults to the current user.
1039
     * @param int $existingstepid optional, if this step is going to replace an existing step
1040
     *      (for example, during a regrade) this is the id of the previous step we are replacing.
1041
     */
1042
    public function start($preferredbehaviour, $variant, $submitteddata = array(),
1043
            $timestamp = null, $userid = null, $existingstepid = null) {
1044
 
1045
        if ($this->get_num_steps() > 0) {
1046
            throw new coding_exception('Cannot start a question that is already started.');
1047
        }
1048
 
1049
        // Initialise the behaviour.
1050
        $this->variant = $variant;
1051
        if (is_string($preferredbehaviour)) {
1052
            $this->behaviour =
1053
                    $this->question->make_behaviour($this, $preferredbehaviour);
1054
        } else {
1055
            $class = get_class($preferredbehaviour);
1056
            $this->behaviour = new $class($this, $preferredbehaviour);
1057
        }
1058
 
1059
        // Record the minimum and maximum fractions.
1060
        $this->minfraction = $this->behaviour->get_min_fraction();
1061
        $this->maxfraction = $this->behaviour->get_max_fraction();
1062
 
1063
        // Initialise the first step.
1064
        $firststep = new question_attempt_step($submitteddata, $timestamp, $userid, $existingstepid);
1065
        if ($submitteddata) {
1066
            $firststep->set_state(question_state::$complete);
1067
            $this->behaviour->apply_attempt_state($firststep);
1068
        } else {
1069
            $this->behaviour->init_first_step($firststep, $variant);
1070
        }
1071
        $this->questioninitialised = self::QUESTION_STATE_APPLIED;
1072
        $this->add_step($firststep);
1073
 
1074
        // Record questionline and correct answer.
1075
        $this->questionsummary = $this->behaviour->get_question_summary();
1076
        $this->rightanswer = $this->behaviour->get_right_answer_summary();
1077
    }
1078
 
1079
    /**
1080
     * Start this question attempt, starting from the point that the previous
1081
     * attempt $oldqa had reached.
1082
     *
1083
     * You should not call this method directly. Call
1084
     * {@link question_usage_by_activity::start_question_based_on()} instead.
1085
     *
1086
     * @param question_attempt $oldqa a previous attempt at this quetsion that
1087
     *      defines the starting point.
1088
     */
1089
    public function start_based_on(question_attempt $oldqa) {
1090
        $this->start($oldqa->behaviour, $oldqa->get_variant(), $oldqa->get_resume_data());
1091
    }
1092
 
1093
    /**
1094
     * Used by {@link start_based_on()} to get the data needed to start a new
1095
     * attempt from the point this attempt has go to.
1096
     * @return array name => value pairs.
1097
     */
1098
    protected function get_resume_data() {
1099
        $this->ensure_question_initialised();
1100
        $resumedata = $this->behaviour->get_resume_data();
1101
        foreach ($resumedata as $name => $value) {
1102
            if ($value instanceof question_file_loader) {
1103
                $resumedata[$name] = $value->get_question_file_saver();
1104
            }
1105
        }
1106
        return $resumedata;
1107
    }
1108
 
1109
    /**
1110
     * Get a particular parameter from the current request. A wrapper round
1111
     * {@link optional_param()}, except that the results is returned without
1112
     * slashes.
1113
     * @param string $name the paramter name.
1114
     * @param int $type one of the standard PARAM_... constants, or one of the
1115
     *      special extra constands defined by this class.
1116
     * @param array $postdata (optional, only inteded for testing use) take the
1117
     *      data from this array, instead of from $_POST.
1118
     * @return mixed the requested value.
1119
     */
1120
    public function get_submitted_var($name, $type, $postdata = null) {
1121
        switch ($type) {
1122
 
1123
            case self::PARAM_FILES:
1124
                return $this->process_response_files($name, $name, $postdata);
1125
 
1126
            case self::PARAM_RAW_FILES:
1127
                $var = $this->get_submitted_var($name, PARAM_RAW, $postdata);
1128
                return $this->process_response_files($name, $name . ':itemid', $postdata, $var);
1129
 
1130
            default:
1131
                if (is_null($postdata)) {
1132
                    $var = optional_param($name, null, $type);
1133
                } else if (array_key_exists($name, $postdata)) {
1134
                    $var = clean_param($postdata[$name], $type);
1135
                } else {
1136
                    $var = null;
1137
                }
1138
 
1139
                if ($var !== null) {
1140
                    // Ensure that, if set, $var is a string. This is because later, after
1141
                    // it has been saved to the database and loaded back it will be a string,
1142
                    // so better if the type is predictably always a string.
1143
                    $var = (string) $var;
1144
                }
1145
 
1146
                return $var;
1147
        }
1148
    }
1149
 
1150
    /**
1151
     * Validate the manual mark for a question.
1152
     * @param string $currentmark the user input (e.g. '1,0', '1,0' or 'invalid'.
1153
     * @return string any errors with the value, or '' if it is OK.
1154
     */
1155
    public function validate_manual_mark($currentmark) {
1156
        if ($currentmark === null || $currentmark === '') {
1157
            return '';
1158
        }
1159
 
1160
        $mark = question_utils::clean_param_mark($currentmark);
1161
        if ($mark === null) {
1162
            return get_string('manualgradeinvalidformat', 'question');
1163
        }
1164
 
1165
        $maxmark = $this->get_max_mark();
1166
        if ($mark > $maxmark * $this->get_max_fraction() + question_utils::MARK_TOLERANCE ||
1167
                $mark < $maxmark * $this->get_min_fraction() - question_utils::MARK_TOLERANCE) {
1168
            return get_string('manualgradeoutofrange', 'question');
1169
        }
1170
 
1171
        return '';
1172
    }
1173
 
1174
    /**
1175
     * Handle a submitted variable representing uploaded files.
1176
     * @param string $name the field name.
1177
     * @param string $draftidname the field name holding the draft file area id.
1178
     * @param array $postdata (optional, only inteded for testing use) take the
1179
     *      data from this array, instead of from $_POST. At the moment, this
1180
     *      behaves as if there were no files.
1181
     * @param string $text optional reponse text.
1182
     * @return question_file_saver that can be used to save the files later.
1183
     */
1184
    protected function process_response_files($name, $draftidname, $postdata = null, $text = null) {
1185
        if ($postdata) {
1186
            // For simulated posts, get the draft itemid from there.
1187
            $draftitemid = $this->get_submitted_var($draftidname, PARAM_INT, $postdata);
1188
        } else {
1189
            $draftitemid = file_get_submitted_draft_itemid($draftidname);
1190
        }
1191
 
1192
        if (!$draftitemid) {
1193
            return null;
1194
        }
1195
 
1196
        $filearea = str_replace($this->get_field_prefix(), '', $name);
1197
        $filearea = str_replace('-', 'bf_', $filearea);
1198
        $filearea = 'response_' . $filearea;
1199
        return new question_file_saver($draftitemid, 'question', $filearea, $text);
1200
    }
1201
 
1202
    /**
1203
     * Get any data from the request that matches the list of expected params.
1204
     *
1205
     * @param array $expected variable name => PARAM_... constant.
1206
     * @param null|array $postdata null to use real post data, otherwise an array of data to use.
1207
     * @param string $extraprefix '-' or ''.
1208
     * @return array name => value.
1209
     */
1210
    protected function get_expected_data($expected, $postdata, $extraprefix) {
1211
        $submitteddata = array();
1212
        foreach ($expected as $name => $type) {
1213
            $value = $this->get_submitted_var(
1214
                    $this->get_field_prefix() . $extraprefix . $name, $type, $postdata);
1215
            if (!is_null($value)) {
1216
                $submitteddata[$extraprefix . $name] = $value;
1217
            }
1218
        }
1219
        return $submitteddata;
1220
    }
1221
 
1222
    /**
1223
     * Get all the submitted question type data for this question, whithout checking
1224
     * that it is valid or cleaning it in any way.
1225
     *
1226
     * @param null|array $postdata null to use real post data, otherwise an array of data to use.
1227
     * @return array name => value.
1228
     */
1229
    public function get_all_submitted_qt_vars($postdata) {
1230
        if (is_null($postdata)) {
1231
            $postdata = $_POST;
1232
        }
1233
 
1234
        $pattern = '/^' . preg_quote($this->get_field_prefix(), '/') . '[^-:]/';
1235
        $prefixlen = strlen($this->get_field_prefix());
1236
 
1237
        $submitteddata = array();
1238
        foreach ($postdata as $name => $value) {
1239
            if (preg_match($pattern, $name)) {
1240
                $submitteddata[substr($name, $prefixlen)] = $value;
1241
            }
1242
        }
1243
 
1244
        return $submitteddata;
1245
    }
1246
 
1247
    /**
1248
     * Get all the sumbitted data belonging to this question attempt from the
1249
     * current request.
1250
     * @param array $postdata (optional, only inteded for testing use) take the
1251
     *      data from this array, instead of from $_POST.
1252
     * @return array name => value pairs that could be passed to {@link process_action()}.
1253
     */
1254
    public function get_submitted_data($postdata = null) {
1255
        $this->ensure_question_initialised();
1256
 
1257
        $submitteddata = $this->get_expected_data(
1258
                $this->behaviour->get_expected_data(), $postdata, '-');
1259
 
1260
        $expected = $this->behaviour->get_expected_qt_data();
1261
        $this->check_qt_var_name_restrictions($expected);
1262
 
1263
        if ($expected === self::USE_RAW_DATA) {
1264
            $submitteddata += $this->get_all_submitted_qt_vars($postdata);
1265
        } else {
1266
            $submitteddata += $this->get_expected_data($expected, $postdata, '');
1267
        }
1268
        return $submitteddata;
1269
    }
1270
 
1271
    /**
1272
     * Ensure that no reserved prefixes are being used by installed
1273
     * question types.
1274
     * @param array $expected An array of question type variables
1275
     */
1276
    protected function check_qt_var_name_restrictions($expected) {
1277
        global $CFG;
1278
 
1279
        if ($CFG->debugdeveloper && $expected !== self::USE_RAW_DATA) {
1280
            foreach ($expected as $key => $value) {
1281
                if (strpos($key, 'bf_') !== false) {
1282
                    debugging('The bf_ prefix is reserved and cannot be used by question types', DEBUG_DEVELOPER);
1283
                }
1284
            }
1285
        }
1286
    }
1287
 
1288
    /**
1289
     * Get a set of response data for this question attempt that would get the
1290
     * best possible mark. If it is not possible to compute a correct
1291
     * response, this method should return null.
1292
     * @return array|null name => value pairs that could be passed to {@link process_action()}.
1293
     */
1294
    public function get_correct_response() {
1295
        $this->ensure_question_initialised();
1296
        $response = $this->question->get_correct_response();
1297
        if (is_null($response)) {
1298
            return null;
1299
        }
1300
        $imvars = $this->behaviour->get_correct_response();
1301
        foreach ($imvars as $name => $value) {
1302
            $response['-' . $name] = $value;
1303
        }
1304
        return $response;
1305
    }
1306
 
1307
    /**
1308
     * Change the quetsion summary. Note, that this is almost never necessary.
1309
     * This method was only added to work around a limitation of the Opaque
1310
     * protocol, which only sends questionLine at the end of an attempt.
1311
     * @param string $questionsummary the new summary to set.
1312
     */
1313
    public function set_question_summary($questionsummary) {
1314
        $this->questionsummary = $questionsummary;
1315
        $this->observer->notify_attempt_modified($this);
1316
    }
1317
 
1318
    /**
1319
     * @return string a simple textual summary of the question that was asked.
1320
     */
1321
    public function get_question_summary() {
1322
        return $this->questionsummary;
1323
    }
1324
 
1325
    /**
1326
     * @return string a simple textual summary of response given.
1327
     */
1328
    public function get_response_summary() {
1329
        return $this->responsesummary;
1330
    }
1331
 
1332
    /**
1333
     * @return string a simple textual summary of the correct resonse.
1334
     */
1335
    public function get_right_answer_summary() {
1336
        return $this->rightanswer;
1337
    }
1338
 
1339
    /**
1340
     * Whether this attempt at this question could be completed just by the
1341
     * student interacting with the question, before {@link finish()} is called.
1342
     *
1343
     * @return boolean whether this attempt can finish naturally.
1344
     */
1345
    public function can_finish_during_attempt() {
1346
        $this->ensure_question_initialised();
1347
        return $this->behaviour->can_finish_during_attempt();
1348
    }
1349
 
1350
    /**
1351
     * Perform the action described by $submitteddata.
1352
     * @param array $submitteddata the submitted data the determines the action.
1353
     * @param int $timestamp the time to record for the action. (If not given, use now.)
1354
     * @param int $userid the user to attribute the action to. (If not given, use the current user.)
1355
     * @param int $existingstepid used by the regrade code.
1356
     */
1357
    public function process_action($submitteddata, $timestamp = null, $userid = null, $existingstepid = null) {
1358
        $this->ensure_question_initialised();
1359
        $pendingstep = new question_attempt_pending_step($submitteddata, $timestamp, $userid, $existingstepid);
1360
        $this->discard_autosaved_step();
1361
        if ($this->behaviour->process_action($pendingstep) == self::KEEP) {
1362
            $this->add_step($pendingstep);
1363
            if ($pendingstep->response_summary_changed()) {
1364
                $this->responsesummary = $pendingstep->get_new_response_summary();
1365
            }
1366
            if ($pendingstep->variant_number_changed()) {
1367
                $this->variant = $pendingstep->get_new_variant_number();
1368
            }
1369
        }
1370
    }
1371
 
1372
    /**
1373
     * Process an autosave.
1374
     * @param array $submitteddata the submitted data the determines the action.
1375
     * @param int $timestamp the time to record for the action. (If not given, use now.)
1376
     * @param int $userid the user to attribute the action to. (If not given, use the current user.)
1377
     * @return bool whether anything was saved.
1378
     */
1379
    public function process_autosave($submitteddata, $timestamp = null, $userid = null) {
1380
        $this->ensure_question_initialised();
1381
        $pendingstep = new question_attempt_pending_step($submitteddata, $timestamp, $userid);
1382
        if ($this->behaviour->process_autosave($pendingstep) == self::KEEP) {
1383
            $this->add_autosaved_step($pendingstep);
1384
            return true;
1385
        }
1386
        return false;
1387
    }
1388
 
1389
    /**
1390
     * Perform a finish action on this question attempt. This corresponds to an
1391
     * external finish action, for example the user pressing Submit all and finish
1392
     * in the quiz, rather than using one of the controls that is part of the
1393
     * question.
1394
     *
1395
     * @param int $timestamp the time to record for the action. (If not given, use now.)
1396
     * @param int $userid the user to attribute the aciton to. (If not given, use the current user.)
1397
     */
1398
    public function finish($timestamp = null, $userid = null) {
1399
        $this->ensure_question_initialised();
1400
        $this->convert_autosaved_step_to_real_step();
1401
        $this->process_action(array('-finish' => 1), $timestamp, $userid);
1402
    }
1403
 
1404
    /**
1405
     * Verify if this question_attempt in can be regraded with that other question version.
1406
     *
1407
     * @param question_definition $otherversion a different version of the question to use in the regrade.
1408
     * @return string|null null if the regrade can proceed, else a reason why not.
1409
     */
1410
    public function validate_can_regrade_with_other_version(question_definition $otherversion): ?string {
1411
        return $this->get_question(false)->validate_can_regrade_with_other_version($otherversion);
1412
    }
1413
 
1414
    /**
1415
     * Perform a regrade. This replays all the actions from $oldqa into this
1416
     * attempt.
1417
     * @param question_attempt $oldqa the attempt to regrade.
1418
     * @param bool $finished whether the question attempt should be forced to be finished
1419
     *      after the regrade, or whether it may still be in progress (default false).
1420
     */
1421
    public function regrade(question_attempt $oldqa, $finished) {
1422
        $oldqa->ensure_question_initialised();
1423
        $first = true;
1424
        foreach ($oldqa->get_step_iterator() as $step) {
1425
            $this->observer->notify_step_deleted($step, $this);
1426
 
1427
            if ($first) {
1428
                // First step of the attempt.
1429
                $first = false;
1430
                $this->start($oldqa->behaviour, $oldqa->get_variant(),
1431
                        $this->get_attempt_state_data_to_regrade_with_version($step, $oldqa->get_question()),
1432
                        $step->get_timecreated(), $step->get_user_id(), $step->get_id());
1433
 
1434
            } else if ($step->has_behaviour_var('finish') && count($step->get_submitted_data()) > 1) {
1435
                // This case relates to MDL-32062. The upgrade code from 2.0
1436
                // generates attempts where the final submit of the question
1437
                // data, and the finish action, are in the same step. The system
1438
                // cannot cope with that, so convert the single old step into
1439
                // two new steps.
1440
                $submitteddata = $step->get_submitted_data();
1441
                unset($submitteddata['-finish']);
1442
                $this->process_action($submitteddata,
1443
                        $step->get_timecreated(), $step->get_user_id(), $step->get_id());
1444
                $this->finish($step->get_timecreated(), $step->get_user_id());
1445
 
1446
            } else {
1447
                // This is the normal case. Replay the next step of the attempt.
1448
                if ($step === $oldqa->autosavedstep) {
1449
                    $this->process_autosave($step->get_submitted_data(),
1450
                            $step->get_timecreated(), $step->get_user_id());
1451
                } else {
1452
                    $this->process_action($step->get_submitted_data(),
1453
                            $step->get_timecreated(), $step->get_user_id(), $step->get_id());
1454
                }
1455
            }
1456
        }
1457
 
1458
        if ($finished) {
1459
            $this->finish();
1460
        }
1461
 
1462
        $this->set_flagged($oldqa->is_flagged());
1463
    }
1464
 
1465
    /**
1466
     * Helper used by regrading.
1467
     *
1468
     * Get the data from the first step of the old attempt and, if necessary,
1469
     * update it to be suitable for use with the other version of the question.
1470
     *
1471
     * @param question_attempt_step $oldstep First step at an attempt at $otherversion of this question.
1472
     * @param question_definition $otherversion Another version of the question being attempted.
1473
     * @return array updated data required to restart an attempt with the current version of this question.
1474
     */
1475
    protected function get_attempt_state_data_to_regrade_with_version(question_attempt_step $oldstep,
1476
            question_definition $otherversion): array {
1477
        if ($this->get_question(false) === $otherversion) {
1478
            return $oldstep->get_all_data();
1479
        } else {
1480
            // Update the data belonging to the question type by asking the question to do it.
1481
            $attemptstatedata = $this->get_question(false)->update_attempt_state_data_for_new_version(
1482
                    $oldstep, $otherversion);
1483
 
1484
            // Then copy over all the behaviour and metadata variables.
1485
            // This terminology is explained in the class comment on {@see question_attempt_step}.
1486
            foreach ($oldstep->get_all_data() as $name => $value) {
1487
                if (substr($name, 0, 1) === '-' || substr($name, 0, 2) === ':_') {
1488
                    $attemptstatedata[$name] = $value;
1489
                }
1490
            }
1491
            return $attemptstatedata;
1492
        }
1493
    }
1494
 
1495
    /**
1496
     * Change the max mark for this question_attempt.
1497
     * @param float $maxmark the new max mark.
1498
     */
1499
    public function set_max_mark($maxmark) {
1500
        $this->maxmark = $maxmark;
1501
        $this->observer->notify_attempt_modified($this);
1502
    }
1503
 
1504
    /**
1505
     * Perform a manual grading action on this attempt.
1506
     * @param string $comment the comment being added.
1507
     * @param float $mark the new mark. If null, then only a comment is added.
1508
     * @param int $commentformat the FORMAT_... for $comment. Must be given.
1509
     * @param int $timestamp the time to record for the action. (If not given, use now.)
1510
     * @param int $userid the user to attribute the aciton to. (If not given, use the current user.)
1511
     */
1512
    public function manual_grade($comment, $mark, $commentformat = null, $timestamp = null, $userid = null) {
1513
        $this->ensure_question_initialised();
1514
        $submitteddata = array('-comment' => $comment);
1515
        if (is_null($commentformat)) {
1516
            debugging('You should pass $commentformat to manual_grade.', DEBUG_DEVELOPER);
1517
            $commentformat = FORMAT_HTML;
1518
        }
1519
        $submitteddata['-commentformat'] = $commentformat;
1520
        if (!is_null($mark)) {
1521
            $submitteddata['-mark'] = $mark;
1522
            $submitteddata['-maxmark'] = $this->maxmark;
1523
        }
1524
        $this->process_action($submitteddata, $timestamp, $userid);
1525
    }
1526
 
1527
    /** @return bool Whether this question attempt has had a manual comment added. */
1528
    public function has_manual_comment() {
1529
        foreach ($this->steps as $step) {
1530
            if ($step->has_behaviour_var('comment')) {
1531
                return true;
1532
            }
1533
        }
1534
        return false;
1535
    }
1536
 
1537
    /**
1538
     * @return array(string, int) the most recent manual comment that was added
1539
     * to this question, the FORMAT_... it is and the step itself.
1540
     */
1541
    public function get_manual_comment() {
1542
        foreach ($this->get_reverse_step_iterator() as $step) {
1543
            if ($step->has_behaviour_var('comment')) {
1544
                return array($step->get_behaviour_var('comment'),
1545
                        $step->get_behaviour_var('commentformat'),
1546
                        $step);
1547
            }
1548
        }
1549
        return array(null, null, null);
1550
    }
1551
 
1552
    /**
1553
     * This is used by the manual grading code, particularly in association with
1554
     * validation. If there is a comment submitted in the request, then use that,
1555
     * otherwise use the latest comment for this question.
1556
     *
1557
     * @return array with three elements, comment, commentformat and mark.
1558
     */
1559
    public function get_current_manual_comment() {
1560
        $comment = $this->get_submitted_var($this->get_behaviour_field_name('comment'), PARAM_RAW);
1561
        if (is_null($comment)) {
1562
            return $this->get_manual_comment();
1563
        } else {
1564
            $commentformat = $this->get_submitted_var(
1565
                    $this->get_behaviour_field_name('commentformat'), PARAM_INT);
1566
            if ($commentformat === null) {
1567
                $commentformat = FORMAT_HTML;
1568
            }
1569
            return array($comment, $commentformat, null);
1570
        }
1571
    }
1572
 
1573
    /**
1574
     * Break down a student response by sub part and classification. See also {@link question::classify_response}.
1575
     * Used for response analysis.
1576
     *
1577
     * @param string $whichtries which tries to analyse for response analysis. Will be one of
1578
     *      question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. Defaults to question_attempt::LAST_TRY.
1579
     * @return question_classified_response[]|question_classified_response[][] If $whichtries is
1580
     *      question_attempt::FIRST_TRY or LAST_TRY index is subpartid and values are
1581
     *      question_classified_response instances.
1582
     *      If $whichtries is question_attempt::ALL_TRIES then first key is submitted response no
1583
     *      and the second key is subpartid.
1584
     */
1585
    public function classify_response($whichtries = self::LAST_TRY) {
1586
        $this->ensure_question_initialised();
1587
        return $this->behaviour->classify_response($whichtries);
1588
    }
1589
 
1590
    /**
1591
     * Create a question_attempt_step from records loaded from the database.
1592
     *
1593
     * For internal use only.
1594
     *
1595
     * @param Iterator $records Raw records loaded from the database.
1596
     * @param int $questionattemptid The id of the question_attempt to extract.
1597
     * @param question_usage_observer $observer the observer that will be monitoring changes in us.
1598
     * @param string $preferredbehaviour the preferred behaviour under which we are operating.
1599
     * @return question_attempt The newly constructed question_attempt.
1600
     */
1601
    public static function load_from_records($records, $questionattemptid,
1602
            question_usage_observer $observer, $preferredbehaviour) {
1603
        $record = $records->current();
1604
        while ($record->questionattemptid != $questionattemptid) {
1605
            $records->next();
1606
            if (!$records->valid()) {
1607
                throw new coding_exception("Question attempt {$questionattemptid} not found in the database.");
1608
            }
1609
            $record = $records->current();
1610
        }
1611
 
1612
        try {
1613
            $question = question_bank::load_question($record->questionid);
1614
        } catch (Exception $e) {
1615
            // The question must have been deleted somehow. Create a missing
1616
            // question to use in its place.
1617
            $question = question_bank::get_qtype('missingtype')->make_deleted_instance(
1618
                    $record->questionid, $record->maxmark + 0);
1619
        }
1620
 
1621
        $qa = new question_attempt($question, $record->questionusageid,
1622
                null, $record->maxmark + 0);
1623
        $qa->set_database_id($record->questionattemptid);
1624
        $qa->set_slot($record->slot);
1625
        $qa->variant = $record->variant + 0;
1626
        $qa->minfraction = $record->minfraction + 0;
1627
        $qa->maxfraction = $record->maxfraction + 0;
1628
        $qa->set_flagged($record->flagged);
1629
        $qa->questionsummary = $record->questionsummary;
1630
        $qa->rightanswer = $record->rightanswer;
1631
        $qa->responsesummary = $record->responsesummary;
1632
        $qa->timemodified = $record->timemodified;
1633
 
1634
        $qa->behaviour = question_engine::make_behaviour(
1635
                $record->behaviour, $qa, $preferredbehaviour);
1636
        $qa->observer = $observer;
1637
 
1638
        // If attemptstepid is null (which should not happen, but has happened
1639
        // due to corrupt data, see MDL-34251) then the current pointer in $records
1640
        // will not be advanced in the while loop below, and we get stuck in an
1641
        // infinite loop, since this method is supposed to always consume at
1642
        // least one record. Therefore, in this case, advance the record here.
1643
        if (is_null($record->attemptstepid)) {
1644
            $records->next();
1645
        }
1646
 
1647
        $i = 0;
1648
        $autosavedstep = null;
1649
        $autosavedsequencenumber = null;
1650
        while ($record && $record->questionattemptid == $questionattemptid && !is_null($record->attemptstepid)) {
1651
            $sequencenumber = $record->sequencenumber;
1652
            $nextstep = question_attempt_step::load_from_records($records, $record->attemptstepid,
1653
                    $qa->get_question(false)->get_type_name());
1654
 
1655
            if ($sequencenumber < 0) {
1656
                if (!$autosavedstep) {
1657
                    $autosavedstep = $nextstep;
1658
                    $autosavedsequencenumber = -$sequencenumber;
1659
                } else {
1660
                    // Old redundant data. Mark it for deletion.
1661
                    $qa->observer->notify_step_deleted($nextstep, $qa);
1662
                }
1663
            } else {
1664
                $qa->steps[$i] = $nextstep;
1665
                $i++;
1666
            }
1667
 
1668
            if ($records->valid()) {
1669
                $record = $records->current();
1670
            } else {
1671
                $record = false;
1672
            }
1673
        }
1674
 
1675
        if ($autosavedstep) {
1676
            if ($autosavedsequencenumber >= $i) {
1677
                $qa->autosavedstep = $autosavedstep;
1678
                $qa->steps[$i] = $qa->autosavedstep;
1679
            } else {
1680
                $qa->observer->notify_step_deleted($autosavedstep, $qa);
1681
            }
1682
        }
1683
 
1684
        return $qa;
1685
    }
1686
 
1687
    /**
1688
     * This method is part of the lazy-initialisation of question objects.
1689
     *
1690
     * Methods which require $this->question to be fully initialised
1691
     * (to have had init_first_step or apply_attempt_state called on it)
1692
     * should call this method before proceeding.
1693
     */
1694
    protected function ensure_question_initialised() {
1695
        if ($this->questioninitialised === self::QUESTION_STATE_APPLIED) {
1696
            return; // Already done.
1697
        }
1698
 
1699
        if (empty($this->steps)) {
1700
            throw new coding_exception('You must call start() before doing anything to a question_attempt().');
1701
        }
1702
 
1703
        $this->question->apply_attempt_state($this->steps[0]);
1704
        $this->questioninitialised = self::QUESTION_STATE_APPLIED;
1705
    }
1706
 
1707
    /**
1708
     * Allow access to steps with responses submitted by students for grading in a question attempt.
1709
     *
1710
     * @return question_attempt_steps_with_submitted_response_iterator to access all steps with submitted data for questions that
1711
     *                                                      allow multiple submissions that count towards grade, per attempt.
1712
     */
1713
    public function get_steps_with_submitted_response_iterator() {
1714
        return new question_attempt_steps_with_submitted_response_iterator($this);
1715
    }
1716
}
1717
 
1718
 
1719
/**
1720
 * This subclass of question_attempt pretends that only part of the step history
1721
 * exists. It is used for rendering the question in past states.
1722
 *
1723
 * All methods that try to modify the question_attempt throw exceptions.
1724
 *
1725
 * @copyright  2010 The Open University
1726
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1727
 */
1728
class question_attempt_with_restricted_history extends question_attempt {
1729
    /**
1730
     * @var question_attempt the underlying question_attempt.
1731
     */
1732
    protected $baseqa;
1733
 
1734
    /**
1735
     * Create a question_attempt_with_restricted_history
1736
     * @param question_attempt $baseqa The question_attempt to make a restricted version of.
1737
     * @param int $lastseq the index of the last step to include.
1738
     * @param string $preferredbehaviour the preferred behaviour. It is slightly
1739
     *      annoying that this needs to be passed, but unavoidable for now.
1740
     */
1741
    public function __construct(question_attempt $baseqa, $lastseq, $preferredbehaviour) {
1742
        $this->baseqa = $baseqa->get_full_qa();
1743
 
1744
        if ($lastseq < 0 || $lastseq >= $this->baseqa->get_num_steps()) {
1745
            throw new coding_exception('$lastseq out of range', $lastseq);
1746
        }
1747
 
1748
        $this->steps = array_slice($this->baseqa->steps, 0, $lastseq + 1);
1749
        $this->observer = new question_usage_null_observer();
1750
 
1751
        // This should be a straight copy of all the remaining fields.
1752
        $this->id = $this->baseqa->id;
1753
        $this->usageid = $this->baseqa->usageid;
1754
        $this->slot = $this->baseqa->slot;
1755
        $this->question = $this->baseqa->question;
1756
        $this->maxmark = $this->baseqa->maxmark;
1757
        $this->minfraction = $this->baseqa->minfraction;
1758
        $this->maxfraction = $this->baseqa->maxfraction;
1759
        $this->questionsummary = $this->baseqa->questionsummary;
1760
        $this->responsesummary = $this->baseqa->responsesummary;
1761
        $this->rightanswer = $this->baseqa->rightanswer;
1762
        $this->flagged = $this->baseqa->flagged;
1763
 
1764
        // Except behaviour, where we need to create a new one.
1765
        $this->behaviour = question_engine::make_behaviour(
1766
                $this->baseqa->get_behaviour_name(), $this, $preferredbehaviour);
1767
    }
1768
 
1769
    public function get_full_qa() {
1770
        return $this->baseqa;
1771
    }
1772
 
1773
    public function get_full_step_iterator() {
1774
        return $this->baseqa->get_step_iterator();
1775
    }
1776
 
1777
    protected function add_step(question_attempt_step $step) {
1778
        throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1779
    }
1780
    public function process_action($submitteddata, $timestamp = null, $userid = null, $existingstepid = null) {
1781
        throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1782
    }
1783
    public function start($preferredbehaviour, $variant, $submitteddata = array(), $timestamp = null, $userid = null, $existingstepid = null) {
1784
        throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1785
    }
1786
 
1787
    public function set_database_id($id) {
1788
        throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1789
    }
1790
    public function set_flagged($flagged) {
1791
        throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1792
    }
1793
    public function set_slot($slot) {
1794
        throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1795
    }
1796
    public function set_question_summary($questionsummary) {
1797
        throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1798
    }
1799
    public function set_usage_id($usageid) {
1800
        throw new coding_exception('Cannot modify a question_attempt_with_restricted_history.');
1801
    }
1802
}
1803
 
1804
 
1805
/**
1806
 * A class abstracting access to the {@link question_attempt::$states} array.
1807
 *
1808
 * This is actively linked to question_attempt. If you add an new step
1809
 * mid-iteration, then it will be included.
1810
 *
1811
 * @copyright  2009 The Open University
1812
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1813
 */
1814
class question_attempt_step_iterator implements Iterator, ArrayAccess {
1815
    /** @var question_attempt the question_attempt being iterated over. */
1816
    protected $qa;
1817
    /** @var integer records the current position in the iteration. */
1818
    protected $i;
1819
 
1820
    /**
1821
     * Do not call this constructor directly.
1822
     * Use {@link question_attempt::get_step_iterator()}.
1823
     * @param question_attempt $qa the attempt to iterate over.
1824
     */
1825
    public function __construct(question_attempt $qa) {
1826
        $this->qa = $qa;
1827
        $this->rewind();
1828
    }
1829
 
1830
    /** @return question_attempt_step */
1831
    #[\ReturnTypeWillChange]
1832
    public function current() {
1833
        return $this->offsetGet($this->i);
1834
    }
1835
    /** @return int */
1836
    #[\ReturnTypeWillChange]
1837
    public function key() {
1838
        return $this->i;
1839
    }
1840
    public function next(): void {
1841
        ++$this->i;
1842
    }
1843
    public function rewind(): void {
1844
        $this->i = 0;
1845
    }
1846
    /** @return bool */
1847
    public function valid(): bool {
1848
        return $this->offsetExists($this->i);
1849
    }
1850
 
1851
    /** @return bool */
1852
    public function offsetExists($i): bool {
1853
        return $i >= 0 && $i < $this->qa->get_num_steps();
1854
    }
1855
    /** @return question_attempt_step */
1856
    #[\ReturnTypeWillChange]
1857
    public function offsetGet($i) {
1858
        return $this->qa->get_step($i);
1859
    }
1860
    public function offsetSet($offset, $value): void {
1861
        throw new coding_exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot set.');
1862
    }
1863
    public function offsetUnset($offset): void {
1864
        throw new coding_exception('You are only allowed read-only access to question_attempt::states through a question_attempt_step_iterator. Cannot unset.');
1865
    }
1866
}
1867
 
1868
 
1869
/**
1870
 * A variant of {@link question_attempt_step_iterator} that iterates through the
1871
 * steps in reverse order.
1872
 *
1873
 * @copyright  2009 The Open University
1874
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1875
 */
1876
class question_attempt_reverse_step_iterator extends question_attempt_step_iterator {
1877
    public function next(): void {
1878
        --$this->i;
1879
    }
1880
 
1881
    public function rewind(): void {
1882
        $this->i = $this->qa->get_num_steps() - 1;
1883
    }
1884
}
1885
 
1886
/**
1887
 * A variant of {@link question_attempt_step_iterator} that iterates through the
1888
 * steps with submitted tries.
1889
 *
1890
 * @copyright  2014 The Open University
1891
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1892
 */
1893
class question_attempt_steps_with_submitted_response_iterator extends question_attempt_step_iterator implements Countable {
1894
 
1895
    /** @var question_attempt the question_attempt being iterated over. */
1896
    protected $qa;
1897
 
1898
    /** @var integer records the current position in the iteration. */
1899
    protected $submittedresponseno;
1900
 
1901
    /**
1902
     * Index is the submitted response number and value is the step no.
1903
     *
1904
     * @var int[]
1905
     */
1906
    protected $stepswithsubmittedresponses;
1907
 
1908
    /**
1909
     * Do not call this constructor directly.
1910
     * Use {@link question_attempt::get_submission_step_iterator()}.
1911
     * @param question_attempt $qa the attempt to iterate over.
1912
     */
1913
    public function __construct(question_attempt $qa) {
1914
        $this->qa = $qa;
1915
        $this->find_steps_with_submitted_response();
1916
        $this->rewind();
1917
    }
1918
 
1919
    /**
1920
     * Find the step nos  in which a student has submitted a response. Including any step with a response that is saved before
1921
     * the question attempt finishes.
1922
     *
1923
     * Called from constructor, should not be called from elsewhere.
1924
     *
1925
     */
1926
    protected function find_steps_with_submitted_response() {
1927
        $stepnos = array();
1928
        $lastsavedstep = null;
1929
        foreach ($this->qa->get_step_iterator() as $stepno => $step) {
1930
            if ($this->qa->get_behaviour()->step_has_a_submitted_response($step)) {
1931
                $stepnos[] = $stepno;
1932
                $lastsavedstep = null;
1933
            } else {
1934
                $qtdata = $step->get_qt_data();
1935
                if (count($qtdata)) {
1936
                    $lastsavedstep = $stepno;
1937
                }
1938
            }
1939
        }
1940
 
1941
        if (!is_null($lastsavedstep)) {
1942
            $stepnos[] = $lastsavedstep;
1943
        }
1944
        if (empty($stepnos)) {
1945
            $this->stepswithsubmittedresponses = array();
1946
        } else {
1947
            // Re-index array so index starts with 1.
1948
            $this->stepswithsubmittedresponses = array_combine(range(1, count($stepnos)), $stepnos);
1949
        }
1950
    }
1951
 
1952
    /** @return question_attempt_step */
1953
    #[\ReturnTypeWillChange]
1954
    public function current() {
1955
        return $this->offsetGet($this->submittedresponseno);
1956
    }
1957
    /** @return int */
1958
    #[\ReturnTypeWillChange]
1959
    public function key() {
1960
        return $this->submittedresponseno;
1961
    }
1962
    public function next(): void {
1963
        ++$this->submittedresponseno;
1964
    }
1965
    public function rewind(): void {
1966
        $this->submittedresponseno = 1;
1967
    }
1968
    /** @return bool */
1969
    public function valid(): bool {
1970
        return $this->submittedresponseno >= 1 && $this->submittedresponseno <= count($this->stepswithsubmittedresponses);
1971
    }
1972
 
1973
    /**
1974
     * @param int $submittedresponseno
1975
     * @return bool
1976
     */
1977
    public function offsetExists($submittedresponseno): bool {
1978
        return $submittedresponseno >= 1;
1979
    }
1980
 
1981
    /**
1982
     * @param int $submittedresponseno
1983
     * @return question_attempt_step
1984
     */
1985
    #[\ReturnTypeWillChange]
1986
    public function offsetGet($submittedresponseno) {
1987
        if ($submittedresponseno > count($this->stepswithsubmittedresponses)) {
1988
            return null;
1989
        } else {
1990
            return $this->qa->get_step($this->step_no_for_try($submittedresponseno));
1991
        }
1992
    }
1993
 
1994
    /**
1995
     * @return int the count of steps with tries.
1996
     */
1997
    public function count(): int {
1998
        return count($this->stepswithsubmittedresponses);
1999
    }
2000
 
2001
    /**
2002
     * @param int $submittedresponseno
2003
     * @throws coding_exception
2004
     * @return int|null the step number or null if there is no such submitted response.
2005
     */
2006
    public function step_no_for_try($submittedresponseno) {
2007
        if (isset($this->stepswithsubmittedresponses[$submittedresponseno])) {
2008
            return $this->stepswithsubmittedresponses[$submittedresponseno];
2009
        } else if ($submittedresponseno > count($this->stepswithsubmittedresponses)) {
2010
            return null;
2011
        } else {
2012
            throw new coding_exception('Try number not found. It should be 1 or more.');
2013
        }
2014
    }
2015
 
2016
    public function offsetSet($offset, $value): void {
2017
        throw new coding_exception('You are only allowed read-only access to question_attempt::states '.
2018
                                   'through a question_attempt_step_iterator. Cannot set.');
2019
    }
2020
    public function offsetUnset($offset): void {
2021
        throw new coding_exception('You are only allowed read-only access to question_attempt::states '.
2022
                                   'through a question_attempt_step_iterator. Cannot unset.');
2023
    }
2024
 
2025
}