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 core_question;
18
 
19
use stdClass;
20
use core\exception\moodle_exception;
21
use core\context;
22
 
23
/**
24
 * Category manager class, used for CRUD operations on question categories and related utility methods.
25
 *
26
 * @copyright 2024 Catalyst IT Europe Ltd.
27
 * @author Mark Johnson <mark.johnson@catalyst-eu.net>
28
 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
29
 * @package core_question
30
 */
31
class category_manager {
32
    /**
33
     * Cached checks for managecategories permissions in each context.
34
     *
35
     * @var array $managedcontexts;
36
     */
37
    protected array $managedcontexts = [];
38
 
39
    /**
40
     * Deletes an existing question category.
41
     *
42
     * @param int $categoryid id of category to delete.
43
     */
44
    public function delete_category(int $categoryid): void {
45
        global $DB;
46
        $this->require_can_delete_category($categoryid);
47
        $category = $DB->get_record('question_categories', ['id' => $categoryid]);
48
 
49
        $transaction = $DB->start_delegated_transaction();
50
        // Send the children categories to live with their grandparent.
51
        $DB->set_field('question_categories', 'parent', $category->parent, ['parent' => $category->id]);
52
 
53
        // Finally delete the category itself.
54
        $DB->delete_records('question_categories', ['id' => $category->id]);
55
 
56
        // Log the deletion of this category.
57
        $event = \core\event\question_category_deleted::create_from_question_category_instance($category);
58
        $event->add_record_snapshot('question_categories', $category);
59
        $event->trigger();
60
        $transaction->allow_commit();
61
    }
62
 
63
    /**
64
     * Move questions and then delete the category.
65
     *
66
     * @param int $oldcat id of the old category.
67
     * @param int $newcat id of the new category.
68
     */
69
    public function move_questions_and_delete_category(int $oldcat, int $newcat): void {
70
        global $DB;
71
        $transaction = $DB->start_delegated_transaction();
72
        $this->require_can_delete_category($oldcat);
73
        $this->move_questions($oldcat, $newcat);
74
        $this->delete_category($oldcat);
75
        $transaction->allow_commit();
76
    }
77
 
78
    /**
79
     * Checks whether the category is a "Top" category (with no parent).
80
     *
81
     * @param int $categoryid a category id.
82
     * @return bool
83
     * @throws \dml_exception
84
     */
85
    public function is_top_category(int $categoryid): bool {
86
        global $DB;
87
        return 0 == $DB->get_field('question_categories', 'parent', ['id' => $categoryid]);
88
    }
89
 
90
    /**
91
     * Checks whether this is the only child of a top category in a context.
92
     *
93
     * @param int $categoryid a category id.
94
     * @return bool
95
     * @throws \dml_exception
96
     */
97
    public function is_only_child_of_top_category_in_context(int $categoryid): bool {
98
        global $DB;
99
        return 1 == $DB->count_records_sql("
100
            SELECT count(siblingcategory.id)
101
              FROM {question_categories} thiscategory
102
              JOIN {question_categories} parentcategory ON thiscategory.parent = parentcategory.id
103
              JOIN {question_categories} siblingcategory ON siblingcategory.parent = thiscategory.parent
104
             WHERE thiscategory.id = ? AND parentcategory.parent = 0", [$categoryid]);
105
    }
106
 
107
    /**
108
     * Ensures that this user is allowed to delete this category.
109
     *
110
     * @param int $todelete a category id.
111
     * @throws \required_capability_exception
112
     * @throws \dml_exception|moodle_exception
113
     */
114
    public function require_can_delete_category(int $todelete): void {
115
        global $DB;
116
        if ($this->is_top_category($todelete)) {
117
            throw new moodle_exception('cannotdeletetopcat', 'question');
118
        } else if ($this->is_only_child_of_top_category_in_context($todelete)) {
119
            throw new moodle_exception('cannotdeletecate', 'question');
120
        } else {
121
            $contextid = $DB->get_field('question_categories', 'contextid', ['id' => $todelete], MUST_EXIST);
122
            $this->require_manage_category(context::instance_by_id($contextid));
123
        }
124
    }
125
 
126
    /**
127
     * Move questions to another category.
128
     *
129
     * @param int $oldcat id of the old category.
130
     * @param int $newcat id of the new category.
131
     * @throws \dml_exception
132
     */
133
    public function move_questions(int $oldcat, int $newcat): void {
134
        $questionids = $this->get_real_question_ids_in_category($oldcat);
135
        question_move_questions_to_category($questionids, $newcat);
136
    }
137
 
138
    /**
139
     * Check the user can manage categories in the given context.
140
     *
141
     * This caches a successful check in $this->managedcontexts in case we check the same context multiple times.
142
     *
143
     * @param context $context
144
     * @return void
145
     * @throws \required_capability_exception
146
     */
147
    public function require_manage_category(context $context): void {
148
        if (!array_key_exists($context->id, $this->managedcontexts)) {
149
            require_capability('moodle/question:managecategory', $context);
150
            $this->managedcontexts[$context->id] = true;
151
        }
152
    }
153
 
154
    /**
155
     * Check there is no question category with the given ID number in the given context.
156
     *
157
     * @param ?string $idnumber The ID number to look for.
158
     * @param int $contextid The context to check the categories in.
159
     * @param ?int $excludecategoryid If set, exclude this category from the check (e.g. if this is the one being edited).
160
     * @return bool
161
     * @throws \dml_exception
162
     */
163
    public function idnumber_is_unique_in_context(?string $idnumber, int $contextid, ?int $excludecategoryid = null): bool {
164
        global $DB;
165
        if (empty($idnumber)) {
166
            return true;
167
        }
168
        $where = 'idnumber = ? AND contextid = ?';
169
        $params = [$idnumber, $contextid];
170
        if ($excludecategoryid) {
171
            $where .= ' AND id != ?';
172
            $params[] = $excludecategoryid;
173
        }
174
        return !$DB->record_exists_select('question_categories', $where, $params);
175
    }
176
 
177
    /**
178
     * Create a new category.
179
     *
180
     * Data is expected to come from question_category_edit_form.
181
     *
182
     * By default redirects on success, unless $return is true.
183
     *
184
     * @param string $newparent 'categoryid,contextid' of the parent category.
185
     * @param string $newcategory the name.
186
     * @param string $newinfo the description.
187
     * @param string $newinfoformat description format. One of the FORMAT_ constants.
188
     * @param ?string $idnumber the idnumber. '' is converted to null.
189
     * @return int New category id.
190
     */
191
    public function add_category(
192
        string $newparent,
193
        string $newcategory,
194
        string $newinfo,
195
        string $newinfoformat = FORMAT_HTML,
196
        ?string $idnumber = null,
197
    ): int {
198
        global $DB;
199
        if (empty($newcategory)) {
200
            throw new moodle_exception('categorynamecantbeblank', 'question');
201
        }
202
        [$parentid, $contextid] = explode(',', $newparent);
203
        // ...moodle_form makes sure select element output is legal no need for further cleaning.
204
        $this->require_manage_category(context::instance_by_id($contextid));
205
 
206
        if ($parentid) {
207
            if (!($DB->get_field('question_categories', 'contextid', ['id' => $parentid]) == $contextid)) {
208
                throw new moodle_exception(
209
                    'cannotinsertquestioncatecontext',
210
                    'question',
211
                    '',
212
                    ['cat' => $newcategory, 'ctx' => $contextid],
213
                );
214
            }
215
        }
216
 
217
        if (!$this->idnumber_is_unique_in_context($idnumber, $contextid)) {
218
            throw new moodle_exception('idnumbertaken', 'error');
219
        }
220
 
221
        if ((string)$idnumber === '') {
222
            $idnumber = null;
223
        }
224
 
225
        $transaction = $DB->start_delegated_transaction();
226
 
227
        $cat = new stdClass();
228
        $cat->parent = $parentid;
229
        $cat->contextid = $contextid;
230
        $cat->name = $newcategory;
231
        $cat->info = $newinfo;
232
        $cat->infoformat = $newinfoformat;
233
        $cat->sortorder = $this->get_max_sortorder($parentid) + 1;
234
        $cat->stamp = make_unique_id_code();
235
        $cat->idnumber = $idnumber;
236
        $categoryid = $DB->insert_record("question_categories", $cat);
237
 
238
        // Log the creation of this category.
239
        $category = new stdClass();
240
        $category->id = $categoryid;
241
        $category->contextid = $contextid;
242
        $event = \core\event\question_category_created::create_from_question_category_instance($category);
243
        $event->trigger();
244
        $transaction->allow_commit();
245
 
246
        return $categoryid;
247
    }
248
 
249
    /**
250
     * Updates an existing category with given params.
251
     *
252
     * Warning! parameter order and meaning confusingly different from add_category in some ways!
253
     *
254
     * @param int $updateid id of the category to update.
255
     * @param string $newparent 'categoryid,contextid' of the parent category to set.
256
     * @param string $newname category name.
257
     * @param string $newinfo category description.
258
     * @param string $newinfoformat description format. One of the FORMAT_ constants.
259
     * @param ?string $idnumber the idnumber. '' is converted to null.
260
     * @param ?int $sortorder The updated sortorder. Not updated if null.
261
     */
262
    public function update_category(
263
        int $updateid,
264
        string $newparent,
265
        string $newname,
266
        string $newinfo,
267
        string $newinfoformat = FORMAT_HTML,
268
        ?string $idnumber = null,
269
        ?int $sortorder = null,
270
    ): void {
271
        global $DB;
272
        if (empty($newname)) {
273
            throw new moodle_exception('categorynamecantbeblank', 'question');
274
        }
275
 
276
        // Get the record we are updating.
277
        $oldcat = $DB->get_record('question_categories', ['id' => $updateid]);
278
        $lastcategoryinthiscontext = $this->is_only_child_of_top_category_in_context($updateid);
279
 
280
        if (!empty($newparent) && !$lastcategoryinthiscontext) {
281
            [$parentid, $tocontextid] = explode(',', $newparent);
282
        } else {
283
            $parentid = $oldcat->parent;
284
            $tocontextid = $oldcat->contextid;
285
        }
286
 
287
        // Check permissions.
288
        $fromcontext = context::instance_by_id($oldcat->contextid);
289
        $this->require_manage_category($fromcontext);
290
 
291
        // If moving to another context, check permissions some more, and confirm contextid,stamp uniqueness.
292
        $newstamprequired = false;
293
        if ($oldcat->contextid != $tocontextid) {
294
            $tocontext = context::instance_by_id($tocontextid);
295
            $this->require_manage_category($tocontext);
296
 
297
            // Confirm stamp uniqueness in the new context. If the stamp already exists, generate a new one.
298
            if ($DB->record_exists('question_categories', ['contextid' => $tocontextid, 'stamp' => $oldcat->stamp])) {
299
                $newstamprequired = true;
300
            }
301
        }
302
 
303
        if (!$this->idnumber_is_unique_in_context($idnumber, $tocontextid, $updateid)) {
304
            throw new moodle_exception('idnumbertaken', 'error');
305
        }
306
 
307
        if ((string)$idnumber === '') {
308
            $idnumber = null;
309
        }
310
 
311
        $transaction = $DB->start_delegated_transaction();
312
 
313
        // Update the category record.
314
        $cat = new stdClass();
315
        $cat->id = $updateid;
316
        $cat->name = $newname;
317
        $cat->info = $newinfo;
318
        $cat->infoformat = $newinfoformat;
319
        $cat->parent = $parentid;
320
        $cat->contextid = $tocontextid;
321
        $cat->idnumber = $idnumber;
322
        if ($newstamprequired) {
323
            $cat->stamp = make_unique_id_code();
324
        }
325
        if ($sortorder) {
326
            $cat->sortorder = $sortorder;
327
        }
328
        $DB->update_record('question_categories', $cat);
329
        // Update the set_reference records when moving a category to a different context.
330
        move_question_set_references($cat->id, $cat->id, $oldcat->contextid, $tocontextid);
331
 
332
        // Log the update of this category.
333
        $event = \core\event\question_category_updated::create_from_question_category_instance($cat);
334
        $event->trigger();
335
 
336
        if ($oldcat->contextid != $tocontextid) {
337
            // Moving to a new context. Must move files belonging to questions.
338
            question_move_category_to_context($cat->id, $oldcat->contextid, $tocontextid);
339
        }
340
        $transaction->allow_commit();
341
    }
342
 
343
    /**
344
     * Returns ids of the question in the given question category.
345
     *
346
     * This method only returns the real question. It does not include
347
     * subquestions of question types like multianswer.
348
     *
349
     * @param int $categoryid id of the category.
350
     * @return int[] array of question ids.
351
     */
352
    public function get_real_question_ids_in_category(int $categoryid): array {
353
        global $DB;
354
 
355
        $sql = "SELECT q.id
356
                  FROM {question} q
357
                  JOIN {question_versions} qv ON qv.questionid = q.id
358
                  JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
359
                 WHERE qbe.questioncategoryid = :categoryid
360
                   AND (q.parent = 0 OR q.parent = q.id)";
361
 
362
        $questionids = $DB->get_records_sql($sql, ['categoryid' => $categoryid]);
363
        return array_keys($questionids);
364
    }
365
 
366
    /**
367
     * Get current max sort in a given parent
368
     *
369
     * @param int $parentid The ID of the parent category.
370
     * @return int current max sort order
371
     */
372
    public function get_max_sortorder(int $parentid): int {
373
        global $DB;
374
        $sql = "SELECT MAX(sortorder)
375
                  FROM {question_categories}
376
                 WHERE parent = :parent";
377
        $lastmax = $DB->get_field_sql($sql, ['parent' => $parentid]);
378
        return $lastmax ?? 0;
379
    }
380
}