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 usage 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
 
27
defined('MOODLE_INTERNAL') || die();
28
 
29
 
30
/**
31
 * This class keeps track of a group of questions that are being attempted,
32
 * and which state, and so on, each one is currently in.
33
 *
34
 * A quiz attempt or a lesson attempt could use an instance of this class to
35
 * keep track of all the questions in the attempt and process student submissions.
36
 * It is basically a collection of {@question_attempt} objects.
37
 *
38
 * The questions being attempted as part of this usage are identified by an integer
39
 * that is passed into many of the methods as $slot. ($question->id is not
40
 * used so that the same question can be used more than once in an attempt.)
41
 *
42
 * Normally, calling code should be able to do everything it needs to be calling
43
 * methods of this class. You should not normally need to get individual
44
 * {@question_attempt} objects and play around with their inner workind, in code
45
 * that it outside the quetsion engine.
46
 *
47
 * Instances of this class correspond to rows in the question_usages table.
48
 *
49
 * @copyright  2009 The Open University
50
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
51
 */
52
class question_usage_by_activity {
53
    /**
54
     * @var integer|string the id for this usage. If this usage was loaded from
55
     * the database, then this is the database id. Otherwise a unique random
56
     * string is used.
57
     */
58
    protected $id = null;
59
 
60
    /**
61
     * @var string name of an archetypal behaviour, that should be used
62
     * by questions in this usage if possible.
63
     */
64
    protected $preferredbehaviour = null;
65
 
66
    /** @var context the context this usage belongs to. */
67
    protected $context;
68
 
69
    /** @var string plugin name of the plugin this usage belongs to. */
70
    protected $owningcomponent;
71
 
72
    /** @var question_attempt[] {@link question_attempt}s that make up this usage. */
73
    protected $questionattempts = array();
74
 
75
    /** @var question_usage_observer that tracks changes to this usage. */
76
    protected $observer;
77
 
78
    /**
79
     * Create a new instance. Normally, calling code should use
80
     * {@link question_engine::make_questions_usage_by_activity()} or
81
     * {@link question_engine::load_questions_usage_by_activity()} rather than
82
     * calling this constructor directly.
83
     *
84
     * @param string $component the plugin creating this attempt. For example mod_quiz.
85
     * @param object $context the context this usage belongs to.
86
     */
87
    public function __construct($component, $context) {
88
        $this->owningcomponent = $component;
89
        $this->context = $context;
90
        $this->observer = new question_usage_null_observer();
91
    }
92
 
93
    /**
94
     * @param string $behaviour the name of an archetypal behaviour, that should
95
     * be used by questions in this usage if possible.
96
     */
97
    public function set_preferred_behaviour($behaviour) {
98
        $this->preferredbehaviour = $behaviour;
99
        $this->observer->notify_modified();
100
    }
101
 
102
    /** @return string the name of the preferred behaviour. */
103
    public function get_preferred_behaviour() {
104
        return $this->preferredbehaviour;
105
    }
106
 
107
    /** @return context the context this usage belongs to. */
108
    public function get_owning_context() {
109
        return $this->context;
110
    }
111
 
112
    /** @return string the name of the plugin that owns this attempt. */
113
    public function get_owning_component() {
114
        return $this->owningcomponent;
115
    }
116
 
117
    /** @return int|string If this usage came from the database, then the id
118
     * from the question_usages table is returned. Otherwise a random string is
119
     * returned. */
120
    public function get_id() {
121
        if (is_null($this->id)) {
122
            $this->id = random_string(10);
123
        }
124
        return $this->id;
125
    }
126
 
127
    /**
128
     * For internal use only. Used by {@link question_engine_data_mapper} to set
129
     * the id when a usage is saved to the database.
130
     * @param int $id the newly determined id for this usage.
131
     */
132
    public function set_id_from_database($id) {
133
        $this->id = $id;
134
        foreach ($this->questionattempts as $qa) {
135
            $qa->set_usage_id($id);
136
        }
137
    }
138
 
139
    /** @return question_usage_observer that is tracking changes made to this usage. */
140
    public function get_observer() {
141
        return $this->observer;
142
    }
143
 
144
    /**
145
     * You should almost certainly not call this method from your code. It is for
146
     * internal use only.
147
     * @param question_usage_observer that should be used to tracking changes made to this usage.
148
     */
149
    public function set_observer($observer) {
150
        $this->observer = $observer;
151
        foreach ($this->questionattempts as $qa) {
152
            $qa->set_observer($observer);
153
        }
154
    }
155
 
156
    /**
157
     * Add another question to this usage.
158
     *
159
     * The added question is not started until you call {@link start_question()}
160
     * on it.
161
     *
162
     * @param question_definition $question the question to add.
163
     * @param number $maxmark the maximum this question will be marked out of in
164
     *      this attempt (optional). If not given, $question->defaultmark is used.
165
     * @return int the number used to identify this question within this usage.
166
     */
167
    public function add_question(question_definition $question, $maxmark = null) {
168
        $qa = new question_attempt($question, $this->get_id(), $this->observer, $maxmark);
169
        $qa->set_slot($this->next_slot_number());
170
        $this->questionattempts[$this->next_slot_number()] = $qa;
171
        $this->observer->notify_attempt_added($qa);
172
        return $qa->get_slot();
173
    }
174
 
175
    /**
176
     * Add another question to this usage, in the place of an existing slot.
177
     *
178
     * Depending on $keepoldquestionattempt, the question_attempt that was in
179
     * that slot is moved to the end at a new slot number, which is returned.
180
     * Otherwise the existing attempt is completely removed and replaced.
181
     *
182
     * The added question is not started until you call {@link start_question()}
183
     * on it.
184
     *
185
     * @param int $slot the slot-number of the question to replace.
186
     * @param question_definition $question the question to add.
187
     * @param number $maxmark the maximum this question will be marked out of in
188
     *      this attempt (optional). If not given, the max mark from the $qa we
189
     *      are replacing is used.
190
     * @param bool $keepoldquestionattempt if true (the default) we keep the existing
191
     *      question_attempt, moving it to a new slot
192
     * @return int the new slot number of the question that was displaced.
193
     */
194
    public function add_question_in_place_of_other(
195
        $slot,
196
        question_definition $question,
197
        $maxmark = null,
198
        bool $keepoldquestionattempt = true,
199
    ) {
200
 
201
        $oldqa = $this->get_question_attempt($slot);
202
 
203
        if ($maxmark === null) {
204
            $maxmark = $oldqa->get_max_mark();
205
        }
206
 
207
        $qa = new question_attempt($question, $this->get_id(), $this->observer, $maxmark);
208
        $qa->set_slot($slot);
209
 
210
        if ($keepoldquestionattempt) {
211
            $newslot = $this->next_slot_number();
212
            $oldqa->set_slot($newslot);
213
            $this->questionattempts[$newslot] = $oldqa;
214
 
215
            $this->observer->notify_attempt_moved($oldqa, $slot);
216
            $this->observer->notify_attempt_added($qa);
217
 
218
        } else {
219
            $newslot = $slot;
220
            $qa->set_database_id($oldqa->get_database_id());
221
 
222
            foreach ($oldqa->get_step_iterator() as $oldstep) {
223
                $this->observer->notify_step_deleted($oldstep, $oldqa);
224
            }
225
            $this->observer->notify_attempt_modified($qa);
226
        }
227
 
228
        $this->questionattempts[$slot] = $qa;
229
 
230
        return $newslot;
231
    }
232
 
233
    /**
234
     * The slot number that will be allotted to the next question added.
235
     */
236
    public function next_slot_number() {
237
        return count($this->questionattempts) + 1;
238
    }
239
 
240
    /**
241
     * Get the question_definition for a question in this attempt.
242
     * @param int $slot the number used to identify this question within this usage.
243
     * @param bool $requirequestioninitialised set this to false if you don't need
244
     *      the behaviour initialised, which may improve performance.
245
     * @return question_definition the requested question object.
246
     */
247
    public function get_question($slot, $requirequestioninitialised = true) {
248
        return $this->get_question_attempt($slot)->get_question($requirequestioninitialised);
249
    }
250
 
251
    /** @return array all the identifying numbers of all the questions in this usage. */
252
    public function get_slots() {
253
        return array_keys($this->questionattempts);
254
    }
255
 
256
    /** @return int the identifying number of the first question that was added to this usage. */
257
    public function get_first_question_number() {
258
        reset($this->questionattempts);
259
        return key($this->questionattempts);
260
    }
261
 
262
    /** @return int the number of questions that are currently in this usage. */
263
    public function question_count() {
264
        return count($this->questionattempts);
265
    }
266
 
267
    /**
268
     * Note the part of the {@link question_usage_by_activity} comment that explains
269
     * that {@link question_attempt} objects should be considered part of the inner
270
     * workings of the question engine, and should not, if possible, be accessed directly.
271
     *
272
     * @return question_attempt_iterator for iterating over all the questions being
273
     * attempted. as part of this usage.
274
     */
275
    public function get_attempt_iterator() {
276
        return new question_attempt_iterator($this);
277
    }
278
 
279
    /**
280
     * Check whether $number actually corresponds to a question attempt that is
281
     * part of this usage. Throws an exception if not.
282
     *
283
     * @param int $slot a number allegedly identifying a question within this usage.
284
     */
285
    protected function check_slot($slot) {
286
        if (!array_key_exists($slot, $this->questionattempts)) {
287
            throw new coding_exception('There is no question_attempt number ' . $slot .
288
                    ' in this attempt.');
289
        }
290
    }
291
 
292
    /**
293
     * Note the part of the {@link question_usage_by_activity} comment that explains
294
     * that {@link question_attempt} objects should be considered part of the inner
295
     * workings of the question engine, and should not, if possible, be accessed directly.
296
     *
297
     * @param int $slot the number used to identify this question within this usage.
298
     * @return question_attempt the corresponding {@link question_attempt} object.
299
     */
300
    public function get_question_attempt($slot) {
301
        $this->check_slot($slot);
302
        return $this->questionattempts[$slot];
303
    }
304
 
305
    /**
306
     * Get the current state of the attempt at a question.
307
     * @param int $slot the number used to identify this question within this usage.
308
     * @return question_state.
309
     */
310
    public function get_question_state($slot) {
311
        return $this->get_question_attempt($slot)->get_state();
312
    }
313
 
314
    /**
315
     * @param int $slot the number used to identify this question within this usage.
316
     * @param bool $showcorrectness Whether right/partial/wrong states should
317
     * be distinguised.
318
     * @return string A brief textual description of the current state.
319
     */
320
    public function get_question_state_string($slot, $showcorrectness) {
321
        return $this->get_question_attempt($slot)->get_state_string($showcorrectness);
322
    }
323
 
324
    /**
325
     * @param int $slot the number used to identify this question within this usage.
326
     * @param bool $showcorrectness Whether right/partial/wrong states should
327
     * be distinguised.
328
     * @return string a CSS class name for the current state.
329
     */
330
    public function get_question_state_class($slot, $showcorrectness) {
331
        return $this->get_question_attempt($slot)->get_state_class($showcorrectness);
332
    }
333
 
334
    /**
335
     * Whether this attempt at a given question could be completed just by the
336
     * student interacting with the question, before {@link finish_question()} is called.
337
     *
338
     * @param int $slot the number used to identify this question within this usage.
339
     * @return boolean whether the attempt at the given question can finish naturally.
340
     */
341
    public function can_question_finish_during_attempt($slot) {
342
        return $this->get_question_attempt($slot)->can_finish_during_attempt();
343
    }
344
 
345
    /**
346
     * Get the time of the most recent action performed on a question.
347
     * @param int $slot the number used to identify this question within this usage.
348
     * @return int timestamp.
349
     */
350
    public function get_question_action_time($slot) {
351
        return $this->get_question_attempt($slot)->get_last_action_time();
352
    }
353
 
354
    /**
355
     * Get the current fraction awarded for the attempt at a question.
356
     * @param int $slot the number used to identify this question within this usage.
357
     * @return number|null The current fraction for this question, or null if one has
358
     * not been assigned yet.
359
     */
360
    public function get_question_fraction($slot) {
361
        return $this->get_question_attempt($slot)->get_fraction();
362
    }
363
 
364
    /**
365
     * Get the current mark awarded for the attempt at a question.
366
     * @param int $slot the number used to identify this question within this usage.
367
     * @return number|null The current mark for this question, or null if one has
368
     * not been assigned yet.
369
     */
370
    public function get_question_mark($slot) {
371
        return $this->get_question_attempt($slot)->get_mark();
372
    }
373
 
374
    /**
375
     * Get the maximum mark possible for the attempt at a question.
376
     * @param int $slot the number used to identify this question within this usage.
377
     * @return number the available marks for this question.
378
     */
379
    public function get_question_max_mark($slot) {
380
        return $this->get_question_attempt($slot)->get_max_mark();
381
    }
382
 
383
    /**
384
     * Get the total mark for all questions in this usage.
385
     * @return number The sum of marks of all the question_attempts in this usage.
386
     */
387
    public function get_total_mark() {
388
        $mark = 0;
389
        foreach ($this->questionattempts as $qa) {
390
            if ($qa->get_max_mark() > 0 && $qa->get_state() == question_state::$needsgrading) {
391
                return null;
392
            }
393
            $mark += $qa->get_mark();
394
        }
395
        return $mark;
396
    }
397
 
398
    /**
399
     * Get summary information about this usage.
400
     *
401
     * Some behaviours may be able to provide interesting summary information
402
     * about the attempt as a whole, and this method provides access to that data.
403
     * To see how this works, try setting a quiz to one of the CBM behaviours,
404
     * and then look at the extra information displayed at the top of the quiz
405
     * review page once you have sumitted an attempt.
406
     *
407
     * In the return value, the array keys are identifiers of the form
408
     * qbehaviour_behaviourname_meaningfullkey. For qbehaviour_deferredcbm_highsummary.
409
     * The values are arrays with two items, title and content. Each of these
410
     * will be either a string, or a renderable.
411
     *
412
     * @param question_display_options $options display options to apply.
413
     * @return array as described above.
414
     */
415
    public function get_summary_information(question_display_options $options) {
416
        return question_engine::get_behaviour_type($this->preferredbehaviour)
417
                ->summarise_usage($this, $options);
418
    }
419
 
420
    /**
421
     * Get a simple textual summary of the question that was asked.
422
     *
423
     * @param int $slot the slot number of the question to summarise.
424
     * @return string the question summary.
425
     */
426
    public function get_question_summary($slot) {
427
        return $this->get_question_attempt($slot)->get_question_summary();
428
    }
429
 
430
    /**
431
     * Get a simple textual summary of response given.
432
     *
433
     * @param int $slot the slot number of the question to get the response summary for.
434
     * @return string the response summary.
435
     */
436
    public function get_response_summary($slot) {
437
        return $this->get_question_attempt($slot)->get_response_summary();
438
    }
439
 
440
    /**
441
     * Get a simple textual summary of the correct response to a question.
442
     *
443
     * @param int $slot the slot number of the question to get the right answer summary for.
444
     * @return string the right answer summary.
445
     */
446
    public function get_right_answer_summary($slot) {
447
        return $this->get_question_attempt($slot)->get_right_answer_summary();
448
    }
449
 
450
    /**
451
     * Return one of the bits of metadata for a particular question attempt in
452
     * this usage.
453
     * @param int $slot the slot number of the question of inereest.
454
     * @param string $name the name of the metadata variable to return.
455
     * @return string the value of that metadata variable.
456
     */
457
    public function get_question_attempt_metadata($slot, $name) {
458
        return $this->get_question_attempt($slot)->get_metadata($name);
459
    }
460
 
461
    /**
462
     * Set some metadata for a particular question attempt in this usage.
463
     * @param int $slot the slot number of the question of inerest.
464
     * @param string $name the name of the metadata variable to return.
465
     * @param string $value the value to set that metadata variable to.
466
     */
467
    public function set_question_attempt_metadata($slot, $name, $value) {
468
        $this->get_question_attempt($slot)->set_metadata($name, $value);
469
    }
470
 
471
    /**
472
     * Get the {@link core_question_renderer}, in collaboration with appropriate
473
     * {@link qbehaviour_renderer} and {@link qtype_renderer} subclasses, to generate the
474
     * HTML to display this question.
475
     * @param int $slot the number used to identify this question within this usage.
476
     * @param question_display_options $options controls how the question is rendered.
477
     * @param string|null $number The question number to display. 'i' is a special
478
     *      value that gets displayed as Information. Null means no number is displayed.
479
     * @return string HTML fragment representing the question.
480
     */
481
    public function render_question($slot, $options, $number = null) {
482
        $options->context = $this->context;
483
        return $this->get_question_attempt($slot)->render($options, $number);
484
    }
485
 
486
    /**
487
     * Generate any bits of HTML that needs to go in the <head> tag when this question
488
     * is displayed in the body.
489
     * @param int $slot the number used to identify this question within this usage.
490
     * @return string HTML fragment.
491
     */
492
    public function render_question_head_html($slot) {
493
        //$options->context = $this->context;
494
        return $this->get_question_attempt($slot)->render_head_html();
495
    }
496
 
497
    /**
498
     * Like {@link render_question()} but displays the question at the past step
499
     * indicated by $seq, rather than showing the latest step.
500
     *
501
     * @param int $slot the number used to identify this question within this usage.
502
     * @param int $seq the seq number of the past state to display.
503
     * @param question_display_options $options controls how the question is rendered.
504
     * @param string|null $number The question number to display. 'i' is a special
505
     *      value that gets displayed as Information. Null means no number is displayed.
506
     * @return string HTML fragment representing the question.
507
     */
508
    public function render_question_at_step($slot, $seq, $options, $number = null) {
509
        $options->context = $this->context;
510
        return $this->get_question_attempt($slot)->render_at_step(
511
                $seq, $options, $number, $this->preferredbehaviour);
512
    }
513
 
514
    /**
515
     * Checks whether the users is allow to be served a particular file.
516
     * @param int $slot the number used to identify this question within this usage.
517
     * @param question_display_options $options the options that control display of the question.
518
     * @param string $component the name of the component we are serving files for.
519
     * @param string $filearea the name of the file area.
520
     * @param array $args the remaining bits of the file path.
521
     * @param bool $forcedownload whether the user must be forced to download the file.
522
     * @return bool true if the user can access this file.
523
     */
524
    public function check_file_access($slot, $options, $component, $filearea,
525
            $args, $forcedownload) {
526
        return $this->get_question_attempt($slot)->check_file_access(
527
                $options, $component, $filearea, $args, $forcedownload);
528
    }
529
 
530
    /**
531
     * Replace a particular question_attempt with a different one.
532
     *
533
     * For internal use only. Used when reloading the state of a question from the
534
     * database.
535
     *
536
     * @param int $slot the slot number of the question to replace.
537
     * @param question_attempt $qa the question attempt to put in that place.
538
     */
539
    public function replace_loaded_question_attempt_info($slot, $qa) {
540
        $this->check_slot($slot);
541
        $this->questionattempts[$slot] = $qa;
542
    }
543
 
544
    /**
545
     * You should probably not use this method in code outside the question engine.
546
     * The main reason for exposing it was for the benefit of unit tests.
547
     * @param int $slot the number used to identify this question within this usage.
548
     * @return string return the prefix that is pre-pended to field names in the HTML
549
     * that is output.
550
     */
551
    public function get_field_prefix($slot) {
552
        return $this->get_question_attempt($slot)->get_field_prefix();
553
    }
554
 
555
    /**
556
     * Get the number of variants available for the question in this slot.
557
     * @param int $slot the number used to identify this question within this usage.
558
     * @return int the number of variants available.
559
     */
560
    public function get_num_variants($slot) {
561
        return $this->get_question_attempt($slot)->get_question()->get_num_variants();
562
    }
563
 
564
    /**
565
     * Get the variant of the question being used in a given slot.
566
     * @param int $slot the number used to identify this question within this usage.
567
     * @return int the variant of this question that is being used.
568
     */
569
    public function get_variant($slot) {
570
        return $this->get_question_attempt($slot)->get_variant();
571
    }
572
 
573
    /**
574
     * Start the attempt at a question that has been added to this usage.
575
     * @param int $slot the number used to identify this question within this usage.
576
     * @param int $variant which variant of the question to use. Must be between
577
     *      1 and ->get_num_variants($slot) inclusive. If not give, a variant is
578
     *      chosen at random.
579
     * @param int|null $timenow optional, the timstamp to record for this action. Defaults to now.
580
     */
581
    public function start_question($slot, $variant = null, $timenow = null) {
582
        if (is_null($variant)) {
583
            $variant = rand(1, $this->get_num_variants($slot));
584
        }
585
 
586
        $qa = $this->get_question_attempt($slot);
587
        $qa->start($this->preferredbehaviour, $variant, array(), $timenow);
588
        $this->observer->notify_attempt_modified($qa);
589
    }
590
 
591
    /**
592
     * Start the attempt at all questions that has been added to this usage.
593
     * @param question_variant_selection_strategy how to pick which variant of each question to use.
594
     * @param int $timestamp optional, the timstamp to record for this action. Defaults to now.
595
     * @param int $userid optional, the user to attribute this action to. Defaults to the current user.
596
     */
597
    public function start_all_questions(question_variant_selection_strategy $variantstrategy = null,
598
            $timestamp = null, $userid = null) {
599
        if (is_null($variantstrategy)) {
600
            $variantstrategy = new question_variant_random_strategy();
601
        }
602
 
603
        foreach ($this->questionattempts as $qa) {
604
            $qa->start($this->preferredbehaviour, $qa->select_variant($variantstrategy), array(),
605
                    $timestamp, $userid);
606
            $this->observer->notify_attempt_modified($qa);
607
        }
608
    }
609
 
610
    /**
611
     * Start the attempt at a question, starting from the point where the previous
612
     * question_attempt $oldqa had reached. This is used by the quiz 'Each attempt
613
     * builds on last' mode.
614
     * @param int $slot the number used to identify this question within this usage.
615
     * @param question_attempt $oldqa a previous attempt at this quetsion that
616
     *      defines the starting point.
617
     */
618
    public function start_question_based_on($slot, question_attempt $oldqa) {
619
        $qa = $this->get_question_attempt($slot);
620
        $qa->start_based_on($oldqa);
621
        $this->observer->notify_attempt_modified($qa);
622
    }
623
 
624
    /**
625
     * Process all the question actions in the current request.
626
     *
627
     * If there is a parameter slots included in the post data, then only
628
     * those question numbers will be processed, otherwise all questions in this
629
     * useage will be.
630
     *
631
     * This function also does {@link update_question_flags()}.
632
     *
633
     * @param int $timestamp optional, use this timestamp as 'now'.
634
     * @param array $postdata optional, only intended for testing. Use this data
635
     * instead of the data from $_POST.
636
     */
637
    public function process_all_actions($timestamp = null, $postdata = null) {
638
        foreach ($this->get_slots_in_request($postdata) as $slot) {
639
            if (!$this->validate_sequence_number($slot, $postdata)) {
640
                continue;
641
            }
642
            $submitteddata = $this->extract_responses($slot, $postdata);
643
            $this->process_action($slot, $submitteddata, $timestamp);
644
        }
645
        $this->update_question_flags($postdata);
646
    }
647
 
648
    /**
649
     * Process all the question autosave data in the current request.
650
     *
651
     * If there is a parameter slots included in the post data, then only
652
     * those question numbers will be processed, otherwise all questions in this
653
     * useage will be.
654
     *
655
     * This function also does {@link update_question_flags()}.
656
     *
657
     * @param int $timestamp optional, use this timestamp as 'now'.
658
     * @param array $postdata optional, only intended for testing. Use this data
659
     * instead of the data from $_POST.
660
     */
661
    public function process_all_autosaves($timestamp = null, $postdata = null) {
662
        foreach ($this->get_slots_in_request($postdata) as $slot) {
663
            if (!$this->is_autosave_required($slot, $postdata)) {
664
                continue;
665
            }
666
            $submitteddata = $this->extract_responses($slot, $postdata);
667
            $this->process_autosave($slot, $submitteddata, $timestamp);
668
        }
669
        $this->update_question_flags($postdata);
670
    }
671
 
672
    /**
673
     * Get the list of slot numbers that should be processed as part of processing
674
     * the current request.
675
     * @param array $postdata optional, only intended for testing. Use this data
676
     * instead of the data from $_POST.
677
     * @return array of slot numbers.
678
     */
679
    protected function get_slots_in_request($postdata = null) {
680
        // Note: we must not use "question_attempt::get_submitted_var()" because there is no attempt instance!!!
681
        if (is_null($postdata)) {
682
            $slots = optional_param('slots', null, PARAM_SEQUENCE);
683
        } else if (array_key_exists('slots', $postdata)) {
684
            $slots = clean_param($postdata['slots'], PARAM_SEQUENCE);
685
        } else {
686
            $slots = null;
687
        }
688
        if (is_null($slots)) {
689
            $slots = $this->get_slots();
690
        } else if (!$slots) {
691
            $slots = array();
692
        } else {
693
            $slots = explode(',', $slots);
694
        }
695
        return $slots;
696
    }
697
 
698
    /**
699
     * Get the submitted data from the current request that belongs to this
700
     * particular question.
701
     *
702
     * @param int $slot the number used to identify this question within this usage.
703
     * @param array|null $postdata optional, only intended for testing. Use this data
704
     * instead of the data from $_POST.
705
     * @return array submitted data specific to this question.
706
     */
707
    public function extract_responses($slot, $postdata = null) {
708
        return $this->get_question_attempt($slot)->get_submitted_data($postdata);
709
    }
710
 
711
    /**
712
     * Transform an array of response data for slots to an array of post data as you would get from quiz attempt form.
713
     *
714
     * @param $simulatedresponses array keys are slot nos => contains arrays representing student
715
     *                                   responses which will be passed to question_definition::prepare_simulated_post_data method
716
     *                                   and then have the appropriate prefix added.
717
     * @return array simulated post data
718
     */
719
    public function prepare_simulated_post_data($simulatedresponses) {
720
        $simulatedpostdata = array();
721
        $simulatedpostdata['slots'] = implode(',', array_keys($simulatedresponses));
722
        foreach ($simulatedresponses as $slot => $responsedata) {
723
            $slotresponse = array();
724
 
725
            // Behaviour vars should not be processed by question type, just add prefix.
726
            $behaviourvars = $this->get_question_attempt($slot)->get_behaviour()->get_expected_data();
727
            foreach (array_keys($responsedata) as $responsedatakey) {
728
                if (is_string($responsedatakey) && $responsedatakey[0] === '-') {
729
                    $behaviourvarname = substr($responsedatakey, 1);
730
                    if (isset($behaviourvars[$behaviourvarname])) {
731
                        // Expected behaviour var found.
732
                        if ($responsedata[$responsedatakey]) {
733
                            // Only set the behaviour var if the column value from the cvs file is non zero.
734
                            // The behaviours only look at whether the var is set or not they don't look at the value.
735
                            $slotresponse[$responsedatakey] = $responsedata[$responsedatakey];
736
                        }
737
                    }
738
                    // Remove both expected and unexpected vars from data passed to question type.
739
                    unset($responsedata[$responsedatakey]);
740
                }
741
            }
742
 
743
            $slotresponse += $this->get_question($slot)->prepare_simulated_post_data($responsedata);
744
            $slotresponse[':sequencecheck'] = $this->get_question_attempt($slot)->get_sequence_check_count();
745
 
746
            // Add this slot's prefix to slot data.
747
            $prefix = $this->get_field_prefix($slot);
748
            foreach ($slotresponse as $key => $value) {
749
                $simulatedpostdata[$prefix.$key] = $value;
750
            }
751
        }
752
        return $simulatedpostdata;
753
    }
754
 
755
    /**
756
     * Process a specific action on a specific question.
757
     * @param int $slot the number used to identify this question within this usage.
758
     * @param array $submitteddata the submitted data that constitutes the action.
759
     * @param int|null $timestamp (optional) the timestamp to consider 'now'.
760
     */
761
    public function process_action($slot, $submitteddata, $timestamp = null) {
762
        $qa = $this->get_question_attempt($slot);
763
        $qa->process_action($submitteddata, $timestamp);
764
        $this->observer->notify_attempt_modified($qa);
765
    }
766
 
767
    /**
768
     * Process an autosave action on a specific question.
769
     * @param int $slot the number used to identify this question within this usage.
770
     * @param array $submitteddata the submitted data that constitutes the action.
771
     * @param int|null $timestamp (optional) the timestamp to consider 'now'.
772
     */
773
    public function process_autosave($slot, $submitteddata, $timestamp = null) {
774
        $qa = $this->get_question_attempt($slot);
775
        if ($qa->process_autosave($submitteddata, $timestamp)) {
776
            $this->observer->notify_attempt_modified($qa);
777
        }
778
    }
779
 
780
    /**
781
     * Check that the sequence number, that detects weird things like the student clicking back, is OK.
782
     *
783
     * If the sequence check variable is not present, returns
784
     * false. If the check variable is present and correct, returns true. If the
785
     * variable is present and wrong, throws an exception.
786
     *
787
     * @param int $slot the number used to identify this question within this usage.
788
     * @param array|null $postdata (optional) data to use in place of $_POST.
789
     * @return bool true if the check variable is present and correct. False if it
790
     * is missing. (Throws an exception if the check fails.)
791
     */
792
    public function validate_sequence_number($slot, $postdata = null) {
793
        $qa = $this->get_question_attempt($slot);
794
        $sequencecheck = $qa->get_submitted_var(
795
                $qa->get_control_field_name('sequencecheck'), PARAM_INT, $postdata);
796
        if (is_null($sequencecheck)) {
797
            return false;
798
        } else if ($sequencecheck != $qa->get_sequence_check_count()) {
799
            throw new question_out_of_sequence_exception($this->id, $slot, $postdata);
800
        } else {
801
            return true;
802
        }
803
    }
804
 
805
    /**
806
     * Check, based on the sequence number, whether this auto-save is still required.
807
     *
808
     * @param int $slot the number used to identify this question within this usage.
809
     * @param array|null $postdata the submitted data that constitutes the action.
810
     * @return bool true if the check variable is present and correct, otherwise false.
811
     */
812
    public function is_autosave_required($slot, $postdata = null) {
813
        $qa = $this->get_question_attempt($slot);
814
        $sequencecheck = $qa->get_submitted_var(
815
                $qa->get_control_field_name('sequencecheck'), PARAM_INT, $postdata);
816
        if (is_null($sequencecheck)) {
817
            return false;
818
        } else if ($sequencecheck != $qa->get_sequence_check_count()) {
819
            return false;
820
        } else {
821
            return true;
822
        }
823
    }
824
 
825
    /**
826
     * Update the flagged state for all question_attempts in this usage, if their
827
     * flagged state was changed in the request.
828
     *
829
     * @param array|null $postdata optional, only intended for testing. Use this data
830
     * instead of the data from $_POST.
831
     */
832
    public function update_question_flags($postdata = null) {
833
        foreach ($this->questionattempts as $qa) {
834
            $flagged = $qa->get_submitted_var(
835
                    $qa->get_flag_field_name(), PARAM_BOOL, $postdata);
836
            if (!is_null($flagged) && $flagged != $qa->is_flagged()) {
837
                $qa->set_flagged($flagged);
838
            }
839
        }
840
    }
841
 
842
    /**
843
     * Get the correct response to a particular question. Passing the results of
844
     * this method to {@link process_action()} will probably result in full marks.
845
     * If it is not possible to compute a correct response, this method should return null.
846
     * @param int $slot the number used to identify this question within this usage.
847
     * @return array that constitutes a correct response to this question.
848
     */
849
    public function get_correct_response($slot) {
850
        return $this->get_question_attempt($slot)->get_correct_response();
851
    }
852
 
853
    /**
854
     * Finish the active phase of an attempt at a question.
855
     *
856
     * This is an external act of finishing the attempt. Think, for example, of
857
     * the 'Submit all and finish' button in the quiz. Some behaviours,
858
     * (for example, immediatefeedback) give a way of finishing the active phase
859
     * of a question attempt as part of a {@link process_action()} call.
860
     *
861
     * After the active phase is over, the only changes possible are things like
862
     * manual grading, or changing the flag state.
863
     *
864
     * @param int $slot the number used to identify this question within this usage.
865
     * @param int|null $timestamp (optional) the timestamp to consider 'now'.
866
     */
867
    public function finish_question($slot, $timestamp = null) {
868
        $qa = $this->get_question_attempt($slot);
869
        $qa->finish($timestamp);
870
        $this->observer->notify_attempt_modified($qa);
871
    }
872
 
873
    /**
874
     * Finish the active phase of an attempt at a question. See {@link finish_question()}
875
     * for a fuller description of what 'finish' means.
876
     *
877
     * @param int|null $timestamp (optional) the timestamp to consider 'now'.
878
     */
879
    public function finish_all_questions($timestamp = null) {
880
        foreach ($this->questionattempts as $qa) {
881
            $qa->finish($timestamp);
882
            $this->observer->notify_attempt_modified($qa);
883
        }
884
    }
885
 
886
    /**
887
     * Perform a manual grading action on a question attempt.
888
     * @param int $slot the number used to identify this question within this usage.
889
     * @param string $comment the comment being added to the question attempt.
890
     * @param number $mark the mark that is being assigned. Can be null to just
891
     * add a comment.
892
     * @param int $commentformat one of the FORMAT_... constants. The format of $comment.
893
     */
894
    public function manual_grade($slot, $comment, $mark, $commentformat = null) {
895
        $qa = $this->get_question_attempt($slot);
896
        $qa->manual_grade($comment, $mark, $commentformat);
897
        $this->observer->notify_attempt_modified($qa);
898
    }
899
 
900
    /**
901
     * Verify if the question_attempt in the given slot can be regraded with that other question version.
902
     *
903
     * @param int $slot the number used to identify this question within this usage.
904
     * @param question_definition $otherversion a different version of the question to use in the regrade.
905
     * @return string|null null if the regrade can proceed, else a reason why not.
906
     */
907
    public function validate_can_regrade_with_other_version(int $slot, question_definition $otherversion): ?string {
908
        return $this->get_question_attempt($slot)->validate_can_regrade_with_other_version($otherversion);
909
    }
910
 
911
    /**
912
     * Regrade a question in this usage. This replays the sequence of submitted
913
     * actions to recompute the outcomes.
914
     *
915
     * @param int $slot the number used to identify this question within this usage.
916
     * @param bool $finished whether the question attempt should be forced to be finished
917
     *      after the regrade, or whether it may still be in progress (default false).
918
     * @param number $newmaxmark (optional) if given, will change the max mark while regrading.
919
     * @param question_definition|null $otherversion a different version of the question to use
920
     *      in the regrade. (By default, the regrode will use exactly the same question version.)
921
     */
922
    public function regrade_question($slot, $finished = false, $newmaxmark = null,
923
            question_definition $otherversion = null) {
924
        $oldqa = $this->get_question_attempt($slot);
925
        if ($otherversion &&
926
                $otherversion->questionbankentryid !== $oldqa->get_question(false)->questionbankentryid) {
927
            throw new coding_exception('You can only regrade using a different version of the same question, ' .
928
                    'not a completely different question.');
929
        }
930
        if (is_null($newmaxmark)) {
931
            $newmaxmark = $oldqa->get_max_mark();
932
        }
933
        $newqa = new question_attempt($otherversion ?? $oldqa->get_question(false),
934
                $oldqa->get_usage_id(), $this->observer, $newmaxmark);
935
        $newqa->set_database_id($oldqa->get_database_id());
936
        $newqa->set_slot($oldqa->get_slot());
937
        $newqa->regrade($oldqa, $finished);
938
 
939
        $this->questionattempts[$slot] = $newqa;
940
        $this->observer->notify_attempt_modified($newqa);
941
    }
942
 
943
    /**
944
     * Regrade all the questions in this usage (without changing their max mark).
945
     * @param bool $finished whether each question should be forced to be finished
946
     *      after the regrade, or whether it may still be in progress (default false).
947
     */
948
    public function regrade_all_questions($finished = false) {
949
        foreach ($this->questionattempts as $slot => $notused) {
950
            $this->regrade_question($slot, $finished);
951
        }
952
    }
953
 
954
    /**
955
     * Change the max mark for this question_attempt.
956
     * @param int $slot the slot number of the question of inerest.
957
     * @param float $maxmark the new max mark.
958
     */
959
    public function set_max_mark($slot, $maxmark) {
960
        $this->get_question_attempt($slot)->set_max_mark($maxmark);
961
    }
962
 
963
    /**
964
     * Create a question_usage_by_activity from records loaded from the database.
965
     *
966
     * For internal use only.
967
     *
968
     * @param Iterator $records Raw records loaded from the database.
969
     * @param int $qubaid The id of the question usage we are loading.
970
     * @return question_usage_by_activity The newly constructed usage.
971
     */
972
    public static function load_from_records($records, $qubaid) {
973
        $record = $records->current();
974
        while ($record->qubaid != $qubaid) {
975
            $records->next();
976
            if (!$records->valid()) {
977
                throw new coding_exception("Question usage {$qubaid} not found in the database.");
978
            }
979
            $record = $records->current();
980
        }
981
 
982
        $quba = new question_usage_by_activity($record->component,
983
            context::instance_by_id($record->contextid, IGNORE_MISSING));
984
        $quba->set_id_from_database($record->qubaid);
985
        $quba->set_preferred_behaviour($record->preferredbehaviour);
986
 
987
        $quba->observer = new question_engine_unit_of_work($quba);
988
 
989
        // If slot is null then the current pointer in $records will not be
990
        // advanced in the while loop below, and we get stuck in an infinite loop,
991
        // since this method is supposed to always consume at least one record.
992
        // Therefore, in this case, advance the record here.
993
        if (is_null($record->slot)) {
994
            $records->next();
995
        }
996
 
997
        while ($record && $record->qubaid == $qubaid && !is_null($record->slot)) {
998
            $quba->questionattempts[$record->slot] =
999
                    question_attempt::load_from_records($records,
1000
                    $record->questionattemptid, $quba->observer,
1001
                    $quba->get_preferred_behaviour());
1002
            if ($records->valid()) {
1003
                $record = $records->current();
1004
            } else {
1005
                $record = false;
1006
            }
1007
        }
1008
 
1009
        return $quba;
1010
    }
1011
 
1012
    /**
1013
     * Preload users of all question attempt steps.
1014
     *
1015
     * @throws dml_exception
1016
     */
1017
    public function preload_all_step_users(): void {
1018
        global $DB;
1019
 
1020
        // Get all user ids.
1021
        $userids = [];
1022
        foreach ($this->questionattempts as $qa) {
1023
            foreach ($qa->get_full_step_iterator() as $step) {
1024
                $userids[$step->get_user_id()] = 1;
1025
            }
1026
        }
1027
 
1028
        // Load user information.
1029
        $users = $DB->get_records_list('user', 'id', array_keys($userids), '', '*');
1030
        // Update user information for steps.
1031
        foreach ($this->questionattempts as $qa) {
1032
            foreach ($qa->get_full_step_iterator() as $step) {
1033
                if (isset($users[$step->get_user_id()])) {
1034
                    $step->add_full_user_object($users[$step->get_user_id()]);
1035
                }
1036
            }
1037
        }
1038
    }
1039
}
1040
 
