Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1441 ariadna 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_qbank\task;
18
 
19
use context;
20
use context_course;
21
use context_coursecat;
22
use context_module;
23
use context_system;
24
use core\task\manager;
25
use core_question\local\bank\random_question_loader;
26
use core_question\local\bank\question_bank_helper;
27
use mod_quiz\quiz_settings;
28
use stdClass;
29
use core_question\local\bank\question_version_status;
30
 
31
/**
32
 * Before testing, we firstly need to create some data to emulate what sites can have pre-upgrade.
33
 * Namely, we are adding question categories and questions to deprecated contexts i.e. anything not CONTEXT_MODULE,
34
 * and to quiz local banks too as we need to test these don't get touched.
35
 * It also adds questions to some categories that are not used by quizzes anywhere.
36
 *
37
 * The tests cover a few areas.
38
 * 1: We validate the data setup is correct before we run the installation script testing.
39
 * 2: The installation test validates that any question categories not in CONTEXT_MODULE get transferred to relevant mod_qbank
40
 * instances including their questions. It also validates that any stale questions that are not in use by quizzes are removed
41
 * along with empty categories.
42
 *
43
 * @package    mod_qbank
44
 * @copyright  2024 onwards Catalyst IT EU {@link https://catalyst-eu.net}
45
 * @author     Simon Adams <simon.adams@catalyst-eu.net>
46
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
47
 * @covers \mod_qbank\task\transfer_question_categories
48
 */
