Proyectos de Subversion Moodle

Rev

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

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
// This file is part of Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
 
17
namespace mod_quiz;
18
 
19
use coding_exception;
20
use context_module;
21
use core\output\inplace_editable;
1441 ariadna 22
use core_question\local\bank\version_options;
1 efrain 23
use mod_quiz\event\quiz_grade_item_created;
24
use mod_quiz\event\quiz_grade_item_deleted;
25
use mod_quiz\event\quiz_grade_item_updated;
26
use mod_quiz\event\slot_grade_item_updated;
27
use mod_quiz\event\slot_mark_updated;
1441 ariadna 28
use mod_quiz\event\slot_version_updated;
1 efrain 29
use mod_quiz\question\bank\qbank_helper;
30
use mod_quiz\question\qubaids_for_quiz;
31
use stdClass;
32
 
33
/**
34
 * Quiz structure class.
35
 *
36
 * The structure of the quiz. That is, which questions it is built up
37
 * from. This is used on the Edit quiz page (edit.php) and also when
38
 * starting an attempt at the quiz (startattempt.php). Once an attempt
39
 * has been started, then the attempt holds the specific set of questions
40
 * that that student should answer, and we no longer use this class.
41
 *
42
 * @package   mod_quiz
43
 * @copyright 2014 The Open University
44
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
45
 */
46
class structure {
1441 ariadna 47
 
48
    /**
49
     * Placeholder string used when a question category is missing.
50
     */
51
    const MISSING_QUESTION_CATEGORY_PLACEHOLDER = 'missing_question_category';
52
 
1 efrain 53
    /** @var quiz_settings the quiz this is the structure of. */
54
    protected $quizobj = null;
55
 
56
    /**
57
     * @var stdClass[] the questions in this quiz. Contains the row from the questions
58
     * table, with the data from the quiz_slots table added, and also question_categories.contextid.
59
     */
60
    protected $questions = [];
61
 
62
    /** @var stdClass[] quiz_slots.slot => the quiz_slots rows for this quiz, augmented by sectionid. */
63
    protected $slotsinorder = [];
64
 
65
    /**
66
     * @var stdClass[] this quiz's data from the quiz_sections table. Each item has a ->lastslot field too.
67
     */
68
    protected $sections = [];
69
 
70
    /** @var stdClass[] quiz_grade_items for this quiz indexed by id. */
71
    protected array $gradeitems = [];
72
 
73
    /** @var bool caches the results of can_be_edited. */
74
    protected $canbeedited = null;
75
 
76
    /** @var bool caches the results of can_add_random_question. */
77
    protected $canaddrandom = null;
78
 
1441 ariadna 79
    /** @var array the slotids => question categories array for all slots containing a random question. */
80
    protected $randomslotcategories = null;
81
 
82
    /** @var array the slotids => question tags array for all slots containing a random question. */
83
    protected $randomslottags = null;
84
 
85
    /** @var array an array of question banks course_modules records indexed by their associated contextid */
86
    protected array $questionsources = [];
87
 
1 efrain 88
    /**
89
     * Create an instance of this class representing an empty quiz.
90
     *
91
     * @return structure
92
     */
93
    public static function create() {
94
        return new self();
95
    }
96
 
97
    /**
98
     * Create an instance of this class representing the structure of a given quiz.
99
     *
100
     * @param quiz_settings $quizobj the quiz.
101
     * @return structure
102
     */
103
    public static function create_for_quiz($quizobj) {
104
        $structure = self::create();
105
        $structure->quizobj = $quizobj;
106
        $structure->populate_structure();
107
        return $structure;
108
    }
109
 
110
    /**
111
     * Whether there are any questions in the quiz.
112
     *
113
     * @return bool true if there is at least one question in the quiz.
114
     */
115
    public function has_questions() {
116
        return !empty($this->questions);
117
    }
118
 
119
    /**
120
     * Get the number of questions in the quiz.
121
     *
122
     * @return int the number of questions in the quiz.
123
     */
124
    public function get_question_count() {
125
        return count($this->questions);
126
    }
127
 
128
    /**
129
     * Get the information about the question with this id.
130
     *
131
     * @param int $questionid The question id.
132
     * @return stdClass the data from the questions table, augmented with
133
     * question_category.contextid, and the quiz_slots data for the question in this quiz.
134
     */
135
    public function get_question_by_id($questionid) {
136
        return $this->questions[$questionid];
137
    }
138
 
139
    /**
140
     * Get the information about the question in a given slot.
141
     *
142
     * @param int $slotnumber the index of the slot in question.
143
     * @return stdClass the data from the questions table, augmented with
144
     * question_category.contextid, and the quiz_slots data for the question in this quiz.
145
     */
146
    public function get_question_in_slot($slotnumber) {
147
        return $this->questions[$this->slotsinorder[$slotnumber]->questionid];
148
    }
149
 
150
    /**
151
     * Get the name of the question in a given slot.
152
     *
153
     * @param int $slotnumber the index of the slot in question.
154
     * @return stdClass the data from the questions table, augmented with
155
     */
156
    public function get_question_name_in_slot($slotnumber) {
157
        return $this->questions[$this->slotsinorder[$slotnumber]->name];
158
    }
159
 
160
    /**
161
     * Get the displayed question number (or 'i') for a given slot.
162
     *
163
     * @param int $slotnumber the index of the slot in question.
164
     * @return string the question number ot display for this slot.
165
     */
166
    public function get_displayed_number_for_slot($slotnumber) {
167
        $slot = $this->slotsinorder[$slotnumber];
168
        return $slot->displaynumber ?? $slot->defaultnumber;
169
    }
170
 
171
    /**
172
     * Check the question has a number that could be customised.
173
     *
174
     * @param int $slotnumber
175
     * @return bool
176
     */
177
    public function can_display_number_be_customised(int $slotnumber): bool {
178
        return $this->is_real_question($slotnumber) && !quiz_has_attempts($this->quizobj->get_quizid());
179
    }
180
 
181
    /**
182
     * Check whether the question number is customised.
183
     *
184
     * @param int $slotid
185
     * @return bool
186
     * @todo MDL-76612 Final deprecation in Moodle 4.6
187
     * @deprecated since 4.2. $slot->displayednumber is no longer used. If you need this,
188
     *      use isset(...->displaynumber), but this method was not used.
189
     */
190
    public function is_display_number_customised(int $slotid): bool {
191
        $slotobj = $this->get_slot_by_id($slotid);
192
        return isset($slotobj->displaynumber);
193
    }
194
 
195
    /**
196
     * Make slot display number in place editable api call.
197
 
198
     * @param int $slotid
199
     * @param \context $context
200
     * @return \core\output\inplace_editable
201
     */
202
    public function make_slot_display_number_in_place_editable(int $slotid, \context $context): \core\output\inplace_editable {
203
        $slot = $this->get_slot_by_id($slotid);
204
        $editable = has_capability('mod/quiz:manage', $context);
205
 
206
        // Get the current value.
207
        $value = $slot->displaynumber ?? $slot->defaultnumber;
208
        $displayvalue = s($value);
209
 
210
        return new inplace_editable('mod_quiz', 'slotdisplaynumber', $slotid,
211
                $editable, $displayvalue, $value,
212
                get_string('edit_slotdisplaynumber_hint', 'mod_quiz'),
213
                get_string('edit_slotdisplaynumber_label', 'mod_quiz', $displayvalue));
214
    }
215
 
216
    /**
217
     * Get the page a given slot is on.
218
     *
219
     * @param int $slotnumber the index of the slot in question.
220
     * @return int the page number of the page that slot is on.
221
     */
222
    public function get_page_number_for_slot($slotnumber) {
223
        return $this->slotsinorder[$slotnumber]->page;
224
    }
225
 
226
    /**
227
     * Get the slot id of a given slot slot.
228
     *
229
     * @param int $slotnumber the index of the slot in question.
230
     * @return int the page number of the page that slot is on.
231
     */
232
    public function get_slot_id_for_slot($slotnumber) {
233
        return $this->slotsinorder[$slotnumber]->id;
234
    }
235
 
236
    /**
237
     * Get the question type in a given slot.
238
     *
239
     * @param int $slotnumber the index of the slot in question.
240
     * @return string the question type (e.g. multichoice).
241
     */
242
    public function get_question_type_for_slot($slotnumber) {
243
        return $this->questions[$this->slotsinorder[$slotnumber]->questionid]->qtype;
244
    }
245
 
246
    /**
247
     * Whether it would be possible, given the question types, etc. for the
248
     * question in the given slot to require that the previous question had been
249
     * answered before this one is displayed.
250
     *
251
     * @param int $slotnumber the index of the slot in question.
252
     * @return bool can this question require the previous one.
253
     */
254
    public function can_question_depend_on_previous_slot($slotnumber) {
255
        return $slotnumber > 1 && $this->can_finish_during_the_attempt($slotnumber - 1);
256
    }
257
 
258
    /**
259
     * Whether it is possible for another question to depend on this one finishing.
260
     * Note that the answer is not exact, because of random questions, and sometimes
261
     * questions cannot be depended upon because of quiz options.
262
     *
263
     * @param int $slotnumber the index of the slot in question.
264
     * @return bool can this question finish naturally during the attempt?
265
     */
266
    public function can_finish_during_the_attempt($slotnumber) {
267
        if ($this->quizobj->get_navigation_method() == QUIZ_NAVMETHOD_SEQ) {
268
            return false;
269
        }
270
 
271
        if ($this->slotsinorder[$slotnumber]->section->shufflequestions) {
272
            return false;
273
        }
274
 
275
        if (in_array($this->get_question_type_for_slot($slotnumber), ['random', 'missingtype'])) {
276
            return \question_engine::can_questions_finish_during_the_attempt(
277
                    $this->quizobj->get_quiz()->preferredbehaviour);
278
        }
279
 
280
        if (isset($this->slotsinorder[$slotnumber]->canfinish)) {
281
            return $this->slotsinorder[$slotnumber]->canfinish;
282
        }
283
 
284
        try {
285
            $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $this->quizobj->get_context());
286
            $tempslot = $quba->add_question(\question_bank::load_question(
287
                    $this->slotsinorder[$slotnumber]->questionid));
288
            $quba->set_preferred_behaviour($this->quizobj->get_quiz()->preferredbehaviour);
289
            $quba->start_all_questions();
290
 
291
            $this->slotsinorder[$slotnumber]->canfinish = $quba->can_question_finish_during_attempt($tempslot);
292
            return $this->slotsinorder[$slotnumber]->canfinish;
293
        } catch (\Exception $e) {
294
            // If the question fails to start, this should not block editing.
295
            return false;
296
        }
297
    }