1041
 
1042
/**
1043
 * A class abstracting access to the {@link question_usage_by_activity::$questionattempts} array.
1044
 *
1045
 * This class snapshots the list of {@link question_attempts} to iterate over
1046
 * when it is created. If a question is added to the usage mid-iteration, it
1047
 * will now show up.
1048
 *
1049
 * To create an instance of this class, use
1050
 * {@link question_usage_by_activity::get_attempt_iterator()}
1051
 *
1052
 * @copyright  2009 The Open University
1053
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1054
 */
1055
class question_attempt_iterator implements Iterator, ArrayAccess {
1056
 
1057
    /** @var question_usage_by_activity that we are iterating over. */
1058
    protected $quba;
1059
 
1060
    /** @var array of slot numbers. */
1061
    protected $slots;
1062
 
1063
    /**
1064
     * To create an instance of this class, use
1065
     * {@link question_usage_by_activity::get_attempt_iterator()}.
1066
     *
1067
     * @param question_usage_by_activity $quba the usage to iterate over.
1068
     */
1069
    public function __construct(question_usage_by_activity $quba) {
1070
        $this->quba = $quba;
1071
        $this->slots = $quba->get_slots();
1072
        $this->rewind();
1073
    }
1074
 
1075
    /**
1076
     * Standard part of the Iterator interface.
1077
     *
1078
     * @return question_attempt
1079
     */
1080
    #[\ReturnTypeWillChange]
1081
    public function current() {
1082
        return $this->offsetGet(current($this->slots));
1083
    }
1084
 
1085
    /**
1086
     * Standard part of the Iterator interface.
1087
     *
1088
     * @return int
1089
     */
1090
    #[\ReturnTypeWillChange]
1091
    public function key() {
1092
        return current($this->slots);
1093
    }
1094
 
1095
    /**
1096
     * Standard part of the Iterator interface.
1097
     */
1098
    public function next(): void {
1099
        next($this->slots);
1100
    }
1101
 
1102
    /**
1103
     * Standard part of the Iterator interface.
1104
     */
1105
    public function rewind(): void {
1106
        reset($this->slots);
1107
    }
1108
 
1109
    /**
1110
     * Standard part of the Iterator interface.
1111
     *
1112
     * @return bool
1113
     */
1114
    public function valid(): bool {
1115
        return current($this->slots) !== false;
1116
    }
1117
 
1118
    /**
1119
     * Standard part of the ArrayAccess interface.
1120
     *
1121
     * @param int $slot
1122
     * @return bool
1123
     */
1124
    public function offsetExists($slot): bool {
1125
        return in_array($slot, $this->slots);
1126
    }
1127
 
1128
    /**
1129
     * Standard part of the ArrayAccess interface.
1130
     *
1131
     * @param int $slot
1132
     * @return question_attempt
1133
     */
1134
    #[\ReturnTypeWillChange]
1135
    public function offsetGet($slot) {
1136
        return $this->quba->get_question_attempt($slot);
1137
    }
1138
 
1139
    /**
1140
     * Standard part of the ArrayAccess interface.
1141
     *
1142
     * @param int $slot
1143
     * @param question_attempt $value
1144
     */
1145
    public function offsetSet($slot, $value): void {
1146
        throw new coding_exception('You are only allowed read-only access to ' .
1147
                'question_attempt::states through a question_attempt_step_iterator. Cannot set.');
1148
    }
1149
 
1150
    /**
1151
     * Standard part of the ArrayAccess interface.
1152
     *
1153
     * @param int $slot
1154
     */
1155
    public function offsetUnset($slot): void {
1156
        throw new coding_exception('You are only allowed read-only access to ' .
1157
                'question_attempt::states through a question_attempt_step_iterator. Cannot unset.');
1158
    }
1159
}
1160
 
