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_system;
20
use core\context;
21
use core\task\adhoc_task;
22
use core\task\manager;
23
use core_course_category;
24
use core_question\local\bank\question_bank_helper;
25
use stdClass;
26
 
27
/**
28
 * This script transfers question categories at CONTEXT_SITE, CONTEXT_COURSE, & CONTEXT_COURSECAT to a new qbank instance
29
 * context.
30
 *
31
 * Firstly, it finds any question categories where questions are not being used and deletes them, including questions.
32
 *
33
 * Then for any remaining, if it is at course level context, it creates a mod_qbank instance taking the course name
34
 * and moves the category there including subcategories, files and tags.
35
 *
36
 * If the original question category context was at system context, then it creates a mod_qbank instance on the site course i.e.
37
 * front page and moves the category & sub categories there, along with its files and tags.
38
 *
39
 * If the original question category context was a course category context, then it creates a course in that category,
40
 * taking the category name. Then it creates a mod_qbank instance in that course and moves the category & sub categories
41
 * there, along with files and tags belonging to those 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
 */
48
class transfer_question_categories extends adhoc_task {
49
 
50
    /**
51
     * @var array a cache [ context id => question category ] of the top category in each context.
52
     * Used by get_top_category_id_for_context() to avoid repeated DB queries.
53
     * 0 is cached if this context id has no corresponding top category.
54
     */
55
    private array $topcategorycache = [];
56
 
57
    #[\Override]
58
    public function execute(): void {
59
 
60
        global $DB, $CFG;
61
 
62
        require_once($CFG->dirroot . '/course/modlib.php');
63
        require_once($CFG->libdir . '/questionlib.php');
64
 
65
        $this->fix_wrong_parents();
66
 
67
        $recordset = $DB->get_recordset('question_categories', ['parent' => 0]);
68
 
69
        $movedcategorycontexts = [];
70
 
71
        foreach ($recordset as $oldtopcategory) {
72
 
73
            if (!$oldcontext = context::instance_by_id($oldtopcategory->contextid, IGNORE_MISSING)) {
74
                // That context does not exist anymore, we will treat these as if they were at site context level.
75
                $oldcontext = context_system::instance();
76
            }
77
 
78
            $trans = $DB->start_delegated_transaction();
79
 
80
            // Remove any unused questions if they are marked as deleted.
81
            // Also, if a category contained questions which were all unusable then delete it as well.
82
            $subcategories = $DB->get_records_select('question_categories',
83
                'parent <> 0 AND contextid = :contextid',
84
                ['contextid' => $oldtopcategory->contextid]
85
            );
86
            // This gives us categories in parent -> child order so array_reverse it,
87
            // because we should process stale categories from the bottom up.
88
            $subcategories = array_reverse(sort_categories_by_tree($subcategories, $oldtopcategory->id));
89
            foreach ($subcategories as $subcategory) {
90
                \qbank_managecategories\helper::question_remove_stale_questions_from_category($subcategory->id);
91
                if ($this->question_category_is_empty($subcategory->id)) {
92
                    question_category_delete_safe($subcategory);
93
                }
94
            }
95
 
96
            // If the top category no longer has any subcategories, because they only contained stale questions,
97
            // delete the top category and stop here without creating a new qbank.
98
            if (!$DB->record_exists('question_categories', ['parent' => $oldtopcategory->id])) {
99
                $DB->delete_records('question_categories', ['id' => $oldtopcategory->id]);
100
                $trans->allow_commit();
101
                continue;
102
            }
103
 
104
            // We don't want to transfer any categories at valid contexts i.e. quiz modules.
105
            if ($oldcontext->contextlevel === CONTEXT_MODULE) {
106
                $trans->allow_commit();
107
                continue;
108
            }
109
 
110
            // Category is in use so let's process it. Firstly, a course and mod instance is needed.
111
            switch ($oldcontext->contextlevel) {
112
                case CONTEXT_SYSTEM:
113
                    $course = get_site();
114
                    $bankname = question_bank_helper::get_bank_name_string('systembank', 'question');
115
                    break;
116
                case CONTEXT_COURSECAT:
117
                    $coursecategory = core_course_category::get($oldcontext->instanceid);
118
                    $courseshortname = "$coursecategory->name-$coursecategory->id";
119
                    $course = $this->create_course($coursecategory, $courseshortname);
120
                    $bankname = question_bank_helper::get_bank_name_string('sharedbank', 'mod_qbank', $coursecategory->name);
121
                    break;
122
                case CONTEXT_COURSE:
123
                    $course = get_course($oldcontext->instanceid);
124
                    $bankname = question_bank_helper::get_bank_name_string('sharedbank', 'mod_qbank', $course->shortname);
125
                    break;
126
                default:
127
                    // This shouldn't be possible, so we can't really transfer it.
128
                    // We should commit any pre-transfer category cleanup though.
129
                    $trans->allow_commit();
130
                    continue 2;
131
            }
132
 
133
            if (!$newmod = question_bank_helper::get_default_open_instance_system_type($course)) {
134
                $newmod = question_bank_helper::create_default_open_instance($course, $bankname, question_bank_helper::TYPE_SYSTEM);
135
            }
136
 
137
            // We have our new mod instance, now move all the subcategories of the old 'top' category to this new context.
138
            $movedcategories = $this->move_question_category($oldtopcategory, $newmod->context);
139
 
140
            $movedcategorycontexts += array_fill_keys($movedcategories, $oldtopcategory->contextid);
141
 
142
            // Job done, lets delete the old 'top' category.
143
            $DB->delete_records('question_categories', ['id' => $oldtopcategory->id]);
144
            $trans->allow_commit();
145
        }
146
 
147
        $recordset->close();
148
 
149
        // Create a set of new tasks to update the questions in each category to the new contexts.
150
        // The category itself is already in the new context. We record the old context so we know where to move
151
        // files and tags from.
152
        foreach ($movedcategorycontexts as $categoryid => $oldcontextid) {
153
            $task = new transfer_questions();
154
            $task->set_custom_data(['categoryid' => $categoryid, 'contextid' => $oldcontextid]);
155
            manager::queue_adhoc_task($task);
156
        }
157
    }
158
 
159
    /**
160
     * Wrapper for \create_course.
161
     *
162
     * @param core_course_category $coursecategory
163
     * @param string $shortname
164
     * @return stdClass
165
     */
166
    protected function create_course(core_course_category $coursecategory, string $shortname): stdClass {
167
        $data = (object) [
168
            'enablecompletion' => 0,
169
            'fullname' => get_string('coursecategory', 'mod_qbank', $coursecategory->name),
170
            'shortname' => $shortname,
171
            'category' => $coursecategory->id,
172
        ];
173
        return create_course($data);
174
    }
175
 
176
    /**
177
     * Create a new 'Top' category in our new context and move the old categories descendents beneath it.
178
     *
179
     * @param stdClass $oldtopcategory The old 'Top' category that we are moving.
180
     * @param context\module $newcontext The context we are moving our category to.
181
     * @return int[] The IDs of all categories moved to the new context.
182
     */
183
    protected function move_question_category(stdClass $oldtopcategory, context\module $newcontext): array {
184
        global $DB;
185
 
186
        $newtopcategory = question_get_top_category($newcontext->id, true);
187
 
188
        move_question_set_references($oldtopcategory->id, $newtopcategory->id, $oldtopcategory->contextid, $newcontext->id, true);
189
 
190
        // This function moves subcategories, so we have to start at the top.
191
        $movedcategories = $this->move_subcategories_to_context($oldtopcategory->id, $newcontext);
192
 
193
        // Move the parent from the old top category to the new one.
194
        $DB->set_field('question_categories', 'parent', $newtopcategory->id, ['parent' => $oldtopcategory->id]);
195
 
196
        return $movedcategories;
197
    }
198
 
199
    /**
200
     * Recursively update the contextid for all subcategories of the given category.
201
     *
202
     * @param int $categoryid The ID of the category to update subcategories for. When calling directly,
203
     *                        this should be a top category.
204
     * @param context\module $newcontext The new context for the subcategories.
205
     * @return int[] The IDs of all categories moved to the new context.
206
     */
207
    protected function move_subcategories_to_context(int $categoryid, context\module $newcontext): array {
208
        global $DB;
209
        $movedcategories = [];
210
 
211
        $subcatids = $DB->get_records('question_categories', ['parent' => $categoryid]);
212
        foreach ($subcatids as $subcatid => $data) {
213
            // Because of the fallback above, where categories pointing to a
214
            // missing contextid are all moved to the new shared system-level
215
            // question bank, some categories are moved from previously
216
            // separate contextids to the same context. This can violate
217
            // unique indexes, so we fix this by ensuring uniqueness.
218
 
219
            // For the stamp, we just generate a new stamp if required.
220
            if ($DB->record_exists('question_categories', ['stamp' => $data->stamp, 'contextid' => $newcontext->id])) {
221
                $data->stamp = make_unique_id_code();
222
            }
223
 
224
            // The idnumber we just reset duplicates to null, as is done in other places.
225
            if (
226
                $data->idnumber !== null &&
227
                $DB->record_exists('question_categories', ['idnumber' => $data->idnumber, 'contextid' => $newcontext->id])
228
            ) {
229
                $data->idnumber = null;
230
            }
231
 
232
            // Update the contextid and save the category.
233
            $data->contextid = $newcontext->id;
234
            $DB->update_record('question_categories', $data);
235
 
236
            $movedcategories[] = $subcatid;
237
            $movedcategories = array_merge(
238
                $this->move_subcategories_to_context($subcatid, $newcontext),
239
                $movedcategories,
240
            );
241
        }
242
        return $movedcategories;
243
    }
244
 
245
    /**
246
     * Find the Top category for a context, if there is one.
247
     *
248
     * @param int $contextid the id of a context (which might not exist).
249
     * @return int a Top category id, or 0 if none is found.
250
     */
251
    protected function get_top_category_id_for_context(int $contextid): int {
252
        global $DB;
253
 
254
        // Use the cache if we have already loaded this.
255
        if (array_key_exists($contextid, $this->topcategorycache)) {
256
            return $this->topcategorycache[$contextid];
257
        }
258
 
259
        $topcategoryid = (int) $DB->get_field('question_categories', 'id',
260
            ['contextid' => $contextid, 'parent' => 0]);
261
 
262
        $this->topcategorycache[$contextid] = $topcategoryid;
263
        return $topcategoryid;
264
    }
265
 
266
    /**
267
     * Fix the context of child categories whose contextid does not match that of their parents.
268
     *
269
     * Fix here means:
270
     *
271
     * - if the child category's context exists, and has a 'Top' category, we move the child
272
     *   category to be just under that Top category. That is where they would have appeared
273
     *   before, e.g. in the return from question_categorylist().
274
     *
275
     * - if the child category points to a context that does not exist at all, then we
276
     *   instead change its context to be the same as it's parent's context. This may
277
     *   break things like images in the question text of questions there, but there is
278
     *   no real alternative.
279
     *
280
     * This is necessary because, due to old bugs, for example in backup and restoree code,
281
     * we know there can be question categories in the databases of old Moodle sites with
282
     * the wrong context id.
283
     */
284
    public function fix_wrong_parents(): void {
285
        global $DB;
286
 
287
        $categoriestofix = $this->get_categories_in_a_different_context_to_their_parent();
288
        foreach ($categoriestofix as $childcategoryid => $childcontextid) {
289
 
290
            $topcategoryid = $this->get_top_category_id_for_context($childcontextid);
291
            if ($topcategoryid) {
292
                // Suitable Top category in the child's current context, so move to be a parent of that.
293
                $DB->set_field('question_categories', 'parent', $topcategoryid, ['id' => $childcategoryid]);
294
            } else {
295
                // Top not found. Change the child to have the same context as its parent.
296
                // This is not efficient in DB queries, but we expect this to be a rare case, and this is simple and right.
297
                $childcategory = $DB->get_record('question_categories', ['id' => $childcategoryid]);
298
                $parentcontextid = $DB->get_field('question_categories', 'contextid', ['id' => $childcategory->parent]);
299
                $this->move_category_and_its_children($childcategoryid, $parentcontextid);
300
            }
301
        }
302
    }
303
 
304
    /**
305
     * Get question categories that are in a different context to their parent.
306
     *
307
     * @return int[] child category id => context id of the child category.
308
     */
309
    public function get_categories_in_a_different_context_to_their_parent(): array {
310
        global $DB;
311
 
312
        return $DB->get_records_sql_menu('
313
            SELECT c.id, c.contextid
314
              FROM {question_categories} c
315
              JOIN {question_categories} p ON p.id = c.parent
316
             WHERE p.contextid <> c.contextid
317
          ORDER BY c.id
318
        ');
319
    }
320
 
321
    /**
322
     * Set the contextid of category $categoryid and all its children to $newcontextid.
323
     *
324
     * @param int $categoryid a question_category id.
325
     * @param int $newcontextid the place to move to.
326
     */
327
    public function move_category_and_its_children(int $categoryid, int $newcontextid): void {
328
        global $DB;
329
 
330
        $DB->set_field('question_categories', 'contextid', $newcontextid, ['id' => $categoryid]);
331
        $children = $DB->get_records('question_categories', ['parent' => $categoryid], '', 'id, contextid');
332
        foreach ($children as $child) {
333
            if ($child->contextid != $newcontextid) {
334
                $this->move_category_and_its_children($child->id, $newcontextid);
335
            }
336
        }
337
    }
338
 
339
    /**
340
     * Recursively check if a question category or its children contain any questions.
341
     *
342
     * @param int $categoryid The parent category to check from.
343
     * @return bool True if neither the category nor its children contain any questions.
344
     */
345
    protected function question_category_is_empty(int $categoryid): bool {
346
        global $DB;
347
 
348
        if ($DB->record_exists('question_bank_entries', ['questioncategoryid' => $categoryid])) {
349
            return false;
350
        }
351
        // If this category is empty, recursively check child categories.
352
        $childcategoryids = $DB->get_fieldset('question_categories', 'id', ['parent' => $categoryid]);
353
        foreach ($childcategoryids as $childcategoryid) {
354
            if (!$this->question_category_is_empty($childcategoryid)) {
355
                // If we found questions in a child, we don't want to check any other children.
356
                return false;
357
            }
358
        }
359
        return true;
360
    }
361
}