298
 
299
    /**
300
     * Whether it would be possible, given the question types, etc. for the
301
     * question in the given slot to require that the previous question had been
302
     * answered before this one is displayed.
303
     *
304
     * @param int $slotnumber the index of the slot in question.
305
     * @return bool can this question require the previous one.
306
     */
307
    public function is_question_dependent_on_previous_slot($slotnumber) {
308
        return $this->slotsinorder[$slotnumber]->requireprevious;
309
    }
310
 
311
    /**
312
     * Is a particular question in this attempt a real question, or something like a description.
313
     *
314
     * @param int $slotnumber the index of the slot in question.
315
     * @return bool whether that question is a real question.
316
     */
317
    public function is_real_question($slotnumber) {
318
        return $this->get_question_in_slot($slotnumber)->length != 0;
319
    }
320
 
321
    /**
322
     * Does the current user have '...use' capability over the question(s) in a given slot?
323
     *
324
     *
325
     * @param int $slotnumber the index of the slot in question.
326
     * @return bool true if they have the required capability.
327
     */
328
    public function has_use_capability(int $slotnumber): bool {
329
        $slot = $this->slotsinorder[$slotnumber];
330
        if (is_numeric($slot->questionid)) {
331
            // Non-random question.
332
            return question_has_capability_on($this->get_question_by_id($slot->questionid), 'use');
333
        } else {
334
            // Random question.
335
            $context = \context::instance_by_id($slot->contextid);
336
            return has_capability('moodle/question:useall', $context);
337
        }
338
    }
339
 
340
    /**
341
     * Get the course id that the quiz belongs to.
342
     *
343
     * @return int the course.id for the quiz.
344
     */
345
    public function get_courseid() {
346
        return $this->quizobj->get_courseid();
347
    }
348
 
349
    /**
350
     * Get the course module id of the quiz.
351
     *
352
     * @return int the course_modules.id for the quiz.
353
     */
354
    public function get_cmid() {
355
        return $this->quizobj->get_cmid();
356
    }
357
 
358
    /**
359
     * Get the quiz context.
360
     *
361
     * @return context_module the context of the quiz that this is the structure of.
362
     */
363
    public function get_context(): context_module {
364
        return $this->quizobj->get_context();
365
    }
366
 
367
    /**
368
     * Get id of the quiz.
369
     *
370
     * @return int the quiz.id for the quiz.
371
     */
372
    public function get_quizid() {
373
        return $this->quizobj->get_quizid();
374
    }
375
 
376
    /**
377
     * Get the quiz object.
378
     *
379
     * @return stdClass the quiz settings row from the database.
380
     */
381
    public function get_quiz() {
382
        return $this->quizobj->get_quiz();
383
    }
384
 
385
    /**
386
     * Quizzes can only be repaginated if they have not been attempted, the
387
     * questions are not shuffled, and there are two or more questions.
388
     *
389
     * @return bool whether this quiz can be repaginated.
390
     */
391
    public function can_be_repaginated() {
392
        return $this->can_be_edited() && $this->get_question_count() >= 2;
393
    }
394
 
395
    /**
396
     * Quizzes can only be edited if they have not been attempted.
397
     *
398
     * @return bool whether the quiz can be edited.
399
     */
400
    public function can_be_edited() {
401
        if ($this->canbeedited === null) {
402
            $this->canbeedited = !quiz_has_attempts($this->quizobj->get_quizid());
403
        }
404
        return $this->canbeedited;
405
    }
406
 
407
    /**
408
     * This quiz can only be edited if they have not been attempted.
409
     * Throw an exception if this is not the case.
410
     */
411
    public function check_can_be_edited() {
412
        if (!$this->can_be_edited()) {
413
            $reportlink = quiz_attempt_summary_link_to_reports($this->get_quiz(),
414
                    $this->quizobj->get_cm(), $this->quizobj->get_context());
415
            throw new \moodle_exception('cannoteditafterattempts', 'quiz',
416
                    new \moodle_url('/mod/quiz/edit.php', ['cmid' => $this->get_cmid()]), $reportlink);
417
        }
418
    }
419
 
420
    /**
421
     * How many questions are allowed per page in the quiz.
422
     * This setting controls how frequently extra page-breaks should be inserted
423
     * automatically when questions are added to the quiz.
424
     *
425
     * @return int the number of questions that should be on each page of the
426
     * quiz by default.
427
     */
428
    public function get_questions_per_page() {
429
        return $this->quizobj->get_quiz()->questionsperpage;
430
    }
431
 
432
    /**
433
     * Get quiz slots.
434
     *
435
     * @return stdClass[] the slots in this quiz.
436
     */
437
    public function get_slots() {
438
        return array_column($this->slotsinorder, null, 'id');
439
    }
440
 
441
    /**
442
     * Is this slot the first one on its page?
443
     *
444
     * @param int $slotnumber the index of the slot in question.
445
     * @return bool whether this slot the first one on its page.
446
     */
447
    public function is_first_slot_on_page($slotnumber) {
448
        if ($slotnumber == 1) {
449
            return true;
450
        }
451
        return $this->slotsinorder[$slotnumber]->page != $this->slotsinorder[$slotnumber - 1]->page;
452
    }
453
 
454
    /**
455
     * Is this slot the last one on its page?
456
     *
457
     * @param int $slotnumber the index of the slot in question.
458
     * @return bool whether this slot the last one on its page.
459
     */
460
    public function is_last_slot_on_page($slotnumber) {
461
        if (!isset($this->slotsinorder[$slotnumber + 1])) {
462
            return true;
463
        }
464
        return $this->slotsinorder[$slotnumber]->page != $this->slotsinorder[$slotnumber + 1]->page;
465
    }
466
 
467
    /**
468
     * Is this slot the last one in its section?
469
     *
470
     * @param int $slotnumber the index of the slot in question.
471
     * @return bool whether this slot the last one on its section.
472
     */
473
    public function is_last_slot_in_section($slotnumber) {
474
        return $slotnumber == $this->slotsinorder[$slotnumber]->section->lastslot;
475
    }
476
 
477
    /**
478
     * Is this slot the only one in its section?
479
     *
480
     * @param int $slotnumber the index of the slot in question.
481
     * @return bool whether this slot the only one on its section.
482
     */
483
    public function is_only_slot_in_section($slotnumber) {
484
        return $this->slotsinorder[$slotnumber]->section->firstslot ==
485
                $this->slotsinorder[$slotnumber]->section->lastslot;
486
    }
487
 
488
    /**
489
     * Is this slot the last one in the quiz?
490
     *
491
     * @param int $slotnumber the index of the slot in question.
492
     * @return bool whether this slot the last one in the quiz.
493
     */
494
    public function is_last_slot_in_quiz($slotnumber) {
495
        end($this->slotsinorder);
496
        return $slotnumber == key($this->slotsinorder);
497
    }
498
 
499
    /**
500
     * Is this the first section in the quiz?
501
     *
502
     * @param stdClass $section the quiz_sections row.
503
     * @return bool whether this is first section in the quiz.
504
     */
505
    public function is_first_section($section) {
506
        return $section->firstslot == 1;
507
    }
508
 
509
    /**
510
     * Is this the last section in the quiz?
511
     *
512
     * @param stdClass $section the quiz_sections row.
513
     * @return bool whether this is first section in the quiz.
514
     */
515
    public function is_last_section($section) {
516
        return $section->id == end($this->sections)->id;
517
    }
518
 
519
    /**
520
     * Does this section only contain one slot?
521
     *
522
     * @param stdClass $section the quiz_sections row.
523
     * @return bool whether this section contains only one slot.
524
     */
525
    public function is_only_one_slot_in_section($section) {
526
        return $section->firstslot == $section->lastslot;
527
    }
528
 
529
    /**
530
     * Get the final slot in the quiz.
531
     *
532
     * @return stdClass the quiz_slots for the final slot in the quiz.
533
     */