1161
 
1162
/**
1163
 * Interface for things that want to be notified of signficant changes to a
1164
 * {@link question_usage_by_activity}.
1165
 *
1166
 * A question behaviour controls the flow of actions a student can
1167
 * take as they work through a question, and later, as a teacher manually grades it.
1168
 *
1169
 * @copyright  2009 The Open University
1170
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1171
 */
1172
interface question_usage_observer {
1173
    /** Called when a field of the question_usage_by_activity is changed. */
1174
    public function notify_modified();
1175
 
1176
    /**
1177
     * Called when a new question attempt is added to this usage.
1178
     * @param question_attempt $qa the newly added question attempt.
1179
     */
1180
    public function notify_attempt_added(question_attempt $qa);
1181
 
1182
    /**
1183
     * Called when the fields of a question attempt in this usage are modified.
1184
     * @param question_attempt $qa the newly added question attempt.
1185
     */
1186
    public function notify_attempt_modified(question_attempt $qa);
1187
 
1188
    /**
1189
     * Called when a question_attempt has been moved to a new slot.
1190
     * @param question_attempt $qa The question attempt that was moved.
1191
     * @param int $oldslot The previous slot number of that attempt.
1192
     */
1193
    public function notify_attempt_moved(question_attempt $qa, $oldslot);
1194
 
1195
    /**
1196
     * Called when a new step is added to a question attempt in this usage.
1197
     * @param question_attempt_step $step the new step.
1198
     * @param question_attempt $qa the usage it is being added to.
1199
     * @param int $seq the sequence number of the new step.
1200
     */
1201
    public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq);