49
final class transfer_question_categories_test extends \advanced_testcase {
50
 
51
    /** @var \core\context\coursecat test course category context */
52
    private \core\context\coursecat $coursecatcontext;
53
 
54
    /** @var \core\context\course  test course context */
55
    private \core\context\course $coursecontext;
56
 
57
    /** @var \core\context\course  test stale course context*/
58
    private \core\context\course $stalecoursecontext;
59
 
60
    /** @var \core\context\module  test quiz mod context */
61
    private \core\context\module $quizcontext;
62
 
63
    /** @var \core\context\course Course context with used and unused questions. */
64
    private \core\context\course $usedunusedcontext;
65
 
66
    /** @var stdClass[] test stale questions */
67
    private array $stalequestions;
68
 
69
    /**
70
     * Get question data from question category ids provided in the argument.
71
     *
72
     * @param array $categoryids
73
     * @return array
74
     */
75
    protected function get_question_data(array $categoryids): array {
76
        global $DB;
77
 
78
        [$insql, $inparams] = $DB->get_in_or_equal($categoryids);
79
 
80
        $sql = "SELECT q.id, qbe.questioncategoryid AS categoryid, qv.status
81
                  FROM {question} q
82
                  JOIN {question_versions} qv ON qv.questionid = q.id
83
                  JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
84
                 WHERE qbe.questioncategoryid {$insql}";
85
 
86
        return $DB->get_records_sql($sql, $inparams);
87
    }
88
 
89
    /**
90
     * This is hacky, but we can't use the API to create these as non module contexts are deprecated for holding question
91
     * categories.
92
     *
93
     * @param string $name of the new category
94
     * @param int $contextid of the module holding the category
95
     * @param int $parentid of the new category
96
     * @return stdClass category object
97
     */
98
    protected function create_question_category(string $name, int $contextid, int $parentid = 0): stdClass {
99
 
100
        global $DB;
101
 
102
        if (!$parentid) {
103
            if (!$parent = $DB->get_record('question_categories', ['contextid' => $contextid, 'parent' => 0, 'name' => 'top'])) {
104
                $parent = new stdClass();
105
                $parent->name = 'top';
106
                $parent->info = '';
107
                $parent->contextid = $contextid;
108
                $parent->parent = 0;
109
                $parent->sortorder = 0;
110
                $parent->stamp = make_unique_id_code();
111
                $parent->id = $DB->insert_record('question_categories', $parent);
112
            }
113
            $parentid = $parent->id;
114
        }
115
 
116
        $record = (object) [
117
            'name' => $name,
118
            'parent' => $parentid,
119
            'contextid' => $contextid,
120
            'info' => '',
121
            'infoformat' => FORMAT_HTML,
122
            'stamp' => make_unique_id_code(),
123
            'sortorder' => 999,
124
            'idnumber' => null,
125
        ];
126
 
127
        $record->id = $DB->insert_record('question_categories', $record);
128
        return $record;
129
    }
130
 
131
    /**
132
     * Sets up the installation test with data.
133
     *
134
     * @return void
135
     */
136
    protected function setup_pre_install_data(): void {
137
        global $DB;
138
        self::setAdminUser();
139
        $questiongenerator = self::getDataGenerator()->get_plugin_generator('core_question');
140
        $quizgenerator = self::getDataGenerator()->get_plugin_generator('mod_quiz');
141
 
142
        // Setup 2 categories at site level context, with a question in each.
143
        $sitecontext = context_system::instance();
144
        $site = get_site();
145
 
146
        $siteparentcat = $this->create_question_category('Site Parent Cat', $sitecontext->id);
147
 
148
        $sitechildcat = $this->create_question_category('Site Child Cat', $sitecontext->id, $siteparentcat->id);
149
 
150
        $question1 = $questiongenerator->create_question(
151
            'shortanswer',
152
            null,
153
            ['category' => $siteparentcat->id, 'status' => question_version_status::QUESTION_STATUS_READY]
154
        );
155
        $question2 = $questiongenerator->create_question(
156
            'shortanswer',
157
            null,
158
            ['category' => $sitechildcat->id, 'status' => question_version_status::QUESTION_STATUS_READY]
159
        );
160
 
161
        // Add a quiz to the site course and put those questions into it.
162
        $quiz = $quizgenerator->create_instance(['course' => $site->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']);
163
        quiz_add_quiz_question($question1->id, $quiz, 1);
164
        quiz_add_quiz_question($question2->id, $quiz, 1);
165
 
166
        // Create a course with a quiz containing a random question from the system context.
167
        $randomcourse = self::getDataGenerator()->create_course(['shortname' => 'Random']);
168
        $randomquiz = $quizgenerator->create_instance(
169
            [
170
                'course' => $randomcourse->id,
171
                'grade' => 100.0,
172
                'sumgrades' => 2,
173
                'layout' => '1,0',
174
            ],
175
        );
176
        $randomquizsettings = quiz_settings::create($randomquiz->id);
177
        $structure = $randomquizsettings->get_structure();
178
        $topcategory = $DB->get_record('question_categories', ['contextid' => $sitecontext->id, 'parent' => 0]);
179
        $filtercondition = [
180
            'filter' => [
181
                'category' => [
182
                    'jointype' => \core_question\local\bank\condition::JOINTYPE_DEFAULT,
183
                    'values' => [$topcategory->id],
184
                    'filteroptions' => ['includesubcategories' => true],
185
                ],
186
            ],
187
        ];
188
        $structure->add_random_questions(1, 1, $filtercondition);
189
 
190
        // Create a course category and then a question category attached to that context.
191
        $coursecategory = self::getDataGenerator()->create_category();
192
        $this->coursecatcontext = context_coursecat::instance($coursecategory->id);
193
        $coursecatcat = $this->create_question_category('Course Cat Parent Cat', $this->coursecatcontext->id);
194
 
195
        // Add a question to the category just made.
196
        $question3 = $questiongenerator->create_question('essay', 'files', ['category' => $coursecatcat->id]);
197
 
198
        // Add a quiz to the course category and put those questions into it.
199
        $course = self::getDataGenerator()->create_course(['category' => $coursecategory->id]);
200
        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']);
201
        quiz_add_quiz_question($question3->id, $quiz, 1);
202
 
203
        // Create an additional question with a missing type, to catch edge cases.
204
        $question4 = $questiongenerator->create_question('missingtype', 'invalid', ['category' => $coursecatcat->id]);
205
        $DB->set_field('question', 'qtype', 'invalid', ['id' => $question4->id]);
206
 
207
        // Create 2 nested categories with questions in them at course context level.
208
        $course = self::getDataGenerator()->create_course();
209
        $this->coursecontext = context_course::instance($course->id);
210
        $coursegrandparentcat = $this->create_question_category('Course Grandparent Cat', $this->coursecontext->id);
211
        $courseparentcat1 = $this->create_question_category(
212
            'Course Parent Cat',
213
            $this->coursecontext->id,
214
            $coursegrandparentcat->id,
215
        );
216
        $coursechildcat1 = $this->create_question_category(
217
            'Course Child Cat',
218
            $this->coursecontext->id,
219
            $courseparentcat1->id,
220
        );
221
 
222
        $question4 = $questiongenerator->create_question('shortanswer', null, ['category' => $courseparentcat1->id]);
223
        $question5 = $questiongenerator->create_question('shortanswer', null, ['category' => $coursechildcat1->id]);
224
 
225
        // Make the questions 'in use'.
226
        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']);
227
        quiz_add_quiz_question($question4->id, $quiz, 1);
228
        quiz_add_quiz_question($question5->id, $quiz, 1);
229
 
230
        // Include a stale question, which should not be migrated with the others.
231
        $question6 = $questiongenerator->create_question('shortanswer', null, ['category' => $coursechildcat1->id]);
232
        $DB->set_field(
233
            'question_versions',
234
            'status',
235
            question_version_status::QUESTION_STATUS_HIDDEN,
236
            ['questionid' => $question6->id],
237
        );
238
 
239
        // Create some nested categories with no questions in use.
240
        $course = self::getDataGenerator()->create_course();
241
        $context = context_course::instance($course->id);
242
        $courseparentcat1 = $this->create_question_category('Stale Course Parent Cat1', $context->id);
243
        $coursechildcat1 = $this->create_question_category('Stale Course Child Cat1', $context->id, $courseparentcat1->id);
244
        $courseparentcat2 = $this->create_question_category('Stale Course Parent Cat2', $context->id);
245
        $coursechildcat2 = $this->create_question_category('Stale Course Child Cat2', $context->id, $courseparentcat2->id);
246
        $coursegrandchildcat1 = $this->create_question_category('Stale Course Grandchild Cat1', $context->id, $coursechildcat2->id);
247
        $this->stalecoursecontext = context_course::instance($course->id);
248
 
249
        // Make all the questions hidden.
250
        $this->stalequestions[] = $questiongenerator->create_question('shortanswer',
251
            null,
252
            ['category' => $courseparentcat1->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN]
253
        );
254
        $this->stalequestions[] = $questiongenerator->create_question('shortanswer',
255
            null,
256
            ['category' => $coursechildcat1->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN]
257
        );
258
        $this->stalequestions[] = $questiongenerator->create_question('shortanswer',
259
            null,
260
            ['category' => $courseparentcat2->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN]
261
        );
262
        $this->stalequestions[] = $questiongenerator->create_question('shortanswer',
263
            null,
264
            ['category' => $coursechildcat2->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN]
265
        );
266
        $this->stalequestions[] = $questiongenerator->create_question('shortanswer',
267
            null,
268
            ['category' => $coursegrandchildcat1->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN]
269
        );
270
 
271
        foreach ($this->stalequestions as $question) {
272
            $DB->set_field('question_versions',
273
                'status',
274
                question_version_status::QUESTION_STATUS_HIDDEN,
275
                ['questionid' => $question->id]
276
            );
277
        }
278
 
279
        // Create additional versions of a stale question, all hidden.
280
        $staleversionquestion = reset($this->stalequestions);
281
        $questiongenerator->update_question($staleversionquestion, overrides: (array) $staleversionquestion);
282
        $questiongenerator->update_question($staleversionquestion, overrides: (array) $staleversionquestion);
283
 
284
        // Set up a quiz with some categories and questions attached to it.
285
        $course = self::getDataGenerator()->create_course();
286
        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']);
287
        $this->quizcontext = context_module::instance($quiz->cmid);
288
        $quizparentcat1 = $this->create_question_category('Quiz Mod Parent Cat1', $this->quizcontext->id);
289
        $quizchildcat1 = $this->create_question_category('Quiz Mod Child Cat1', $this->quizcontext->id, $quizparentcat1->id);
290
        $question1 = $questiongenerator->create_question('shortanswer', null, ['category' => $quizparentcat1->id]);
291
        $question2 = $questiongenerator->create_question('shortanswer', null, ['category' => $quizchildcat1->id]);
292
        quiz_add_quiz_question($question1->id, $quiz, 1);
293
        quiz_add_quiz_question($question2->id, $quiz, 1);
294
 
295
        // Set up a course with three categories
296
        // - One contains questions including 1 that is used in a quiz.
297
        // - One contains questions that are not used anywhere, but are in "ready" state.
298
        // - One contains no questions.
299
        $course = self::getDataGenerator()->create_course(['shortname' => 'Used-Unused-Empty']);
300
        $this->usedunusedcontext = context_course::instance($course->id);
301
        $usedcategory = $this->create_question_category(name: 'Used Question Cat', contextid: $this->usedunusedcontext->id);
302
        $unusedcategory = $this->create_question_category('Unused Question Cat', $this->usedunusedcontext->id);
303
        $emptycategory = $this->create_question_category('Empty Cat', $this->usedunusedcontext->id);
304
        $question1 = $questiongenerator->create_question('shortanswer', null, ['category' => $usedcategory->id]);
305
        $question2 = $questiongenerator->create_question('shortanswer', null, ['category' => $usedcategory->id]);
306
        $question3 = $questiongenerator->create_question('shortanswer', null, ['category' => $unusedcategory->id]);
307
        $question4 = $questiongenerator->create_question('shortanswer', null, ['category' => $unusedcategory->id]);
308
        $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']);
309
        quiz_add_quiz_question($question1->id, $quiz, 1);
310
 
311
        // The quiz also contains a random question from the used category.
312
        $quizsettings = quiz_settings::create($quiz->id);
313
        $structure = $quizsettings->get_structure();
314
        $filtercondition = [
315
            'filter' => [
316
                'category' => [
317
                    'jointype' => \core_question\local\bank\condition::JOINTYPE_DEFAULT,
318
                    'values' => [$usedcategory->id],
319
                    'filteroptions' => ['includesubcategories' => false],
320
                ],
321
            ],
322
        ];
323
        $structure->add_random_questions(1, 1, $filtercondition);
324
    }
325
 
326
    /**
327
     * Asserts that the pre-installation setup is correct.
328
     *
329
     * @return void
330
     */
331
    public function test_setup_pre_install_data(): void {
332
        global $DB;
333
        $this->resetAfterTest();
334
        $this->setup_pre_install_data();
335
 
336
        $sitecontext = context_system::instance();
337
        $allsitecats = $DB->get_records('question_categories', ['contextid' => $sitecontext->id], 'id ASC');
338
 
339
        // Make sure we have 2 site level question categories below 'top' and that the child is below the parent.
340
        $this->assertCount(3, $allsitecats);
341
        $parentcat = next($allsitecats);
342
        $childcat = end($allsitecats);
343
        $this->assertEquals($parentcat->id, $childcat->parent);
344
 
345
        // Make sure we have 1 question per the above site level question categories.
346
        $questions = $this->get_question_data(array_map(static fn($cat) => $cat->id, $allsitecats));
347
        usort($questions, static fn($a, $b) => $a->categoryid <=> $b->categoryid);
348
        $this->assertCount(2, $questions);
349
        $parentcatq = reset($questions);
350
        $childcatq = end($questions);
351
        $this->assertEquals($parentcat->id, $parentcatq->categoryid);
352
        $this->assertEquals($childcat->id, $childcatq->categoryid);
353
 
354
        // Make sure the "Random" course has 1 quiz with 1 random question that returns the questions from the system top category.
355
        $randomcourse = $DB->get_record('course', ['shortname' => 'Random']);
356
        $coursemods = get_course_mods($randomcourse->id);
357
        $randomquiz = reset($coursemods);
358
        $randomquizsettings = quiz_settings::create($randomquiz->instance);
359
        $structure = $randomquizsettings->get_structure();
360
        $randomquestionslot = $structure->get_question_in_slot(1);
361
        $this->assertEquals($randomquestionslot->contextid, $sitecontext->id);
362
        $loader = new random_question_loader(new \qubaid_list([]));
363
        $randomquestions = $loader->get_filtered_questions($randomquestionslot->filtercondition['filter']);
364
        $this->assertCount(2, $randomquestions);
365
        $randomq1 = reset($randomquestions);
366
        $randomq2 = end($randomquestions);
367
        $this->assertEquals($parentcatq->id, $randomq1->id);
368
        $this->assertEquals($parentcat->id, $randomq1->category);
369
        $this->assertEquals($childcatq->id, $randomq2->id);
370
        $this->assertEquals($childcat->id, $randomq2->category);
371
 
372
        // Make sure that the course category has a question category below 'top'.
373
        $allcoursecatcats = $DB->get_records('question_categories', ['contextid' => $this->coursecatcontext->id], 'id ASC');
374
        $this->assertCount(2, $allcoursecatcats);
375
        $topcat = reset($allcoursecatcats);
376
        $parentcat = end($allcoursecatcats);
377
        $this->assertEquals($topcat->id, $parentcat->parent);
378
 
379
        // Make sure we have 2 questions in the above course category level question category.
380
        $questions = $this->get_question_data(array_map(static fn($cat) => $cat->id, $allcoursecatcats));
381
        $this->assertCount(2, $questions);
382
        $question = reset($questions);
383
        $this->assertEquals($parentcat->id, $question->categoryid);
384
        // Make sure there are files in the expected fileareas for this question.
385
        $fs = get_file_storage();
386
        $this->assertTrue($fs->file_exists($this->coursecatcontext->id, 'question', 'questiontext', $question->id, '/', '1.png'));
387
        $this->assertTrue(
388
            $fs->file_exists($this->coursecatcontext->id, 'question', 'generalfeedback', $question->id, '/', '2.png'),
389
        );
390
        $this->assertTrue($fs->file_exists($this->coursecatcontext->id, 'qtype_essay', 'graderinfo', $question->id, '/', '3.png'));
391
 
392
        // Make sure we have 4 question categories at course level (including 'top') with some questions in them.
393
        $allcoursecats = $DB->get_records('question_categories', ['contextid' => $this->coursecontext->id], 'id ASC');
394
        $this->assertCount(4, $allcoursecats);
395
        $grandparentcat = next($allcoursecats);
396
        $parentcat = next($allcoursecats);
397
        $this->assertEquals($grandparentcat->id, $parentcat->parent);
398
        $childcat = end($allcoursecats);
399
        $this->assertEquals($parentcat->id, $childcat->parent);
400
        $questions = $this->get_question_data(array_map(static fn($cat) => $cat->id, $allcoursecats));
401
        // 2 active questions and 1 stale question, for a total of 3.
402
        $this->assertCount(3, $questions);
403
 
404
        // Make sure we have 6 stale question categories at course level (including 'top') with some questions in them.
405
        $questioncats = $DB->get_records('question_categories', ['contextid' => $this->stalecoursecontext->id], 'id ASC');
406
        $this->assertCount(6, $questioncats);
407
        $topcat = reset($questioncats);
408
        $parentcat1 = next($questioncats);
409
        $childcat1 = next($questioncats);
410
        $parentcat2 = next($questioncats);
411
        $childcat2 = next($questioncats);
412
        $grandchildcat1 = next($questioncats);
413
        $this->assertEquals($topcat->id, $parentcat1->parent);
414
        $this->assertEquals($topcat->id, $parentcat2->parent);
415
        $this->assertEquals($parentcat1->id, $childcat1->parent);
416
        $this->assertEquals($parentcat2->id, $childcat2->parent);
417
        $this->assertEquals($childcat2->id, $grandchildcat1->parent);
418
        // There should be 4 question bank entries with 1 version each, and 1 with 3 versions, for a total of 7.
419
        $questionids = $this->get_question_data(array_map(static fn($cat) => $cat->id, $questioncats));
420
        $this->assertCount(7, $questionids);
421
 
422
        // Make sure the "Used-Unused-Empty" course has 4 question categories (including 'top') with 0, 2, 2, and 0
423
        // questions respectively.
424
        $questioncats = $DB->get_records('question_categories', ['contextid' => $this->usedunusedcontext->id], 'id ASC');
425
        $this->assertCount(4, $questioncats);
426
        $topcat = reset($questioncats);
427
        $this->assertEmpty($this->get_question_data([$topcat->id]));
428
        $usedcat = next($questioncats);
429
        $this->assertCount(2, $this->get_question_data([$usedcat->id]));
430
        $unusedcat = next($questioncats);
431
        $this->assertCount(2, $this->get_question_data([$unusedcat->id]));
432
        $emptycat = next($questioncats);
433
        $this->assertCount(0, $this->get_question_data([$emptycat->id]));
434
 
435
        // The question reference for the random question is using the "used" category, and the site context.
436
        $coursemods = get_course_mods($this->usedunusedcontext->instanceid);
437
        $quiz = reset($coursemods);
438
        $quizsettings = quiz_settings::create($quiz->instance);
439
        $structure = $quizsettings->get_structure();
440
        $randomquestionslot = $structure->get_question_in_slot(2);
441
        $this->assertEquals($this->usedunusedcontext->id, $randomquestionslot->contextid);
442
        $this->assertEquals($usedcat->id, $randomquestionslot->filtercondition['filter']['category']['values'][0]);
443
    }
444
 
445
    /**
446
     * Assert the installation task handles the deprecated contexts correctly.
447
     *
448
     * @return void
449
     */
450
    public function test_qbank_install(): void {
451
        global $DB;
452
        $this->resetAfterTest();
453
        $this->setup_pre_install_data();
454
 
455
        $task = new transfer_question_categories();
456
        $task->execute();
457
 
458
        // Site context checks.
459
 
460
        $sitecontext = context_system::instance();
461
        $sitecontextcats = $DB->get_records('question_categories', ['contextid' => $sitecontext->id]);
462
 
463
        // Should be no site context question categories left, not even 'top'.
464
        $this->assertCount(0, $sitecontextcats);
465
 
466
        $sitemodinfo = get_fast_modinfo(get_site());
467
        $siteqbanks = $sitemodinfo->get_instances_of('qbank');
468
 
469
        // We should have 1 new module on the site course.
470
        $this->assertCount(1, $siteqbanks);
471
        $siteqbank = reset($siteqbanks);
472
 
473
        // Make doubly sure it got put into section 0 as these mod types are not rendered to the course page.
474
        $this->assertEquals(0, $siteqbank->sectionnum);
475
 
476
        // It should have our determined name.
477
        $this->assertEquals('System shared question bank', $siteqbank->name);
478
        $sitemodcontext = context_module::instance($siteqbank->get_course_module_record()->id);
479
 
480
        // The 3 question categories including 'top' should now be at the new module context with their order intact.
481
        $sitemodcats = $DB->get_records_select('question_categories',
482
            'parent <> 0 AND contextid = :contextid',
483
            ['contextid' => $sitemodcontext->id],
484
            'id ASC'
485
        );
486
        $this->assertCount(2, $sitemodcats);
487
        $topcat = question_get_top_category($sitemodcontext->id);
488
        $parentcat = reset($sitemodcats);
489
        $childcat = next($sitemodcats);
490
        $this->assertEquals($topcat->id, $parentcat->parent);
491
        $this->assertEquals($parentcat->id, $childcat->parent);
492
 
493
        // The random question should now point to the questions in the site course question bank.
494
        $randomcourse = $DB->get_record('course', ['shortname' => 'Random']);
495
        $coursemods = get_course_mods($randomcourse->id);
496
        $randomquiz = reset($coursemods);
497
        $randomquizsettings = quiz_settings::create($randomquiz->instance);
498
        $structure = $randomquizsettings->get_structure();
499
        $randomquestionslot = $structure->get_question_in_slot(1);
500
        $this->assertEquals($randomquestionslot->contextid, $sitemodcontext->id);
501
        $loader = new random_question_loader(new \qubaid_list([]));
502
        $randomquestions = $loader->get_filtered_questions($randomquestionslot->filtercondition['filter']);
503
        $this->assertCount(2, $randomquestions);
504
        $randomq1 = reset($randomquestions);
505
        $randomq2 = end($randomquestions);
506
        $this->assertEquals($parentcat->id, $randomq1->category);
507
        $this->assertEquals($childcat->id, $randomq2->category);
508
 
509
        // Course category context checks.
510
 
511
        // Make sure that the course category has no question categories, not even 'top'.
512
        $this->assertEquals(0, $DB->count_records('question_categories', ['contextid' => $this->coursecatcontext->id]));
513
 
514
        $courses = $DB->get_records('course', ['category' => $this->coursecatcontext->instanceid], 'id ASC');
515
        // We should have 2 courses in this category now, the original and the new one that holds our new mod instance.
516
        $this->assertCount(2, $courses);
517
        $newcourse = end($courses);
518
        $coursecat = $DB->get_record('course_categories', ['id' => $newcourse->category]);
519
 
520
        // Make sure the new course shortname is a unique name based on the category name and id.
521
        $this->assertEquals("$coursecat->name-$coursecat->id", $newcourse->shortname);
522
 
523
        // Make sure the new course fullname is based on the category name.
524
        $this->assertEquals("Shared teaching resources for category: $coursecat->name", $newcourse->fullname);
525
 
526
        $coursemodinfo = get_fast_modinfo($newcourse);
527
        $coursecatqbanks = $coursemodinfo->get_instances_of('qbank');
528
 
529
        // We should have 1 new module on this course.
530
        $this->assertCount(1, $coursecatqbanks);
531
        $coursecatqbank = reset($coursecatqbanks);
532
 
533
        // Make sure the new module name is what we expect.
534
        $this->assertEquals("$coursecat->name shared question bank", $coursecatqbank->name);
535
 
536
        $coursecatqcats = $DB->get_records('question_categories', ['contextid' => $coursecatqbank->context->id], 'parent ASC');
537
 
538
        // The 2 question categories should be moved to the module context now.
539
        $this->assertCount(2, $coursecatqcats);
540
        $topcat = reset($coursecatqcats);
541
        $parentcat = end($coursecatqcats);
542
 
543
        // Make sure the parent orders are correct.
544
        $this->assertEquals($topcat->id, $parentcat->parent);
545
 
546
        // Course context checks.
547
 
548
        // Make sure that the course has no more question categories, not even 'top'.
549
        $this->assertEquals(0, $DB->count_records('question_categories', ['contextid' => $this->coursecontext->id]));
550
 
551
        $coursemodinfo = get_fast_modinfo($this->coursecontext->instanceid);
552
        $course = $coursemodinfo->get_course();
553
        $courseqbanks = $coursemodinfo->get_instances_of('qbank');
554
 
555
        // We should have only 1 new mod instance in this course.
556
        $this->assertCount(1, $coursecatqbanks);
557
 
558
        // The module name should be what we expect.
559
        $courseqbank = reset($courseqbanks);
560
        $this->assertEquals("$course->shortname shared question bank", $courseqbank->name);
561
 
562
        // Make sure the question categories still exist and that we have a new top one at the new module context.
563
        $topcat = question_get_top_category($courseqbank->context->id);
564
        $courseqcats = $DB->get_records_select('question_categories',
565
            'parent <> 0 AND contextid = :contextid',
566
            ['contextid' => $courseqbank->context->id],
567
            'id ASC'
568
        );
569
        $grandparentcat = reset($courseqcats);
570
        $parentcat = next($courseqcats);
571
        $childcat = next($courseqcats);
572
 
573
        $this->assertEquals($topcat->id, $grandparentcat->parent);
574
        $this->assertEquals($grandparentcat->id, $parentcat->parent);
575
        $this->assertEquals($parentcat->id, $childcat->parent);
576
        // Make sure the two active questions were migrated with their categories, but not the stale question.
577
        $migratedquestions = $this->get_question_data([$parentcat->id, $childcat->id]);
578
        $this->assertCount(2, $migratedquestions);
579
        foreach ($migratedquestions as $migratedquestion) {
580
            $this->assertTrue($migratedquestion->status === question_version_status::QUESTION_STATUS_READY);
581
        }
582
 
583
        // Stale course context checks.
584
 
585
        // Make sure the stale course has no categories attached to it anymore and the questions were removed.
586
        $this->assertFalse($DB->record_exists('question_categories', ['contextid' => $this->stalecoursecontext->id]));
587
        foreach ($this->stalequestions as $stalequestion) {
588
            $this->assertFalse($DB->record_exists('question', ['id' => $stalequestion->id]));
589
        }
590
        // Make sure the we did not create a qbank in the stale course.
591
        $this->assertEmpty(get_fast_modinfo($this->stalecoursecontext->instanceid)->get_instances_of('qbank'));
592
 
593
        // Quiz module checks.
594
 
595
        // Make sure the 3 categories at quiz context, including 'top' have not been touched.
596
        $quizcategories = $DB->get_records('question_categories', ['contextid' => $this->quizcontext->id]);
597
        $this->assertCount(3, $quizcategories);
598
        $questions = $this->get_question_data(array_map(static fn($cat) => $cat->id, $quizcategories));
599
        $this->assertCount(2, $questions);
600
 
601
        // Used-Unused-Empty checks.
602
        // The empty category should have been removed. The other categories should both have been migrated to a qbank module,
603
        // with all of their questions.
604
        $usedunusedmodinfo = get_fast_modinfo($this->usedunusedcontext->instanceid);
605
        $usedunusedcourse = $usedunusedmodinfo->get_course();
606
        $usedunusedqbanks = $usedunusedmodinfo->get_instances_of('qbank');
607
        $usedunusedqbank = reset($usedunusedqbanks);
608
        $this->assertEquals("$usedunusedcourse->shortname shared question bank", $usedunusedqbank->name);
609
 
610
        // We should now only have 3 categories. Top, used and unused.
611
        $usedunusedcats = $DB->get_records(
612
            'question_categories',
613
            ['contextid' => $usedunusedqbank->context->id],
614
            fields: 'name, id',
615
        );
616
        $this->assertCount(3, $usedunusedcats);
617
        $this->assertArrayHasKey('top', $usedunusedcats);
618
        $this->assertArrayHasKey('Used Question Cat', $usedunusedcats);
619
        $this->assertArrayHasKey('Unused Question Cat', $usedunusedcats);
620
        $this->assertArrayNotHasKey('Empty Question Cat', $usedunusedcats);
621
 
622
        $this->assertEmpty($this->get_question_data([$usedunusedcats['top']->id]));
623
        $this->assertCount(2, $this->get_question_data([$usedunusedcats['Used Question Cat']->id]));
624
        $this->assertCount(2, $this->get_question_data([$usedunusedcats['Unused Question Cat']->id]));
625
 
626
        // The question reference for the random question is using the same category, but the new context.
627
        $modinfo = get_fast_modinfo($this->usedunusedcontext->instanceid);
628
        $quizzes = $modinfo->get_instances_of('quiz');
629
        $quiz = reset($quizzes);
630
        $quizsettings = quiz_settings::create($quiz->instance);
631
        $structure = $quizsettings->get_structure();
632
        $randomquestionslot = $structure->get_question_in_slot(2);
633
        $this->assertEquals($usedunusedqbank->context->id, $randomquestionslot->contextid);
634
        $this->assertEquals(
635
            $usedunusedcats['Used Question Cat']->id,
636
            $randomquestionslot->filtercondition['filter']['category']['values'][0]
637
        );
638
    }
639
 
640
    /**
641
     * Assert the installation task handles the missing contexts correctly.
642
     *
643
     * @return void
644
     */
645
    public function test_qbank_install_with_missing_context(): void {
646
        global $DB;
647
        $this->resetAfterTest();
648
        self::setAdminUser();
649
 
650
        $questiongenerator = self::getDataGenerator()->get_plugin_generator('core_question');
651
 
652
        // The problem is that question categories that used to related to contextids
653
        // which no longer exist are now all moved to the new system-level shared
654
        // question bank. This moving categories together can cause unique key violations.
655
 
656
        // Create 2 orphaned categories where the contextid no longer exists, with the same stamp and idnumber.
657
        // We need to do this by creating in a real context, then deleting the context,
658
        // because create category logs, which needs a valid context id.
659
        $tamperedstamp = make_unique_id_code();
660
        $context1 = context_course::instance(self::getDataGenerator()->create_course()->id);
661
        $oldcat1 = $this->create_question_category('Lost category 1', $context1->id);
662
        $oldcat1->stamp = $tamperedstamp;
663
        $oldcat1->idnumber = 'tamperedidnumber';
664
        $DB->update_record('question_categories', $oldcat1);
665
        $DB->delete_records('context', ['id' => $context1->id]);
666
 
667
        $context2 = context_course::instance(self::getDataGenerator()->create_course()->id);
668
        $oldcat2 = $this->create_question_category('Lost category 2', $context2->id);
669
        $oldcat2->stamp = $tamperedstamp;
670
        $oldcat2->idnumber = 'tamperedidnumber';
671
        $DB->update_record('question_categories', $oldcat2);
672
        $DB->delete_records('context', ['id' => $context2->id]);
673
 
674
        // Add a question to each category.
675
        $question1 = $questiongenerator->create_question('shortanswer', null, ['category' => $oldcat1->id]);
676
        $question2 = $questiongenerator->create_question('shortanswer', null, ['category' => $oldcat2->id]);
677
 
678
        // Make the questions 'in use'.
679
        $quizcourse = self::getDataGenerator()->create_course();
680
        $quiz = self::getDataGenerator()->get_plugin_generator('mod_quiz')->create_instance(
681
            ['course' => $quizcourse->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']
682
        );
683
        quiz_add_quiz_question($question1->id, $quiz);
684
        quiz_add_quiz_question($question2->id, $quiz);
685
 
686
        // Make sure the caches are reset so that the contexts are not cached.
687
        \core\context_helper::reset_caches();
688
 
689
        // Run the task.
690
        $task = new transfer_question_categories();
691
        $task->execute();
692
        // An important thing to verify is that the task completes without errors,
693
        // for example unique key violations.
694
 
695
        // Verify - there should be a single question bank in the site course with the expected name.
696
        $sitemodinfo = get_fast_modinfo(get_site());
697
        $siteqbanks = $sitemodinfo->get_instances_of('qbank');
698
        $this->assertCount(1, $siteqbanks);
699
        $siteqbank = reset($siteqbanks);
700
        $this->assertEquals('System shared question bank', $siteqbank->name);
701
 
702
        // The two previously orphaned categories should now be in this site questions bank, with a top category.
703
        $sitemodcontext = context_module::instance($siteqbank->get_course_module_record()->id);
704
        $sitemodcats = $DB->get_records_select(
705
            'question_categories',
706
            'parent <> 0 AND contextid = :contextid',
707
            ['contextid' => $sitemodcontext->id],
708
            'id ASC',
709
        );
710
 
711
        // Work out which category is which.
712
        $movedcat1 = null;
713
        $movedcat2 = null;
714
        foreach ($sitemodcats as $movedcat) {
715
            if ($movedcat->name === $oldcat1->name) {
716
                $movedcat1 = $movedcat;
717
            }
718
            if ($movedcat->name === $oldcat2->name) {
719
                $movedcat2 = $movedcat;
720
            }
721
        }
722
        $this->assertNotNull($movedcat1);
723
        $this->assertNotNull($movedcat2);
724
 
725
        // Verify the properties of the moved categories.
726
        $this->assertNotEquals($movedcat1->stamp, $movedcat2->stamp);
727
        $this->assertNotEquals($movedcat1->idnumber, $movedcat2->idnumber);
728
        $this->assertEquals(question_get_top_category($sitemodcontext->id)->id, $movedcat1->parent);
729
        $this->assertEquals(question_get_top_category($sitemodcontext->id)->id, $movedcat2->parent);
730
    }
731
 
732
    public function test_fix_wrong_parents(): void {
733
        $this->resetAfterTest();
734
        $this->setup_pre_install_data();
735
 
736
        // Create a second course.
737
        $course2 = self::getDataGenerator()->create_course();
738
        $course2context = context_course::instance($course2->id);
739
 
740
        // In course2 we build this category structure:
741
        // - $course2parentcat -- context $course2context
742
        // - - $wrongchild1 -- context $this->coursecontext (wrong)
743
        // - - - $wronggrandchild1 -- context $this->coursecontext (same wrong)
744
        // - - - $doublywronggrandchild1 -- context $course2context (back right, but not matching its parent)
745
        // - - $wrongchild2 -- context non-existant A
746
        // - - - $wronggrandchild2 -- context non-existent A
747
        // - - - $doublywronggrandchild2 -- context non-existent B.
748
        $course2parentcat = $this->create_question_category(
749
            'Course2 parent cat', $course2context->id);
750
 
751
        $wrongchild1 = $this->create_question_category(
752
            'Child cat with wrong context', $this->coursecontext->id, $course2parentcat->id);
753
        $wronggrandchild1 = $this->create_question_category(
754
            'Grandchild of child1 in same wrong context', $this->coursecontext->id, $wrongchild1->id);
755
        $doublywronggrandchild1 = $this->create_question_category(
756
            'Grandchild of child1 back in the right context', $course2context->id, $wrongchild1->id);
757
 
758
        $wrongchild2 = $this->create_question_category(
759
            'Child cat with non-existent context', $course2context->id + 1000, $course2parentcat->id);
760
        $wronggrandchild2 = $this->create_question_category(
761
            'Grandchild of child2 with same non-existent context', $course2context->id + 1000, $wrongchild2->id);
762
        $doublywronggrandchild2 = $this->create_question_category(
763
            'Grandchild of child2 with different non-existent context', $course2context->id + 2000, $wrongchild2->id);
764
 
765
        // Before we clean up, check that the expected categories are picked up.
766
        // $wronggrandchild1 & $wronggrandchild2 are not seen, because their contexts match
767
        // their parent's even though both are wrong. They should still get fixed.
768
        $task = new transfer_question_categories();
769
        $this->assertEquals(
770
            [
771
                $wrongchild1->id => $wrongchild1->contextid,
772
                $doublywronggrandchild1->id => $course2context->id,
773
                $wrongchild2->id => $wrongchild2->contextid,
774
                $doublywronggrandchild2->id => $doublywronggrandchild2->contextid,
775
            ],
776
            $task->get_categories_in_a_different_context_to_their_parent(),
777
        );
778
 
779
        // Call the cleanup method.
780
        $task->fix_wrong_parents();
781
 
782
        // Now we expect no mismatches.
783
        $this->assertEmpty($task->get_categories_in_a_different_context_to_their_parent());
784
 
785
        // Assert that the child categories have been moved to the locations they should have been.
786
        $this->assert_category_is_in_context_with_parent($this->coursecontext, null, $wrongchild1->id);
787
        $this->assert_category_is_in_context_with_parent($this->coursecontext, $wrongchild1, $wronggrandchild1->id);
788
        $this->assert_category_is_in_context_with_parent($course2context, null, $doublywronggrandchild1->id);
789
        $this->assert_category_is_in_context_with_parent($course2context, $course2parentcat, $wrongchild2->id);
790
        $this->assert_category_is_in_context_with_parent($course2context, $wrongchild2, $wronggrandchild2->id);
791
        $this->assert_category_is_in_context_with_parent($course2context, $wrongchild2, $doublywronggrandchild2->id);
792
    }
793
 
794
    /**
795
     * Assert that the category with id $categoryid is in context $expectedcontext, with the given parent.
796
     *
797
     * @param context $expectedcontext the expected context for the category with id $categoryid.
798
     * @param stdClass|null $expectedparent the expected parent category.
799
     *      null means the Top category in $expectedcontext.
800
     * @param int $categoryid the id of the category to check.
801
     */
802
    protected function assert_category_is_in_context_with_parent(
803
        context $expectedcontext,
804
        ?stdClass $expectedparent,
805
        int $categoryid,
806
    ): void {
807
        global $DB;
808
 
809
        if ($expectedparent === null) {
810
            $expectedparent = $DB->get_record(
811
                'question_categories',
812
                ['contextid' => $expectedcontext->id, 'parent' => 0],
813
                '*',
814
                MUST_EXIST,
815
            );
816
        }
817
 
818
        $actualcategory = $DB->get_record('question_categories', ['id' => $categoryid]);
819
        $this->assertEquals($expectedparent->id, $actualcategory->parent,
820
            "Checking parent of category $actualcategory->name.");
821
        $this->assertEquals($expectedcontext->id, $actualcategory->contextid,
822
            "Checking context of category $actualcategory->name.");
823
    }
824
 
825
    public function test_transfer_questions(): void {
826
        global $DB;
827
        $this->resetAfterTest();
828
        $this->setup_pre_install_data();
829
 
830
        $task = new \mod_qbank\task\transfer_question_categories();
831
        $task->execute();
832
 
833
        // Assert that files are still in their original context.
834
        $courses = $DB->get_records('course', ['category' => $this->coursecatcontext->instanceid], 'id ASC');
835
        $newcourse = end($courses);
836
        $coursemodinfo = get_fast_modinfo($newcourse);
837
        $coursecatqbanks = $coursemodinfo->get_instances_of('qbank');
838
        $coursecatqbank = reset($coursecatqbanks);
839
        $coursecatqcats = $DB->get_records('question_categories', ['contextid' => $coursecatqbank->context->id], 'parent ASC');
840
        $parentcat = end($coursecatqcats);
841
        $questions = get_questions_category($parentcat, true);
842
        $question = reset($questions);
843
        $fs = get_file_storage();
844
        $this->assertTrue($fs->file_exists(
845
            $this->coursecatcontext->id,
846
            'question',
847
            'questiontext',
848
            $question->id,
849
            '/',
850
            '1.png'
851
        ));
852
        $this->assertTrue($fs->file_exists(
853
            $this->coursecatcontext->id,
854
            'question',
855
            'generalfeedback',
856
            $question->id,
857
            '/',
858
            '2.png'
859
        ));
860
        $this->assertTrue($fs->file_exists(
861
            $this->coursecatcontext->id,
862
            'qtype_essay',
863
            'graderinfo',
864
            $question->id,
865
            '/',
866
            '3.png'
867
        ));
868
        $this->assertFalse($fs->file_exists(
869
            $coursecatqbank->context->id,
870
            'question',
871
            'questiontext',
872
            $question->id,
873
            '/',
874
            '1.png'
875
        ));
876
        $this->assertFalse($fs->file_exists(
877
            $coursecatqbank->context->id,
878
            'question',
879
            'generalfeedback',
880
            $question->id,
881
            '/',
882
            '2.png'
883
        ));
884
        $this->assertFalse($fs->file_exists(
885
            $coursecatqbank->context->id,
886
            'qtype_essay',
887
            'graderinfo',
888
            $question->id,
889
            '/',
890
            '3.png'
891
        ));
892
 
893
        $this->assertFalse(question_bank_helper::has_bank_migration_task_completed_successfully());
894
 
895
        $questiontasks = manager::get_adhoc_tasks(transfer_questions::class);
896
 
897
        // We should have a transfer_questions task for each category that was moved.
898
        // 2 site categories,
899
        // 1 coursecat category,
900
        // 3 regular course categories,
901
        // 2 used/unused course categories.
902
        $this->assertCount(8, $questiontasks);
903
 
904
        $this->expectOutputRegex('~Moving files and tags~');
905
        // Delete one of the categories before running the tasks, to ensure missing categories are handled gracefully.
906
        $unusedcat = $DB->get_record('question_categories', ['name' => 'Unused Question Cat']);
907
        question_category_delete_safe($unusedcat);
908
        $this->expectOutputRegex("~Could not find a category record for id {$unusedcat->id}. Terminating task.~");
909
 
910
        $this->runAdhocTasks();
911
 
912
        // The files have now been moved to the new context.
913
        $this->assertFalse($fs->file_exists(
914
            $this->coursecatcontext->id,
915
            'question',
916
            'questiontext',
917
            $question->id,
918
            '/',
919
            '1.png'
920
        ));
921
        $this->assertFalse($fs->file_exists(
922
            $this->coursecatcontext->id,
923
            'question',
924
            'generalfeedback',
925
            $question->id,
926
            '/',
927
            '2.png'
928
        ));
929
        $this->assertFalse($fs->file_exists(
930
            $this->coursecatcontext->id,
931
            'qtype_essay',
932
            'graderinfo',
933
            $question->id,
934
            '/',
935
            '3.png'
936
        ));
937
        $this->assertTrue($fs->file_exists(
938
            $coursecatqbank->context->id,
939
            'question',
940
            'questiontext',
941
            $question->id,
942
            '/',
943
            '1.png'
944
        ));
945
        $this->assertTrue($fs->file_exists(
946
            $coursecatqbank->context->id,
947
            'question',
948
            'generalfeedback',
949
            $question->id,
950
            '/',
951
            '2.png'
952
        ));
953
        $this->assertTrue($fs->file_exists(
954
            $coursecatqbank->context->id,
955
            'qtype_essay',
956
            'graderinfo',
957
            $question->id,
958
            '/',
959
            '3.png'
960
        ));
961
 
962
        $this->assertTrue(question_bank_helper::has_bank_migration_task_completed_successfully());
963
    }
964
}