534
    public function get_last_slot() {
535
        return end($this->slotsinorder);
536
    }
537
 
538
    /**
539
     * Get a slot by its id. Throws an exception if it is missing.
540
     *
541
     * @param int $slotid the slot id.
542
     * @return stdClass the requested quiz_slots row.
543
     */
544
    public function get_slot_by_id($slotid) {
545
        foreach ($this->slotsinorder as $slot) {
546
            if ($slot->id == $slotid) {
547
                return $slot;
548
            }
549
        }
550
 
551
        throw new coding_exception('The slot with id ' . $slotid .
552
                ' could not be found in the quiz with id ' . $this->get_quizid() . '.');
553
    }
554
 
555
    /**
556
     * Get a slot by its slot number. Throws an exception if it is missing.
557
     *
558
     * @param int $slotnumber The slot number
559
     * @return stdClass
560
     * @throws coding_exception
561
     */
562
    public function get_slot_by_number($slotnumber) {
563
        if (!array_key_exists($slotnumber, $this->slotsinorder)) {
564
            throw new coding_exception('The \'slotnumber\' could not be found.');
565
        }
566
        return $this->slotsinorder[$slotnumber];
567
    }
568
 
569
    /**
570
     * Check whether adding a section heading is possible
571
     *
572
     * @param int $pagenumber the number of the page.
573
     * @return boolean
574
     */
575
    public function can_add_section_heading($pagenumber) {
576
        // There is a default section heading on this page,
577
        // do not show adding new section heading in the Add menu.
578
        if ($pagenumber == 1) {
579
            return false;
580
        }
581
        // Get an array of firstslots.
582
        $firstslots = [];
583
        foreach ($this->sections as $section) {
584
            $firstslots[] = $section->firstslot;
585
        }
586
        foreach ($this->slotsinorder as $slot) {
587
            if ($slot->page == $pagenumber) {
588
                if (in_array($slot->slot, $firstslots)) {
589
                    return false;
590
                }
591
            }
592
        }
593
        // Do not show the adding section heading on the last add menu.
594
        if ($pagenumber == 0) {
595
            return false;
596
        }
597
        return true;
598
    }
599
 
600
    /**
601
     * Get all the slots in a section of the quiz.
602
     *
603
     * @param int $sectionid the section id.
604
     * @return int[] slot numbers.
605
     */
606
    public function get_slots_in_section($sectionid) {
607
        $slots = [];
608
        foreach ($this->slotsinorder as $slot) {
609
            if ($slot->section->id == $sectionid) {
610
                $slots[] = $slot->slot;
611
            }
612
        }
613
        return $slots;
614
    }
615
 
616
    /**
617
     * Get all the sections of the quiz.
618
     *
619
     * @return stdClass[] the sections in this quiz.
620
     */
621
    public function get_sections() {
622
        return $this->sections;
623
    }
624
 
625
    /**
626
     * Get a particular section by id.
627
     *
628
     * @return stdClass the section.
629
     */
630
    public function get_section_by_id($sectionid) {
631
        return $this->sections[$sectionid];
632
    }
633
 
634
    /**
635
     * Get the number of questions in the quiz.
636
     *
637
     * @return int the number of questions in the quiz.
638
     */
639
    public function get_section_count() {
640
        return count($this->sections);
641
    }
642
 
643
    /**
644
     * Get the overall quiz grade formatted for display.
645
     *
646
     * @return string the maximum grade for this quiz.
647
     */
648
    public function formatted_quiz_grade() {
649
        return quiz_format_grade($this->get_quiz(), $this->get_quiz()->grade);
650
    }
651
 
652
    /**
653
     * Get the maximum mark for a question, formatted for display.
654
     *
655
     * @param int $slotnumber the index of the slot in question.
656
     * @return string the maximum mark for the question in this slot.
657
     */
658
    public function formatted_question_grade($slotnumber) {
659
        return quiz_format_question_grade($this->get_quiz(), $this->slotsinorder[$slotnumber]->maxmark);
660
    }
661
 
662
    /**
663
     * Get the number of decimal places for displaying overall quiz grades or marks.
664
     *
665
     * @return int the number of decimal places.
666
     */
667
    public function get_decimal_places_for_grades() {
668
        return $this->get_quiz()->decimalpoints;
669
    }
670
 
671
    /**
672
     * Get the number of decimal places for displaying question marks.
673
     *
674
     * @return int the number of decimal places.
675
     */
676
    public function get_decimal_places_for_question_marks() {
677
        return quiz_get_grade_format($this->get_quiz());
678
    }
679
 
680
    /**
681
     * Get any warnings to show at the top of the edit page.
682
     * @return string[] array of strings.
683
     */
684
    public function get_edit_page_warnings() {
685
        $warnings = [];
686
 
687
        if (quiz_has_attempts($this->quizobj->get_quizid())) {
688
            $reviewlink = quiz_attempt_summary_link_to_reports($this->quizobj->get_quiz(),
689
                    $this->quizobj->get_cm(), $this->quizobj->get_context());
690
            $warnings[] = get_string('cannoteditafterattempts', 'quiz', $reviewlink);
691
        }
692
 
693
        return $warnings;
694
    }
695
 
696
    /**
697
     * Get the date information about the current state of the quiz.
698
     * @return string[] array of two strings. First a short summary, then a longer
699
     * explanation of the current state, e.g. for a tool-tip.
700
     */
701
    public function get_dates_summary() {
702
        $timenow = time();
703
        $quiz = $this->quizobj->get_quiz();
704
 
705
        // Exact open and close dates for the tool-tip.
706
        $dates = [];
707
        if ($quiz->timeopen > 0) {
708
            if ($timenow > $quiz->timeopen) {
709
                $dates[] = get_string('quizopenedon', 'quiz', userdate($quiz->timeopen));
710
            } else {
711
                $dates[] = get_string('quizwillopen', 'quiz', userdate($quiz->timeopen));
712
            }
713
        }
714
        if ($quiz->timeclose > 0) {
715
            if ($timenow > $quiz->timeclose) {
716
                $dates[] = get_string('quizclosed', 'quiz', userdate($quiz->timeclose));
717
            } else {
718
                $dates[] = get_string('quizcloseson', 'quiz', userdate($quiz->timeclose));
719
            }
720
        }
721
        if (empty($dates)) {
722
            $dates[] = get_string('alwaysavailable', 'quiz');
723
        }
724
        $explanation = implode(', ', $dates);
725
 
726
        // Brief summary on the page.
727
        if ($timenow < $quiz->timeopen) {
728
            $currentstatus = get_string('quizisclosedwillopen', 'quiz',
729
                    userdate($quiz->timeopen, get_string('strftimedatetimeshort', 'langconfig')));
730
        } else if ($quiz->timeclose && $timenow <= $quiz->timeclose) {
731
            $currentstatus = get_string('quizisopenwillclose', 'quiz',
732
                    userdate($quiz->timeclose, get_string('strftimedatetimeshort', 'langconfig')));
733
        } else if ($quiz->timeclose && $timenow > $quiz->timeclose) {
734
            $currentstatus = get_string('quizisclosed', 'quiz');
735
        } else {
736
            $currentstatus = get_string('quizisopen', 'quiz');
737
        }
738
 
739
        return [$currentstatus, $explanation];
740
    }
741
 
742
    /**
743
     * Set up this class with the structure for a given quiz.
744
     */
745
    protected function populate_structure() {
746
        global $DB;
747
 
748
        $this->populate_grade_items();
749
        $slots = qbank_helper::get_question_structure($this->quizobj->get_quizid(), $this->quizobj->get_context());
750
        $this->questions = [];
751
        $this->slotsinorder = [];
752
        foreach ($slots as $slotdata) {
753
            $this->questions[$slotdata->questionid] = $slotdata;
754
 
755
            $slot = clone($slotdata);
756
            $slot->quizid = $this->quizobj->get_quizid();
757
            $this->slotsinorder[$slot->slot] = $slot;
758
        }
759
 
760
        // Get quiz sections in ascending order of the firstslot.
761
        $this->sections = $DB->get_records('quiz_sections', ['quizid' => $this->quizobj->get_quizid()], 'firstslot');
762
        $this->populate_slots_with_sections();
763
        $this->populate_question_numbers();
764
    }
765
 
766
    /**
767
     * Load the information about the grade items for this quiz.
768
     */
769
    protected function populate_grade_items(): void {
770
        global $DB;
771
        $this->gradeitems = $DB->get_records('quiz_grade_items',
772
                ['quizid' => $this->get_quizid()], 'sortorder');
773
    }
774
 
775
    /**
776
     * Fill in the section ids for each slot.
777
     */
778
    public function populate_slots_with_sections() {
779
        $sections = array_values($this->sections);
780
        foreach ($sections as $i => $section) {
781
            if (isset($sections[$i + 1])) {
782
                $section->lastslot = $sections[$i + 1]->firstslot - 1;
783
            } else {
784
                $section->lastslot = count($this->slotsinorder);
785
            }
786
            for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) {
787
                $this->slotsinorder[$slot]->section = $section;
788
            }
789
        }
790
    }
791
 
792
    /**
793
     * Number the questions.
794
     */
