Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
// This file is part of Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
 
17
namespace qbank_managecategories;
18
 
19
use context;
20
use core_question\local\bank\question_version_status;
21
use moodle_exception;
22
use html_writer;
23
 
24
/**
25
 * Class helper contains all the library functions.
26
 *
27
 * Library functions used by qbank_managecategories.
28
 * This code is based on lib/questionlib.php by Martin Dougiamas.
29
 *
30
 * @package    qbank_managecategories
31
 * @copyright  2021 Catalyst IT Australia Pty Ltd
32
 * @author     Guillermo Gomez Arias <guillermogomez@catalyst-au.net>
33
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
34
 */
35
class helper {
36
 
37
    /**
38
     * Name of this plugin.
39
     */
40
    const PLUGINNAME = 'qbank_managecategories';
41
 
42
    /**
43
     * Remove stale questions from a category.
44
     *
45
     * While questions should not be left behind when they are not used any more,
46
     * it does happen, maybe via restore, or old logic, or uncovered scenarios. When
47
     * this happens, the users are unable to delete the question category unless
48
     * they move those stale questions to another one category, but to them the
49
     * category is empty as it does not contain anything. The purpose of this function
50
     * is to detect the questions that may have gone stale and remove them.
51
     *
52
     * You will typically use this prior to checking if the category contains questions.
53
     *
54
     * The stale questions (unused and hidden to the user) handled are:
55
     * - hidden questions
56
     * - random questions
57
     *
58
     * @param int $categoryid The category ID.
59
     * @throws \dml_exception
60
     */
61
    public static function question_remove_stale_questions_from_category(int $categoryid): void {
62
        global $DB;
63
 
64
        $sql = "SELECT q.id
65
                  FROM {question} q
66
                  JOIN {question_versions} qv ON qv.questionid = q.id
67
                  JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
68
                 WHERE qbe.questioncategoryid = :categoryid
69
                   AND (q.qtype = :qtype OR qv.status = :status)";
70
 
71
        $params = ['categoryid' => $categoryid, 'qtype' => 'random', 'status' => question_version_status::QUESTION_STATUS_HIDDEN];
72
        $questions = $DB->get_records_sql($sql, $params);
73
        foreach ($questions as $question) {
74
            // The function question_delete_question does not delete questions in use.
75
            question_delete_question($question->id);
76
        }
77
    }
78
 
79
    /**
80
     * Checks whether this is the only child of a top category in a context.
81
     *
82
     * @param int $categoryid a category id.
83
     * @return bool
84
     * @throws \dml_exception
85
     */
86
    public static function question_is_only_child_of_top_category_in_context(int $categoryid): bool {
87
        global $DB;
88
        return 1 == $DB->count_records_sql("
89
            SELECT count(*)
90
              FROM {question_categories} c
91
              JOIN {question_categories} p ON c.parent = p.id
92
              JOIN {question_categories} s ON s.parent = c.parent
93
             WHERE c.id = ? AND p.parent = 0", [$categoryid]);
94
    }
95
 
96
    /**
97
     * Checks whether the category is a "Top" category (with no parent).
98
     *
99
     * @param int $categoryid a category id.
100
     * @return bool
101
     * @throws \dml_exception
102
     */
103
    public static function question_is_top_category(int $categoryid): bool {
104
        global $DB;
105
        return 0 == $DB->get_field('question_categories', 'parent', ['id' => $categoryid]);
106
    }
107
 
108
    /**
109
     * Ensures that this user is allowed to delete this category.
110
     *
111
     * @param int $todelete a category id.
112
     * @throws \required_capability_exception
113
     * @throws \dml_exception|moodle_exception
114
     */
115
    public static function question_can_delete_cat(int $todelete): void {
116
        global $DB;
117
        if (self::question_is_top_category($todelete)) {
118
            throw new moodle_exception('cannotdeletetopcat', 'question');
119
        } else if (self::question_is_only_child_of_top_category_in_context($todelete)) {
120
            throw new moodle_exception('cannotdeletecate', 'question');
121
        } else {
122
            $contextid = $DB->get_field('question_categories', 'contextid', ['id' => $todelete]);
123
            require_capability('moodle/question:managecategory', context::instance_by_id($contextid));
124
        }
125
    }
126
 
127
    /**
128
     * Only for the use of add_indented_names().
129
     *
130
     * Recursively adds an indentedname field to each category, starting with the category
131
     * with id $id, and dealing with that category and all its children, and
132
     * return a new array, with those categories in the right order.
133
     *
134
     * @param array $categories an array of categories which has had childids
135
     *          fields added by flatten_category_tree(). Passed by reference for
136
     *          performance only. It is not modfied.
137
     * @param int $id the category to start the indenting process from.
138
     * @param int $depth the indent depth. Used in recursive calls.
139
     * @param int $nochildrenof
140
     * @return array a new array of categories, in the right order for the tree.
141
     */
142
    public static function flatten_category_tree(array &$categories, $id, int $depth = 0, int $nochildrenof = -1): array {
143
 
144
        // Indent the name of this category.
145
        $newcategories = [];
146
        $newcategories[$id] = $categories[$id];
147
        $newcategories[$id]->indentedname = str_repeat('&nbsp;&nbsp;&nbsp;', $depth) .
148
            $categories[$id]->name;
149
 
150
        // Recursively indent the children.
151
        foreach ($categories[$id]->childids as $childid) {
152
            if ($childid != $nochildrenof) {
153
                $newcategories = $newcategories + self::flatten_category_tree(
154
                        $categories, $childid, $depth + 1, $nochildrenof);
155
            }
156
        }
157
 
158
        // Remove the childids array that were temporarily added.
159
        unset($newcategories[$id]->childids);
160
 
161
        return $newcategories;
162
    }
163
 
164
    /**
165
     * Format categories into an indented list reflecting the tree structure.
166
     *
167
     * @param array $categories An array of category objects, for example from the.
168
     * @param int $nochildrenof
169
     * @return array The formatted list of categories.
170
     */
171
    public static function add_indented_names(array $categories, int $nochildrenof = -1): array {
172
 
173
        // Add an array to each category to hold the child category ids. This array
174
        // will be removed again by flatten_category_tree(). It should not be used
175
        // outside these two functions.
176
        foreach (array_keys($categories) as $id) {
177
            $categories[$id]->childids = [];
178
        }
179
 
180
        // Build the tree structure, and record which categories are top-level.
181
        // We have to be careful, because the categories array may include published
182
        // categories from other courses, but not their parents.
183
        $toplevelcategoryids = [];
184
        foreach (array_keys($categories) as $id) {
185
            if (!empty($categories[$id]->parent) &&
186
                array_key_exists($categories[$id]->parent, $categories)) {
187
                $categories[$categories[$id]->parent]->childids[] = $id;
188
            } else {
189
                $toplevelcategoryids[] = $id;
190
            }
191
        }
192
 
193
        // Flatten the tree to and add the indents.
194
        $newcategories = [];
195
        foreach ($toplevelcategoryids as $id) {
196
            $newcategories = $newcategories + self::flatten_category_tree(
197
                    $categories, $id, 0, $nochildrenof);
198
        }
199
 
200
        return $newcategories;
201
    }
202
 
203
    /**
204
     * Output a select menu of question categories.
205
     *
206
     * Categories from this course and (optionally) published categories from other courses
207
     * are included. Optionally, only categories the current user may edit can be included.
208
     *
209
     * @param array $contexts
210
     * @param bool $top
211
     * @param int $currentcat
212
     * @param string $selected optionally, the id of a category to be selected by
213
     *      default in the dropdown.
214
     * @param int $nochildrenof
215
     * @param bool $return to return the string of the select menu or echo that from the method
216
     * @throws \coding_exception|\dml_exception
217
     */
218
    public static function question_category_select_menu(array $contexts, bool $top = false, int $currentcat = 0,
219
                                           string $selected = "", int $nochildrenof = -1, bool $return = false) {
220
        $categoriesarray = self::question_category_options($contexts, $top, $currentcat,
221
            false, $nochildrenof, false);
222
        $choose = '';
223
        $options = [];
224
        foreach ($categoriesarray as $group => $opts) {
225
            $options[] = [$group => $opts];
226
        }
227
        $outputhtml = html_writer::label(get_string('questioncategory', 'core_question'),
228
            'id_movetocategory', false, ['class' => 'accesshide']);
229
        $attrs = [
230
            'id' => 'id_movetocategory',
231
            'class' => 'custom-select',
232
            'data-action' => 'toggle',
233
            'data-togglegroup' => 'qbank',
234
            'data-toggle' => 'action',
235
            'disabled' => false,
236
        ];
237
        $outputhtml .= html_writer::select($options, 'category', $selected, $choose, $attrs);
238
        if ($return) {
239
            return $outputhtml;
240
        } else {
241
            echo $outputhtml;
242
        }
243
    }
244
 
245
    /**
246
     * Get all the category objects, including a count of the number of questions in that category,
247
     * for all the categories in the lists $contexts.
248
     *
249
     * @param context $contexts
250
     * @param string $sortorder used as the ORDER BY clause in the select statement.
251
     * @param bool $top Whether to return the top categories or not.
252
     * @param int $showallversions 1 to show all versions not only the latest.
253
     * @return array of category objects.
254
     * @throws \dml_exception
255
     */
256
    public static function get_categories_for_contexts($contexts, string $sortorder = 'parent, sortorder, name ASC',
257
                                                       bool $top = false, int $showallversions = 0): array {
258
        global $DB;
259
        $topwhere = $top ? '' : 'AND c.parent <> 0';
260
        $statuscondition = "AND (qv.status = '". question_version_status::QUESTION_STATUS_READY . "' " .
261
            " OR qv.status = '" . question_version_status::QUESTION_STATUS_DRAFT . "' )";
262
 
263
        $sql = "SELECT c.*,
264
                    (SELECT COUNT(1)
265
                       FROM {question} q
266
                       JOIN {question_versions} qv ON qv.questionid = q.id
267
                       JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
268
                      WHERE q.parent = '0'
269
                        $statuscondition
270
                            AND c.id = qbe.questioncategoryid
271
                            AND ($showallversions = 1
272
                                OR (qv.version = (SELECT MAX(v.version)
273
                                                    FROM {question_versions} v
274
                                                    JOIN {question_bank_entries} be ON be.id = v.questionbankentryid
275
                                                   WHERE be.id = qbe.id)
276
                                   )
277
                                )
278
                            ) AS questioncount
279
                  FROM {question_categories} c
280
                 WHERE c.contextid IN ($contexts) $topwhere
281
              ORDER BY $sortorder";
282
 
283
        return $DB->get_records_sql($sql);
284
    }
285
 
286
    /**
287
     * Output an array of question categories.
288
     *
289
     * @param array $contexts The list of contexts.
290
     * @param bool $top Whether to return the top categories or not.
291
     * @param int $currentcat
292
     * @param bool $popupform
293
     * @param int $nochildrenof
294
     * @param bool $escapecontextnames Whether the returned name of the thing is to be HTML escaped or not.
295
     * @return array
296
     * @throws \coding_exception|\dml_exception
297
     */
298
    public static function question_category_options(array $contexts, bool $top = false, int $currentcat = 0,
299
                                                     bool $popupform = false, int $nochildrenof = -1,
300
                                                     bool $escapecontextnames = true): array {
301
        global $CFG;
302
        $pcontexts = [];
303
        foreach ($contexts as $context) {
304
            $pcontexts[] = $context->id;
305
        }
306
        $contextslist = join(', ', $pcontexts);
307
 
308
        $categories = self::get_categories_for_contexts($contextslist, 'parent, sortorder, name ASC', $top);
309
 
310
        if ($top) {
311
            $categories = self::question_fix_top_names($categories);
312
        }
313
 
314
        $categories = self::question_add_context_in_key($categories);
315
        $categories = self::add_indented_names($categories, $nochildrenof);
316
 
317
        // Sort cats out into different contexts.
318
        $categoriesarray = [];
319
        foreach ($pcontexts as $contextid) {
320
            $context = \context::instance_by_id($contextid);
321
            $contextstring = $context->get_context_name(true, true, $escapecontextnames);
322
            foreach ($categories as $category) {
323
                if ($category->contextid == $contextid) {
324
                    $cid = $category->id;
325
                    if ($currentcat != $cid || $currentcat == 0) {
326
                        $a = new \stdClass;
327
                        $a->name = format_string($category->indentedname, true,
328
                            ['context' => $context]);
329
                        if ($category->idnumber !== null && $category->idnumber !== '') {
330
                            $a->idnumber = s($category->idnumber);
331
                        }
332
                        if (!empty($category->questioncount)) {
333
                            $a->questioncount = $category->questioncount;
334
                        }
335
                        if (isset($a->idnumber) && isset($a->questioncount)) {
336
                            $formattedname = get_string('categorynamewithidnumberandcount', 'question', $a);
337
                        } else if (isset($a->idnumber)) {
338
                            $formattedname = get_string('categorynamewithidnumber', 'question', $a);
339
                        } else if (isset($a->questioncount)) {
340
                            $formattedname = get_string('categorynamewithcount', 'question', $a);
341
                        } else {
342
                            $formattedname = $a->name;
343
                        }
344
                        $categoriesarray[$contextstring][$cid] = $formattedname;
345
                    }
346
                }
347
            }
348
        }
349
        if ($popupform) {
350
            $popupcats = [];
351
            foreach ($categoriesarray as $contextstring => $optgroup) {
352
                $group = [];
353
                foreach ($optgroup as $key => $value) {
354
                    $key = str_replace($CFG->wwwroot, '', $key);
355
                    $group[$key] = $value;
356
                }
357
                $popupcats[] = [$contextstring => $group];
358
            }
359
            return $popupcats;
360
        } else {
361
            return $categoriesarray;
362
        }
363
    }
364
 
365
    /**
366
     * Add context in categories key.
367
     *
368
     * @param array $categories The list of categories.
369
     * @return array
370
     */
371
    public static function question_add_context_in_key(array $categories): array {
372
        $newcatarray = [];
373
        foreach ($categories as $id => $category) {
374
            $category->parent = "$category->parent,$category->contextid";
375
            $category->id = "$category->id,$category->contextid";
376
            $newcatarray["$id,$category->contextid"] = $category;
377
        }
378
        return $newcatarray;
379
    }
380
 
381
    /**
382
     * Finds top categories in the given categories hierarchy and replace their name with a proper localised string.
383
     *
384
     * @param array $categories An array of question categories.
385
     * @param bool $escape Whether the returned name of the thing is to be HTML escaped or not.
386
     * @return array The same question category list given to the function, with the top category names being translated.
387
     * @throws \coding_exception
388
     */
389
    public static function question_fix_top_names(array $categories, bool $escape = true): array {
390
 
391
        foreach ($categories as $id => $category) {
392
            if ($category->parent == 0) {
393
                $context = \context::instance_by_id($category->contextid);
394
                $categories[$id]->name = get_string('topfor', 'question', $context->get_context_name(false, false, $escape));
395
            }
396
        }
397
 
398
        return $categories;
399
    }
400
}