1202
 
1203
    /**
1204
     * Called when a new step is updated in a question attempt in this usage.
1205
     * @param question_attempt_step $step the step that was updated.
1206
     * @param question_attempt $qa the usage it is being added to.
1207
     * @param int $seq the sequence number of the new step.
1208
     */
1209
    public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq);
1210
 
1211
    /**
1212
     * Called when a new step is updated in a question attempt in this usage.
1213
     * @param question_attempt_step $step the step to delete.
1214
     * @param question_attempt $qa the usage it is being added to.
1215
     */
1216
    public function notify_step_deleted(question_attempt_step $step, question_attempt $qa);
1217
 
1218
    /**
1219
     * Called when a new metadata variable is set on a question attempt in this usage.
1220
     * @param question_attempt $qa the question attempt the metadata is being added to.
1221
     * @param int $name the name of the metadata variable added.
1222
     */
1223
    public function notify_metadata_added(question_attempt $qa, $name);
1224
 
1225
    /**
1226
     * Called when a metadata variable on a question attempt in this usage is updated.
1227
     * @param question_attempt $qa the question attempt where the metadata is being modified.
1228
     * @param int $name the name of the metadata variable modified.
1229
     */
1230
    public function notify_metadata_modified(question_attempt $qa, $name);
1231
}
1232
 
1233
 
1234
/**
1235
 * Null implmentation of the {@link question_usage_watcher} interface.
1236
 * Does nothing.
1237
 *
1238
 * @copyright  2009 The Open University
1239
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1240
 */
1241
class question_usage_null_observer implements question_usage_observer {
1242
    public function notify_modified() {
1243
    }
1244
    public function notify_attempt_added(question_attempt $qa) {
1245
    }
1246
    public function notify_attempt_modified(question_attempt $qa) {
1247
    }
1248
    public function notify_attempt_moved(question_attempt $qa, $oldslot) {
1249
    }
1250
    public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
1251
    }
1252
    public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq) {
1253
    }
1254
    public function notify_step_deleted(question_attempt_step $step, question_attempt $qa) {
1255
    }
1256
    public function notify_metadata_added(question_attempt $qa, $name) {
1257
    }
1258
    public function notify_metadata_modified(question_attempt $qa, $name) {
1259
    }
1260
}