795
    protected function populate_question_numbers() {
796
        $number = 1;
797
        foreach ($this->slotsinorder as $slot) {
798
            $question = $this->questions[$slot->questionid];
799
            if ($question->length == 0) {
800
                $slot->displaynumber = null;
801
                $slot->defaultnumber = get_string('infoshort', 'quiz');
802
            } else {
803
                $slot->defaultnumber = $number;
804
            }
805
            if ($slot->displaynumber === '') {
806
                $slot->displaynumber = null;
807
            }
808
            $number += $question->length;
809
        }
810
    }
811
 
812
    /**
813
     * Get the version options to show on the 'Questions' page for a particular question.
814
     *
815
     * @param int $slotnumber which slot to get the choices for.
816
     * @return stdClass[] other versions of this question. Each object has fields versionid,
817
     *       version and selected. Array is returned most recent version first.
818
     */
819
    public function get_version_choices_for_slot(int $slotnumber): array {
820
        $slot = $this->get_slot_by_number($slotnumber);
821
 
822
        // Get all the versions which exist.
1441 ariadna 823
        $versions = version_options::get_version_menu_options($slot->questionid);
824
        $versioninfo = [];
1 efrain 825
 
1441 ariadna 826
        // Loop through them and set which one is selected.
827
        foreach ($versions as $versionnumber => $version) {
828
            $versioninfo[] = (object)[
829
                'version' => $versionnumber,
830
                'versionvalue' => $version,
831
                'selected' => ($versionnumber == $slot->requestedversion),
832
            ];
1 efrain 833
        }
834
 
1441 ariadna 835
        return $versioninfo;
1 efrain 836
    }
837
 
838
    /**
839
     * Move a slot from its current location to a new location.
840
     *
841
     * After calling this method, this class will be in an invalid state, and
842
     * should be discarded if you want to manipulate the structure further.
843
     *
844
     * @param int $idmove id of slot to be moved
845
     * @param int $idmoveafter id of slot to come before slot being moved
846
     * @param int $page new page number of slot being moved
847
     */
848
    public function move_slot($idmove, $idmoveafter, $page) {
849
        global $DB;
850
 
851
        $this->check_can_be_edited();
852
 
853
        $movingslot = $this->get_slot_by_id($idmove);
854
        if (empty($movingslot)) {
855
            throw new \moodle_exception('Bad slot ID ' . $idmove);
856
        }
857
        $movingslotnumber = (int) $movingslot->slot;
858
 
859
        // Empty target slot means move slot to first.
860
        if (empty($idmoveafter)) {
861
            $moveafterslotnumber = 0;
862
        } else {
863
            $moveafterslotnumber = (int) $this->get_slot_by_id($idmoveafter)->slot;
864
        }
865
 
866
        // If the action came in as moving a slot to itself, normalise this to
867
        // moving the slot to after the previous slot.
868
        if ($moveafterslotnumber == $movingslotnumber) {
869
            $moveafterslotnumber = $moveafterslotnumber - 1;
870
        }
871
 
872
        $followingslotnumber = $moveafterslotnumber + 1;
873
        // Prevent checking against non-existence slot when already at the last slot.
874
        if ($followingslotnumber == $movingslotnumber && !$this->is_last_slot_in_quiz($followingslotnumber)) {
875
            $followingslotnumber += 1;
876
        }
877
 
878
        // Check the target page number is OK.
879
        if ($page == 0 || $page === '') {
880
            $page = 1;
881
        }
882
        if (($moveafterslotnumber > 0 && $page < $this->get_page_number_for_slot($moveafterslotnumber)) ||
883
                $page < 1) {
884
            throw new coding_exception('The target page number is too small.');
885
        } else if (!$this->is_last_slot_in_quiz($moveafterslotnumber) &&
886
                $page > $this->get_page_number_for_slot($followingslotnumber)) {
887
            throw new coding_exception('The target page number is too large.');
888
        }
889
 
890
        // Work out how things are being moved.
891
        $slotreorder = [];
892
        if ($moveafterslotnumber > $movingslotnumber) {
893
            // Moving down.
894
            $slotreorder[$movingslotnumber] = $moveafterslotnumber;
895
            for ($i = $movingslotnumber; $i < $moveafterslotnumber; $i++) {
896
                $slotreorder[$i + 1] = $i;
897
            }
898
 
899
            $headingmoveafter = $movingslotnumber;
900
            if ($this->is_last_slot_in_quiz($moveafterslotnumber) ||
901
                    $page == $this->get_page_number_for_slot($moveafterslotnumber + 1)) {
902
                // We are moving to the start of a section, so that heading needs
903
                // to be included in the ones that move up.
904
                $headingmovebefore = $moveafterslotnumber + 1;
905
            } else {
906
                $headingmovebefore = $moveafterslotnumber;
907
            }
908
            $headingmovedirection = -1;
909
 
910
        } else if ($moveafterslotnumber < $movingslotnumber - 1) {
911
            // Moving up.
912
            $slotreorder[$movingslotnumber] = $moveafterslotnumber + 1;
913
            for ($i = $moveafterslotnumber + 1; $i < $movingslotnumber; $i++) {
914
                $slotreorder[$i] = $i + 1;
915
            }
916
 
917
            if ($page == $this->get_page_number_for_slot($moveafterslotnumber + 1)) {
918
                // Moving to the start of a section, don't move that section.
919
                $headingmoveafter = $moveafterslotnumber + 1;
920
            } else {
921
                // Moving tot the end of the previous section, so move the heading down too.
922
                $headingmoveafter = $moveafterslotnumber;
923
            }
924
            $headingmovebefore = $movingslotnumber + 1;
925
            $headingmovedirection = 1;
926
        } else {
927
            // Staying in the same place, but possibly changing page/section.
928
            if ($page > $movingslot->page) {
929
                $headingmoveafter = $movingslotnumber;
930
                $headingmovebefore = $movingslotnumber + 2;
931
                $headingmovedirection = -1;
932
            } else if ($page < $movingslot->page) {
933
                $headingmoveafter = $movingslotnumber - 1;
934
                $headingmovebefore = $movingslotnumber + 1;
935
                $headingmovedirection = 1;
936
            } else {
937
                return; // Nothing to do.
938
            }
939
        }
940
 
941
        if ($this->is_only_slot_in_section($movingslotnumber)) {
942
            throw new coding_exception('You cannot remove the last slot in a section.');
943
        }
944
 
945
        $trans = $DB->start_delegated_transaction();
946
 
947
        // Slot has moved record new order.
948
        if ($slotreorder) {
949
            update_field_with_unique_index('quiz_slots', 'slot', $slotreorder,
950
                    ['quizid' => $this->get_quizid()]);
951
        }
952
 
953
        // Page has changed. Record it.
954
        if ($movingslot->page != $page) {
955
            $DB->set_field('quiz_slots', 'page', $page,
956
                    ['id' => $movingslot->id]);
957
        }
958
 
959
        // Update section fist slots.
960
        quiz_update_section_firstslots($this->get_quizid(), $headingmovedirection,
961
                $headingmoveafter, $headingmovebefore);
962
 
963
        // If any pages are now empty, remove them.
964
        $emptypages = $DB->get_fieldset_sql("
965
                SELECT DISTINCT page - 1
966
                  FROM {quiz_slots} slot
967
                 WHERE quizid = ?
968
                   AND page > 1
969
                   AND NOT EXISTS (SELECT 1 FROM {quiz_slots} WHERE quizid = ? AND page = slot.page - 1)
970
              ORDER BY page - 1 DESC
971
                ", [$this->get_quizid(), $this->get_quizid()]);
972
 
973
        foreach ($emptypages as $emptypage) {
974
            $DB->execute("
975
                    UPDATE {quiz_slots}
976
                       SET page = page - 1
977
                     WHERE quizid = ?
978
                       AND page > ?
979
                    ", [$this->get_quizid(), $emptypage]);
980
        }
981
 
982
        $trans->allow_commit();
983
 
984
        // Log slot moved event.
985
        $event = \mod_quiz\event\slot_moved::create([
986
            'context' => $this->quizobj->get_context(),
987
            'objectid' => $idmove,
988
            'other' => [
989
                'quizid' => $this->quizobj->get_quizid(),
990
                'previousslotnumber' => $movingslotnumber,
991
                'afterslotnumber' => $moveafterslotnumber,
992
                'page' => $page
993
             ]
994
        ]);
995
        $event->trigger();
996
    }
997
 
998
    /**
999
     * Refresh page numbering of quiz slots.
1000
     * @param stdClass[] $slots (optional) array of slot objects.
1001
     * @return stdClass[] array of slot objects.
1002
     */
1003
    public function refresh_page_numbers($slots = []) {
1004
        global $DB;
1005
        // Get slots ordered by page then slot.
1006
        if (!count($slots)) {
1007
            $slots = $DB->get_records('quiz_slots', ['quizid' => $this->get_quizid()], 'slot, page');
1008
        }
1009
 
1010
        // Loop slots. Start the page number at 1 and increment as required.
1011
        $pagenumbers = ['new' => 0, 'old' => 0];
1012
 
1013
        foreach ($slots as $slot) {
1014
            if ($slot->page !== $pagenumbers['old']) {
1015
                $pagenumbers['old'] = $slot->page;
1016
                ++$pagenumbers['new'];
1017
            }
1018
 
1019
            if ($pagenumbers['new'] == $slot->page) {
1020
                continue;
1021
            }
1022
            $slot->page = $pagenumbers['new'];
1023
        }
1024
 
1025
        return $slots;
1026
    }
1027
 
1028
    /**
1029
     * Refresh page numbering of quiz slots and save to the database.
1030
     *
1031
     * @return stdClass[] array of slot objects.
1032
     */
1033
    public function refresh_page_numbers_and_update_db() {
1034
        global $DB;
1035
        $this->check_can_be_edited();
1036
 
1037
        $slots = $this->refresh_page_numbers();
1038
 
1039
        // Record new page order.
1040
        foreach ($slots as $slot) {
1041
            $DB->set_field('quiz_slots', 'page', $slot->page,
1042
                    ['id' => $slot->id]);
1043
        }
1044
 
1045
        return $slots;
1046
    }
1047
 
1048
    /**
1049
     * Remove a slot from a quiz.
1050
     *
1051
     * @param int $slotnumber The number of the slot to be deleted.
1052
     * @throws coding_exception
1053
     */
1054
    public function remove_slot($slotnumber) {
1055
        global $DB;
1056
 
1057
        $this->check_can_be_edited();
1058
 
1059
        if ($this->is_only_slot_in_section($slotnumber) && $this->get_section_count() > 1) {
1060
            throw new coding_exception('You cannot remove the last slot in a section.');
1061
        }
1062
 
1063
        $slot = $DB->get_record('quiz_slots', ['quizid' => $this->get_quizid(), 'slot' => $slotnumber]);
1064
        if (!$slot) {
1065
            return;
1066
        }
1067
        $maxslot = $DB->get_field_sql('SELECT MAX(slot) FROM {quiz_slots} WHERE quizid = ?', [$this->get_quizid()]);
1068
 
1069
        $trans = $DB->start_delegated_transaction();
1070
        // Delete the reference if it is a question.
1071
        $questionreference = $DB->get_record('question_references',
1072
                ['component' => 'mod_quiz', 'questionarea' => 'slot', 'itemid' => $slot->id]);
1073
        if ($questionreference) {
1074
            $DB->delete_records('question_references', ['id' => $questionreference->id]);
1075
        }
1076
        // Delete the set reference if it is a random question.
1077
        $questionsetreference = $DB->get_record('question_set_references',
1078
                ['component' => 'mod_quiz', 'questionarea' => 'slot', 'itemid' => $slot->id]);
1079
        if ($questionsetreference) {
1080
            $DB->delete_records('question_set_references',
1081
                ['id' => $questionsetreference->id, 'component' => 'mod_quiz', 'questionarea' => 'slot']);
1082
        }
1083
        $DB->delete_records('quiz_slots', ['id' => $slot->id]);
1084
        for ($i = $slot->slot + 1; $i <= $maxslot; $i++) {
1085
            $DB->set_field('quiz_slots', 'slot', $i - 1,
1086
                    ['quizid' => $this->get_quizid(), 'slot' => $i]);
1087
            $this->slotsinorder[$i]->slot = $i - 1;
1088
            $this->slotsinorder[$i - 1] = $this->slotsinorder[$i];
1089
            unset($this->slotsinorder[$i]);
1090
        }
1091
 
1092
        quiz_update_section_firstslots($this->get_quizid(), -1, $slotnumber);
1093
        foreach ($this->sections as $key => $section) {
1094
            if ($section->firstslot > $slotnumber) {
1095
                $this->sections[$key]->firstslot--;
1096
            }
1097
        }
1098
        $this->populate_slots_with_sections();
1099
        $this->populate_question_numbers();
1100
        $this->unset_question($slot->id);
1101
 
1102
        $this->refresh_page_numbers_and_update_db();
1103
 
1104
        $trans->allow_commit();
1105
 
1106
        // Log slot deleted event.
1107
        $event = \mod_quiz\event\slot_deleted::create([
1108
            'context' => $this->quizobj->get_context(),
1109
            'objectid' => $slot->id,
1110
            'other' => [
1111
                'quizid' => $this->get_quizid(),
1112
                'slotnumber' => $slotnumber,
1113
            ]
1114
        ]);
1115
        $event->trigger();
1116
    }
1117
 
1118
    /**
1119
     * Unset the question object after deletion.
1120
     *
1121
     * @param int $slotid
1122
     */
1123
    public function unset_question($slotid) {
1124
        foreach ($this->questions as $key => $question) {
1125
            if ($question->slotid === $slotid) {
1126
                unset($this->questions[$key]);
1127
            }
1128
        }
1129
    }
1130
 
1131
    /**
1132
     * Change the max mark for a slot.
1133
     *
1134
     * Save changes to the question grades in the quiz_slots table and any
1135
     * corresponding question_attempts.
1136
     *
1137
     * It does not update 'sumgrades' in the quiz table.
1138
     *
1139
     * @param stdClass $slot row from the quiz_slots table.
1140
     * @param float $maxmark the new maxmark.
1141
     * @return bool true if the new grade is different from the old one.
1142
     */
1143
    public function update_slot_maxmark($slot, $maxmark) {
1144
        global $DB;
1145
 
1146
        if (abs($maxmark - $slot->maxmark) < 1e-7) {
1147
            // Grade has not changed. Nothing to do.
1148
            return false;
1149
        }
1150
 
1151
        $transaction = $DB->start_delegated_transaction();
1152
        $DB->set_field('quiz_slots', 'maxmark', $maxmark, ['id' => $slot->id]);
1153
        \question_engine::set_max_mark_in_attempts(new qubaids_for_quiz($slot->quizid),
1154
                $slot->slot, $maxmark);
1155
 
1156
        // Log slot mark updated event.
1157
        // We use $num + 0 as a trick to remove the useless 0 digits from decimals.
1158
        $event = slot_mark_updated::create([
1159
            'context' => $this->quizobj->get_context(),
1160
            'objectid' => $slot->id,
1161
            'other' => [
1162
                'quizid' => $this->get_quizid(),
1163
                'previousmaxmark' => $slot->maxmark + 0,
1164
                'newmaxmark' => $maxmark + 0
1165
            ]
1166
        ]);
1167
        $event->trigger();
1168
 
1169
        $this->slotsinorder[$slot->slot]->maxmark = $maxmark;
1170
 
1171
        $transaction->allow_commit();
1172
        return true;
1173
    }
1174
 
1175
    /**
1441 ariadna 1176
     * Update the question version for a given slot, if necessary.
1177
     *
1178
     * @param int $id ID of row from the quiz_slots table.
1179
     * @param int|null $newversion The new question version for the slot.
1180
     *                             A null value means 'Always latest'.
1181
     * @return bool True if the version was updated, false if no update was required.
1182
     * @throws coding_exception If the specified version does not exist.
1183
     */
1184
    public function update_slot_version(int $id, ?int $newversion): bool {
1185
        global $DB;
1186
 
1187
        $slot = $this->get_slot_by_id($id);
1188
        $context = $this->quizobj->get_context();
1189
        $refparams = ['usingcontextid' => $context->id, 'component' => 'mod_quiz', 'questionarea' => 'slot', 'itemid' => $slot->id];
1190
        $reference = $DB->get_record('question_references', $refparams, '*', MUST_EXIST);
1191
        $oldversion = is_null($reference->version) ? null : (int) $reference->version;
1192
        $reference->version = $newversion === 0 ? null : $newversion;
1193
        $existsparams = ['questionbankentryid' => $reference->questionbankentryid, 'version' => $newversion];
1194
        $versionexists = $DB->record_exists('question_versions', $existsparams);
1195
 
1196
        // We are attempting to switch to an existing version.
1197
        // Verify that the version we want to switch to exists.
1198
        if (!is_null($newversion) && !$versionexists) {
1199
            throw new coding_exception(
1200
                'Version: ' . $newversion . ' ' .
1201
                'does not exist for question bank entry: ' . $reference->questionbankentryid
1202
            );
1203
        }
1204
 
1205
        if ($newversion === $oldversion) {
1206
            return false;
1207
        }
1208
 
1209
        $transaction = $DB->start_delegated_transaction();
1210
        $DB->update_record('question_references', $reference);
1211
        slot_version_updated::create([
1212
            'context' => $this->quizobj->get_context(),
1213
            'objectid' => $slot->id,
1214
            'other' => [
1215
                'quizid' => $this->get_quizid(),
1216
                'previousversion' => $oldversion,
1217
                'newversion' => $reference->version,
1218
            ],
1219
        ])->trigger();
1220
        $transaction->allow_commit();
1221
 
1222
        return true;
1223
    }
1224
 
1225
    /**
1 efrain 1226
     * Change which grade this slot contributes to, for quizzes with multiple grades.
1227
     *
1228
     * It does not update 'sumgrades' in the quiz table. If this method returns true,
1229
     * it will be necessary to recompute all the quiz grades.
1230
     *
1231
     * @param stdClass $slot row from the quiz_slots table.
1232
     * @param int|null $gradeitemid id of the grade item this slot should contribute to. 0 or null means none.
1233
     * @return bool true if the new $gradeitemid is different from the previous one.
1234
     */
1235
    public function update_slot_grade_item(stdClass $slot, ?int $gradeitemid): bool {
1236
        global $DB;
1237
 
1238
        if ($gradeitemid === 0) {
1239
            $gradeitemid = null;
1240
        }
1241
 
11 efrain 1242
        if ($gradeitemid !== null && !$this->is_real_question($slot->slot)) {
1243
            throw new coding_exception('Cannot set a grade item for a question that is ungraded.');
1244
        }
1245
 
1 efrain 1246
        if ($slot->quizgradeitemid !== null) {
1247
            // Object $slot likely comes from the database, which means int may be
1248
            // represented as a string, which breaks the next test, so fix up.
1249
            $slot->quizgradeitemid = (int) $slot->quizgradeitemid;
1250
        }
1251
 
1252
        if ($gradeitemid === $slot->quizgradeitemid) {
1253
            // Grade has not changed. Nothing to do.
1254
            return false;
1255
        }
1256
 
1257
        $transaction = $DB->start_delegated_transaction();
1258
        $DB->set_field('quiz_slots', 'quizgradeitemid', $gradeitemid, ['id' => $slot->id]);
1259
 
1260
        // Log slot mark updated event.
1261
        slot_grade_item_updated::create([
1262
            'context' => $this->quizobj->get_context(),
1263
            'objectid' => $slot->id,
1264
            'other' => [
1265
                'quizid' => $this->get_quizid(),
1266
                'previousgradeitem' => $slot->quizgradeitemid,
1267
                'newgradeitem' => $gradeitemid,
1268
            ],
1269
        ])->trigger();
1270
 
1271
        $this->slotsinorder[$slot->slot]->quizgradeitemid = $gradeitemid;
1272
 
1273
        $transaction->allow_commit();
1274
        return true;
1275
    }
1276
 
1277
    /**
1278
     * Set whether the question in a particular slot requires the previous one.
1279
     * @param int $slotid id of slot.
1280
     * @param bool $requireprevious if true, set this question to require the previous one.
1281
     */
1282
    public function update_question_dependency($slotid, $requireprevious) {
1283
        global $DB;
1284
        $DB->set_field('quiz_slots', 'requireprevious', $requireprevious, ['id' => $slotid]);
1285
 
1286
        // Log slot require previous event.
1287
        $event = \mod_quiz\event\slot_requireprevious_updated::create([
1288
            'context' => $this->quizobj->get_context(),
1289
            'objectid' => $slotid,
1290
            'other' => [
1291
                'quizid' => $this->get_quizid(),
1292
                'requireprevious' => $requireprevious ? 1 : 0
1293
            ]
1294
        ]);
1295
        $event->trigger();
1296
    }
1297
 
1298
    /**
1299
     * Update the question display number when is set as customised display number or empy string.
1300
     * When the field displaynumber is set to empty string, the automated numbering is used.
1301
     * Log the updated displatnumber field.
1302
     *
1303
     * @param int $slotid id of slot.
1304
     * @param string $displaynumber set to customised string as question number or empty string fo autonumbering.
1305
     */
1306
    public function update_slot_display_number(int $slotid, string $displaynumber): void {
1307
        global $DB;
1308
 
1309
        $DB->set_field('quiz_slots', 'displaynumber', $displaynumber, ['id' => $slotid]);
1310
        $this->populate_structure();
1311
 
1312
        // Log slot displaynumber event (customised question number).
1313
        $event = \mod_quiz\event\slot_displaynumber_updated::create([
1314
                'context' => $this->quizobj->get_context(),
1315
                'objectid' => $slotid,
1316
                'other' => [
1317
                        'quizid' => $this->get_quizid(),
1318
                        'displaynumber' => $displaynumber
1319
                ]
1320
        ]);
1321
        $event->trigger();
1322
    }
1323
 
1324
    /**
1325
     * Add/Remove a pagebreak.
1326
     *
1327
     * Save changes to the slot page relationship in the quiz_slots table and reorders the paging
1328
     * for subsequent slots.
1329
     *
1330
     * @param int $slotid id of slot which we will add/remove the page break before.
1331
     * @param int $type repaginate::LINK or repaginate::UNLINK.
1332
     * @return stdClass[] array of slot objects.
1333
     */
1334
    public function update_page_break($slotid, $type) {
1335
        global $DB;
1336
 
1337
        $this->check_can_be_edited();
1338
 
1339
        $quizslots = $DB->get_records('quiz_slots', ['quizid' => $this->get_quizid()], 'slot');
1340
        $repaginate = new repaginate($this->get_quizid(), $quizslots);
1341
        $repaginate->repaginate_slots($quizslots[$slotid]->slot, $type);
1342
        $slots = $this->refresh_page_numbers_and_update_db();
1343
 
1344
        if ($type == repaginate::LINK) {
1345
            // Log page break created event.
1346
            $event = \mod_quiz\event\page_break_deleted::create([
1347
                'context' => $this->quizobj->get_context(),
1348
                'objectid' => $slotid,
1349
                'other' => [
1350
                    'quizid' => $this->get_quizid(),
1351
                    'slotnumber' => $quizslots[$slotid]->slot
1352
                ]
1353
            ]);
1354
            $event->trigger();
1355
        } else {
1356
            // Log page deleted created event.
1357
            $event = \mod_quiz\event\page_break_created::create([
1358
                'context' => $this->quizobj->get_context(),
1359
                'objectid' => $slotid,
1360
                'other' => [
1361
                    'quizid' => $this->get_quizid(),
1362
                    'slotnumber' => $quizslots[$slotid]->slot
1363
                ]
1364
            ]);
1365
            $event->trigger();
1366
        }
1367
 
1368
        return $slots;
1369
    }
1370
 
1371
    /**
1372
     * Add a section heading on a given page and return the sectionid
1373
     * @param int $pagenumber the number of the page where the section heading begins.
1374
     * @param string|null $heading the heading to add. If not given, a default is used.
1375
     */
1376
    public function add_section_heading($pagenumber, $heading = null) {
1377
        global $DB;
1378
        $section = new stdClass();
1379
        if ($heading !== null) {
1380
            $section->heading = $heading;
1381
        } else {
1382
            $section->heading = get_string('newsectionheading', 'quiz');
1383
        }
1384
        $section->quizid = $this->get_quizid();
1385
        $slotsonpage = $DB->get_records('quiz_slots', ['quizid' => $this->get_quizid(), 'page' => $pagenumber], 'slot DESC');
1386
        $firstslot = end($slotsonpage);
1387
        $section->firstslot = $firstslot->slot;
1388
        $section->shufflequestions = 0;
1389
        $sectionid = $DB->insert_record('quiz_sections', $section);
1390
 
1391
        // Log section break created event.
1392
        $event = \mod_quiz\event\section_break_created::create([
1393
            'context' => $this->quizobj->get_context(),
1394
            'objectid' => $sectionid,
1395
            'other' => [
1396
                'quizid' => $this->get_quizid(),
1397
                'firstslotnumber' => $firstslot->slot,
1398
                'firstslotid' => $firstslot->id,
1399
                'title' => $section->heading,
1400
            ]
1401
        ]);
1402
        $event->trigger();
1403
 
1404
        return $sectionid;
1405
    }
1406
 
1407
    /**
1408
     * Change the heading for a section.
1409
     * @param int $id the id of the section to change.
1410
     * @param string $newheading the new heading for this section.
1411
     */
1412
    public function set_section_heading($id, $newheading) {
1413
        global $DB;
1414
        $section = $DB->get_record('quiz_sections', ['id' => $id], '*', MUST_EXIST);
1415
        $section->heading = $newheading;
1416
        $DB->update_record('quiz_sections', $section);
1417
 
1418
        // Log section title updated event.
1419
        $firstslot = $DB->get_record('quiz_slots', ['quizid' => $this->get_quizid(), 'slot' => $section->firstslot]);
1420
        $event = \mod_quiz\event\section_title_updated::create([
1421
            'context' => $this->quizobj->get_context(),
1422
            'objectid' => $id,
1423
            'other' => [
1424
                'quizid' => $this->get_quizid(),
1425
                'firstslotid' => $firstslot ? $firstslot->id : null,
1426
                'firstslotnumber' => $firstslot ? $firstslot->slot : null,
1427
                'newtitle' => $newheading
1428
            ]
1429
        ]);
1430
        $event->trigger();
1431
    }
1432
 
1433
    /**
1434
     * Change the shuffle setting for a section.
1435
     * @param int $id the id of the section to change.
1436
     * @param bool $shuffle whether this section should be shuffled.
1437
     */
1438
    public function set_section_shuffle($id, $shuffle) {
1439
        global $DB;
1440
        $section = $DB->get_record('quiz_sections', ['id' => $id], '*', MUST_EXIST);
1441
        $section->shufflequestions = $shuffle;
1442
        $DB->update_record('quiz_sections', $section);
1443
 
1444
        // Log section shuffle updated event.
1445
        $event = \mod_quiz\event\section_shuffle_updated::create([
1446
            'context' => $this->quizobj->get_context(),
1447
            'objectid' => $id,
1448
            'other' => [
1449
                'quizid' => $this->get_quizid(),
1450
                'firstslotnumber' => $section->firstslot,
1451
                'shuffle' => $shuffle
1452
            ]
1453
        ]);
1454
        $event->trigger();
1455
    }
1456
 
1457
    /**
1458
     * Remove the section heading with the given id
1459
     * @param int $sectionid the section to remove.
1460
     */
1461
    public function remove_section_heading($sectionid) {
1462
        global $DB;
1463
        $section = $DB->get_record('quiz_sections', ['id' => $sectionid], '*', MUST_EXIST);
1464
        if ($section->firstslot == 1) {
1465
            throw new coding_exception('Cannot remove the first section in a quiz.');
1466
        }
1467
        $DB->delete_records('quiz_sections', ['id' => $sectionid]);
1468
 
1469
        // Log page deleted created event.
1470
        $firstslot = $DB->get_record('quiz_slots', ['quizid' => $this->get_quizid(), 'slot' => $section->firstslot]);
1471
        $event = \mod_quiz\event\section_break_deleted::create([
1472
            'context' => $this->quizobj->get_context(),
1473
            'objectid' => $sectionid,
1474
            'other' => [
1475
                'quizid' => $this->get_quizid(),
1476
                'firstslotid' => $firstslot->id,
1477
                'firstslotnumber' => $firstslot->slot
1478
            ]
1479
        ]);
1480
        $event->trigger();
1481
    }
1482
 
1483
    /**
1484
     * Whether the current user can add random questions to the quiz or not.
1485
     * It is only possible to add a random question if the user has the moodle/question:useall capability
1486
     * on at least one of the contexts related to the one where we are currently editing questions.
1487
     *
1488
     * @return bool
1489
     */
1490
    public function can_add_random_questions() {
1491
        if ($this->canaddrandom === null) {
1492
            $quizcontext = $this->quizobj->get_context();
1493
            $relatedcontexts = new \core_question\local\bank\question_edit_contexts($quizcontext);
1494
            $usablecontexts = $relatedcontexts->having_cap('moodle/question:useall');
1495
 
1496
            $this->canaddrandom = !empty($usablecontexts);
1497
        }
1498
 
1499
        return $this->canaddrandom;
1500
    }
1501
 
1502
    /**
1503
     * Get the grade items defined for this quiz.
1504
     *
1505
     * @return stdClass[] quiz_grade_item rows, indexed by id.
1506
     */
1507
    public function get_grade_items(): array {
1508
        return $this->gradeitems;
1509
    }
1510
 
1511
    /**
1512
     * Check the grade item with the given id belongs to this quiz.
1513
     *
1514
     * @param int $gradeitemid id of a quiz grade item.
1515
     * @throws coding_exception if the grade item does not belong to this quiz.
1516
     */
1517
    public function verify_grade_item_is_ours(int $gradeitemid): void {
1518
        if (!array_key_exists($gradeitemid, $this->gradeitems)) {
1519
            throw new coding_exception('Grade item ' . $gradeitemid .
1520
                    ' does not belong to quiz ' . $this->get_quizid());
1521
        }
1522
    }
1523
 
1524
    /**
1525
     * Is a particular quiz grade item used by any slots?
1526
     *
1527
     * @param int $gradeitemid id of a quiz grade item belonging to this quiz.
1528
     * @return bool true if it is used.
1529
     */
1530
    public function is_grade_item_used(int $gradeitemid): bool {
1531
        $this->verify_grade_item_is_ours($gradeitemid);
1532
 
1533
        foreach ($this->slotsinorder as $slot) {
1534
            if ($slot->quizgradeitemid == $gradeitemid) {
1535
                return true;
1536
            }
1537
        }
1538
        return false;
1539
    }
1540
 
1541
    /**
1542
     * Get the total of marks of all questions assigned to this grade item, formatted for display.
1543
     *
1544
     * @param int $gradeitemid id of a quiz grade item belonging to this quiz.
1545
     * @return string total of marks of all questions assigned to this grade item.
1546
     */
1547
    public function formatted_grade_item_sum_marks(int $gradeitemid): string {
1548
        $this->verify_grade_item_is_ours($gradeitemid);
1549
 
1550
        $summarks = 0;
1551
        foreach ($this->slotsinorder as $slot) {
1552
            if ($slot->quizgradeitemid == $gradeitemid) {
1553
                $summarks += $slot->maxmark;
1554
            }
1555
        }
1556
 
1557
        return quiz_format_grade($this->get_quiz(), $summarks);
1558
    }
1559
 
1560
    /**
1561
     * Create a grade item.
1562
     *
1563
     * The new grade item is added at the end of the order.
1564
     *
1565
     * @param stdClass $gradeitemdata must have property name - updated with the inserted data (sortorder and id).
1566
     */
1567
    public function create_grade_item(stdClass $gradeitemdata): void {
1568
        global $DB;
1569
 
1570
        // Add to the end of the sort order.
1571
        $gradeitemdata->sortorder = $DB->get_field('quiz_grade_items',
1572
                'COALESCE(MAX(sortorder) + 1, 1)',
1573
                ['quizid' => $this->get_quizid()]);
1574
 
1575
        // If name is blank, supply a default.
1576
        if ((string) $gradeitemdata->name === '') {
1577
            $count = 0;
1578
            do {
1579
                $count += 1;
1580
                $gradeitemdata->name = get_string('gradeitemdefaultname', 'quiz', $count);
1581
            } while ($DB->record_exists('quiz_grade_items',
1582
                ['quizid' => $this->get_quizid(), 'name' => $gradeitemdata->name]));
1583
        }
1584
 
1585
        $transaction = $DB->start_delegated_transaction();
1586
 
1587
        // Create the grade item.
1588
        $gradeitemdata->id = $DB->insert_record('quiz_grade_items', $gradeitemdata);
1589
        $this->gradeitems[$gradeitemdata->id] = $DB->get_record(
1590
                'quiz_grade_items', ['id' => $gradeitemdata->id]);
1591
 
1592
        // Log.
1593
        quiz_grade_item_created::create([
1594
            'context' => $this->quizobj->get_context(),
1595
            'objectid' => $gradeitemdata->id,
1596
            'other' => [
1597
                'quizid' => $this->get_quizid(),
1598
            ],
1599
        ])->trigger();
1600
 
1601
        $transaction->allow_commit();
1602
    }
1603
 
1604
    /**
1605
     * Update a grade item.
1606
     *
1607
     * @param stdClass $gradeitemdata must have properties id and name.
1608
     */
1609
    public function update_grade_item(stdClass $gradeitemdata): void {
1610
        global $DB;
1611
 
1612
        $this->verify_grade_item_is_ours($gradeitemdata->id);
1613
 
1614
        $transaction = $DB->start_delegated_transaction();
1615
 
1616
        // Update the grade item.
1617
        $DB->update_record('quiz_grade_items', $gradeitemdata);
1618
        $this->gradeitems[$gradeitemdata->id] = $DB->get_record(
1619
                'quiz_grade_items', ['id' => $gradeitemdata->id]);
1620
 
1621
        // Log.
1622
        quiz_grade_item_updated::create([
1623
            'context' => $this->quizobj->get_context(),
1624
            'objectid' => $gradeitemdata->id,
1625
            'other' => [
1626
                'quizid' => $this->get_quizid(),
1627
            ],
1628
        ])->trigger();
1629
 
1630
        $transaction->allow_commit();
1631
    }
1632
 
1633
    /**
1634
     * Delete a grade item (only if it is not used).
1635
     *
1636
     * @param int $gradeitemid id of the grade item to delete. Must belong to this quiz.
1637
     */
1638
    public function delete_grade_item(int $gradeitemid): void {
1639
        global $DB;
1640
 
1641
        if ($this->is_grade_item_used($gradeitemid)) {
1642
            throw new coding_exception('Cannot delete a quiz grade item which is used.');
1643
        }
1644
 
1645
        $transaction = $DB->start_delegated_transaction();
1646
 
1647
        $DB->delete_records('quiz_grade_items', ['id' => $gradeitemid]);
1648
        unset($this->gradeitems[$gradeitemid]);
1649
 
1650
        // Log.
1651
        quiz_grade_item_deleted::create([
1652
            'context' => $this->quizobj->get_context(),
1653
            'objectid' => $gradeitemid,
1654
            'other' => [
1655
                'quizid' => $this->get_quizid(),
1656
            ],
1657
        ])->trigger();
1658
 
1659
        $transaction->allow_commit();
1660
    }
1661
 
1662
    /**
1663
     * @deprecated since Moodle 4.0 MDL-71573
1664
     */
1665
    public function get_slot_tags_for_slot_id() {
1666
        throw new \coding_exception(__FUNCTION__ . '() has been removed.');
1667
    }
1668
 
1669
    /**
1670
     * Add a random question to the quiz at a given point.
1671
     *
1672
     * @param int $addonpage the page on which to add the question.
1673
     * @param int $number the number of random questions to add.
1674
     * @param array $filtercondition the filter condition. Must contain at least a category filter.
1675
     */
1676
    public function add_random_questions(int $addonpage, int $number, array $filtercondition): void {
1677
        global $DB;
1678
 
1679
        if (!isset($filtercondition['filter']['category'])) {
1680
            throw new \invalid_parameter_exception('$filtercondition must contain at least a category filter.');
1681
        }
1682
        $categoryid = $filtercondition['filter']['category']['values'][0];
1683
 
1684
        $category = $DB->get_record('question_categories', ['id' => $categoryid]);
1685
        if (!$category) {
1686
            new \moodle_exception('invalidcategoryid');
1687
        }
1688
 
1689
        $catcontext = \context::instance_by_id($category->contextid);
1690
        require_capability('moodle/question:useall', $catcontext);
1691
 
1692
        // Create the selected number of random questions.
1693
        for ($i = 0; $i < $number; $i++) {
1694
            // Slot data.
1695
            $randomslotdata = new stdClass();
1696
            $randomslotdata->quizid = $this->get_quizid();
1697
            $randomslotdata->usingcontextid = context_module::instance($this->get_cmid())->id;
1698
            $randomslotdata->questionscontextid = $category->contextid;
1699
            $randomslotdata->maxmark = 1;
1700
 
1701
            $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata);
1702
            $randomslot->set_quiz($this->get_quiz());
1703
            $randomslot->set_filter_condition(json_encode($filtercondition));
1704
            $randomslot->insert($addonpage);
1705
        }
1706
    }
1441 ariadna 1707
 
1708
    /**
1709
     * Get a human-readable description of a random slot.
1710
     *
1711
     * @param int $slotid id of slot.
1712
     * @return string that can be used to display the random slot.
1713
     */
1714
    public function describe_random_slot(int $slotid): string {
1715
        $this->ensure_random_slot_info_loaded();
1716
 
1717
        if (!isset($this->randomslotcategories[$slotid])) {
1718
            throw new coding_exception('Called describe_random_slot on slot id ' .
1719
                $slotid . ' which is not a random slot.');
1720
        }
1721
 
1722
        // Build the random question name with categories and tags information and return.
1723
        $a = new stdClass();
1724
        $a->category = $this->randomslotcategories[$slotid];
1725
        $stringid = 'randomqnamecat';
1726
 
1727
        if (!empty($this->randomslottags[$slotid])) {
1728
            $a->tags = $this->randomslottags[$slotid];
1729
            $stringid = 'randomqnamecattags';
1730
        }
1731
 
1732
        return shorten_text(get_string($stringid, 'quiz', $a), 255);
1733
    }
1734
 
1735
    /**
1736
     * Ensure that {@see load_random_slot_info()} has been called, so the data is available.
1737
     */
1738
    protected function ensure_random_slot_info_loaded(): void {
1739
        if ($this->randomslotcategories == null) {
1740
            $this->load_random_slot_info();
1741
        }
1742
    }
1743
 
1744
    /**
1745
     * Load information about the question categories and tags for all random slots,
1746
     */
1747
    protected function load_random_slot_info(): void {
1748
        global $DB;
1749
 
1750
        // Find the random slots.
1751
        $allslots = $this->get_slots();
1752
        foreach ($allslots as $key => $slot) {
1753
            if ($slot->qtype != 'random') {
1754
                unset($allslots[$key]);
1755
            }
1756
        }
1757
        if (empty($allslots)) {
1758
            // No random slots. Nothing to do.
1759
            $this->randomslotcategories = [];
1760
            $this->randomslottags = [];
1761
            return;
1762
        }
1763
 
1764
        // Loop over all random slots to build arrays of the data we will need.
1765
        $tagids = [];
1766
        $questioncategoriesids = [];
1767
        // An associative array of slotid. Example structure:
1768
        // ['cat' => [values => catid, 'includesubcategories' => true, 'tag' => [tagid, tagid, ...]].
1769
        $randomcategoriesandtags = [];
1770
        foreach ($allslots as $slotid => $slot) {
1771
            foreach ($slot->filtercondition as $name => $value) {
1772
                if ($name !== 'filter') {
1773
                    continue;
1774
                }
1775
 
1776
                // Parse the filter condition.
1777
                foreach ($value as $filteroption => $filtervalue) {
1778
                    if ($filteroption === 'category') {
1779
                        $randomcategoriesandtags[$slotid]['cat']['values'] = $questioncategoriesids[] = $filtervalue['values'][0];
1780
                        $randomcategoriesandtags[$slotid]['cat']['includesubcategories'] =
1781
                            $filtervalue['filteroptions']['includesubcategories'] ?? false;
1782
                    }
1783
 
1784
                    if ($filteroption === 'qtagids') {
1785
                        foreach ($filtervalue as $qtagidsoption => $qtagidsvalue) {
1786
                            if ($qtagidsoption !== 'values') {
1787
                                continue;
1788
                            }
1789
                            foreach ($qtagidsvalue as $qtagidsvaluevalue) {
1790
                                $randomcategoriesandtags[$slotid]['tag'][] = $qtagidsvaluevalue;
1791
                                $tagids[] = $qtagidsvaluevalue;
1792
                            }
1793
                        }
1794
                    }
1795
                }
1796
            }
1797
        }
1798
 
1799
        // Get names for all tags into a tagid => name array.
1800
        $tags = \core_tag_tag::get_bulk($tagids, 'id, rawname');
1801
        $tagnames = array_map(fn($tag) => $tag->get_display_name(), $tags);
1802
 
1803
        // Get names for all question categories.
1804
        $categories = $DB->get_records_list('question_categories', 'id', $questioncategoriesids,
1805
            'id', 'id, name, contextid, parent');
1806
 
1807
        // Now, put the data required for each slot into $this->randomslotcategories and $this->randomslottags.
1808
        foreach ($randomcategoriesandtags as $slotid => $catandtags) {
1809
            $qcategoryid = $catandtags['cat']['values'];
1810
            if (!array_key_exists($qcategoryid, $categories)) {
1811
                $this->randomslotcategories[$slotid] = self::MISSING_QUESTION_CATEGORY_PLACEHOLDER;
1812
                continue;
1813
            }
1814
            $qcategory = $categories[$qcategoryid];
1815
            $includesubcategories = $catandtags['cat']['includesubcategories'];
1816
            $this->randomslotcategories[$slotid] = $this->get_used_category_description($qcategory, $includesubcategories);
1817
            if (isset($catandtags['tag'])) {
1818
                $slottagnames = [];
1819
                foreach ($catandtags['tag'] as $tagid) {
1820
                    $slottagnames[] = $tagnames[$tagid];
1821
                }
1822
                $this->randomslottags[$slotid] = implode(', ', $slottagnames);
1823
            }
1824
 
1825
        }
1826
    }
1827
 
1828
    /**
1829
     * Returns a description of the used question category, taking into account the context and whether subcategories are
1830
     * included.
1831
     *
1832
     * @param stdClass $qcategory The question category object containing category details.
1833
     * @param bool $includesubcategories Whether subcategories are included.
1834
     * @return string The generated description based on the used category.
1835
     * @throws coding_exception If the context level is unsupported.
1836
     */
1837
    private function get_used_category_description(stdClass $qcategory, bool $includesubcategories): string {
1838
 
1839
        $context = \context::instance_by_id($qcategory->contextid);
1840
 
1841
        if ($context->contextlevel != CONTEXT_MODULE) {
1842
            throw new coding_exception('Unsupported context.');
1843
        }
1844
 
1845
        if ($qcategory->name === 'top') { // This is a "top" question category.
1846
            if (!$includesubcategories) {
1847
                // Question categories labeled as "top" cannot directly contain questions. If the subcategories that may
1848
                // hold questions are excluded, the generated random questions will be invalid. Thus, return a description
1849
                // that informs the user about the issues associated with these types of generated random questions.
1850
                return get_string('randomfaultynosubcat', 'mod_quiz');
1851
            }
1852
            return get_string('randommodulewithsubcat', 'mod_quiz');
1853
        }
1854
        // Otherwise, return the description of the used standard question category, also indicating whether subcategories
1855
        // are included.
1856
        return $includesubcategories ? get_string('randomcatwithsubcat', 'mod_quiz', $qcategory->name) :
1857
            $qcategory->name;
1858
    }
1859
 
1860
    /**
1861
     * Populate question_sources with cm records for later reference.
1862
     *
1863
     * @return void
1864
     */
1865
    private function populate_question_sources(): void {
1866
        global $DB;
1867
 
1868
        $contextids = array_map(fn($question) => $question->contextid, $this->questions);
1869
        [$insql, $inparams] = $DB->get_in_or_equal(array_unique($contextids));
1870
 
1871
        $sql = "
1872
            SELECT c.id as contextid, cm.id, cm.course
1873
              FROM {context} c
1874
              JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = ?
1875
             WHERE c.id {$insql}
1876
        ";
1877
        $params = array_merge([context_module::LEVEL], $inparams);
1878
 
1879
        $this->questionsources = $DB->get_records_sql($sql, $params);
1880
    }
1881
 
1882
    /**
1883
     * Get data on the question bank being used by the question in the slot.
1884
     *
1885
     * @param int $slot slot number
1886
     * @return stdClass|null
1887
     */
1888
    public function get_source_bank(int $slot): ?stdClass {
1889
        $questionid = $this->slotsinorder[$slot]->questionid;
1890
 
1891
            $this->questionsources[$this->questions[$questionid]->contextid] ?? $this->populate_question_sources();
1892
 
1893
        // This shouldn't happen as all categories belong to a module context level but let's account for it.
1894
        if (empty($this->questionsources[$this->questions[$questionid]->contextid])) {
1895
            return null;
1896
        }
1897
 
1898
        $cminfo = \cm_info::create($this->questionsources[$this->questions[$questionid]->contextid]);
1899
 
1900
        return (object) [
1901
            'cminfo' => $cminfo,
1902
            'issharedbank' => plugin_supports('mod', $cminfo->modname, FEATURE_PUBLISHES_QUESTIONS, false),
1903
        ];
1904
    }
1 efrain 1905
}