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\local\bank;
18
 
19
use cm_info;
20
use context;
21
use context_course;
22
use core\task\manager;
23
use moodle_url;
24
use stdClass;
25
 
26
defined('MOODLE_INTERNAL') || die();
27
 
28
require_once($CFG->dirroot . '/lib/questionlib.php');
29
require_once($CFG->dirroot . '/course/modlib.php');
30
 
31
/**
32
 * Helper class for qbank sharing.
33
 *
34
 * @package    core_question
35
 * @copyright  2024 onwards Catalyst IT EU {@link https://catalyst-eu.net}
36
 * @author     Simon Adams <simon.adams@catalyst-eu.net>
37
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
38
 */
39
class question_bank_helper {
40
    /** @var string the type of qbank module that users create */
41
    public const TYPE_STANDARD = 'standard';
42
 
43
    /**
44
     * The type of shared bank module that the system creates.
45
     * These are created in course restores when no target context can be found,
46
     * and also for when a question category cannot be deleted safely due to questions being in use.
47
     *
48
     * @var string
49
     */
50
    public const TYPE_SYSTEM = 'system';
51
 
52
    /** @var string The type of shared bank module that the system creates for previews. Not used for any other purpose. */
53
    public const TYPE_PREVIEW = 'preview';
54
 
55
    /** @var array Shared bank types */
56
    public const SHARED_TYPES = [self::TYPE_STANDARD, self::TYPE_SYSTEM, self::TYPE_PREVIEW];
57
 
58
    /**
59
     * User preferences record key to store recently viewed question banks.
60
     */
61
    protected const RECENTLY_VIEWED = 'recently_viewed_open_banks';
62
 
63
    /**
64
     * Category delimiter used by the SQL to group concatenate question category data i.e.
65
     * category_id<->category_name<->context_id.
66
     */
67
    private const CATEGORY_DELIMITER = '<->';
68
 
69
    /**
70
     * Category separator used by the SQL for group concatenation of those category triplets
71
     * from above.
72
     */
73
    private const CATEGORY_SEPARATOR = '<,>';
74
 
75
    /**
76
     * Maximum length for the question bank name database field.
77
     */
78
    public const BANK_NAME_MAX_LENGTH = 255;
79
 
80
    /**
81
     * Modules that share questions via FEATURE_PUBLISHES_QUESTIONS.
82
     *
83
     * @return array
84
     */
85
    public static function get_activity_types_with_shareable_questions(): array {
86
        static $sharedmods;
87
 
88
        if (!empty($sharedmods)) {
89
            return $sharedmods;
90
        }
91
 
92
        $manager = \core_plugin_manager::instance();
93
        $plugins = $manager->get_enabled_plugins('mod');
94
 
95
        $sharedmods = array_filter(
96
            array_keys($plugins),
97
            static fn ($plugin) => plugin_supports('mod', $plugin, FEATURE_PUBLISHES_QUESTIONS) &&
98
                question_module_uses_questions($plugin)
99
        );
100
 
101
        return array_values($sharedmods);
102
    }
103
 
104
    /**
105
     * Get module types that do not share questions. They will have FEATURE_USES_QUESTIONS set to false or won't have it defined.
106
     *
107
     * @return array
108
     */
109
    public static function get_activity_types_with_private_questions(): array {
110
        static $privatemods;
111
 
112
        if (!empty($privatemods)) {
113
            return $privatemods;
114
        }
115
 
116
        $manager = \core_plugin_manager::instance();
117
        $plugins = $manager->get_enabled_plugins('mod');
118
 
119
        $privatemods = array_filter(
120
            array_keys($plugins),
121
            static fn ($plugin) => !plugin_supports('mod', $plugin, FEATURE_PUBLISHES_QUESTIONS) &&
122
                question_module_uses_questions($plugin)
123
        );
124
 
125
        return array_values($privatemods);
126
    }
127
 
128
    /**
129
     * Get records for activity modules that do publish questions, and optionally get their question categories too.
130
     *
131
     * @param array $incourseids array of course ids where you want instances included. Leave empty if you want from all courses.
132
     * @param array $notincourseids array of course ids where you do not want instances included.
133
     * @param array $havingcap current user must have at least one of these capabilities on each bank context.
134
     * @param bool $getcategories optionally return the categories belonging to these banks.
135
     * @param int $currentbankid optionally include the bank id you want included as the first result from the method return.
136
     * it will only be included if the other parameters allow it.
137
     * @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with
138
     *      parameters that will get banks across multiple contexts.
139
     * @param string $search Optional term to search question bank instances by name
140
     * @param int $limit The number of results to return (default 0 = no limit)
141
     * @return stdClass[]
142
     */
143
    public static function get_activity_instances_with_shareable_questions(
144
        array $incourseids = [],
145
        array $notincourseids = [],
146
        array $havingcap = [],
147
        bool $getcategories = false,
148
        int $currentbankid = 0,
149
        ?context $filtercontext = null,
150
        string $search = '',
151
        int $limit = 0,
152
    ): array {
153
        return self::get_bank_instances(true,
154
            $incourseids,
155
            $notincourseids,
156
            $getcategories,
157
            $currentbankid,
158
            $havingcap,
159
            $filtercontext,
160
            $search,
161
            $limit,
162
        );
163
    }
164
 
165
    /**
166
     * Get records for activity modules that don't publish questions, and optionally get their question categories too.
167
     *
168
     * @param array $incourseids array of course ids where you want instances included. Leave empty if you want from all courses.
169
     * @param array $notincourseids array of course ids where you do not want instances included.
170
     * @param array $havingcap current user must have at least one of these capabilities on each bank context.
171
     * @param bool $getcategories optionally return the categories belonging to these banks.
172
     * @param int $currentbankid optionally include the bank id you want included as the first result from the method return.
173
     * it will only be included if the other parameters allow it.
174
     * @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with
175
     *       parameters that will get banks across multiple contexts.
176
     * @return stdClass[]
177
     */
178
    public static function get_activity_instances_with_private_questions(
179
        array $incourseids = [],
180
        array $notincourseids = [],
181
        array $havingcap = [],
182
        bool $getcategories = false,
183
        int $currentbankid = 0,
184
        ?context $filtercontext = null,
185
    ): array {
186
        return self::get_bank_instances(false,
187
            $incourseids,
188
            $notincourseids,
189
            $getcategories,
190
            $currentbankid,
191
            $havingcap,
192
            $filtercontext,
193
        );
194
    }
195
 
196
    /**
197
     * Private method to build the SQL and get records from the DB. Called from public API methods
198
     * {@see self::get_activity_instances_with_shareable_questions()}
199
     * {@see self::get_activity_instances_with_private_questions()}
200
     *
201
     * @param bool $isshared true if you want instances that publish questions false if you want instances that don't
202
     * @param array $incourseids array of course ids where you want instances included. Leave empty if you want from all courses.
203
     * @param array $notincourseids array of course ids where you do not want instances included.
204
     * @param bool $getcategories optionally return the categories belonging to these banks.
205
     * @param int $currentbankid optionally include the bank id you want included as the first result from the method return.
206
     *  it will only be included if the other parameters allow it.
207
     * @param array $havingcap current user must have at least one of these capabilities on each bank context.
208
     * @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with
209
     *     parameters that will get banks across multiple contexts.
210
     * @param string $search Optional term to search question bank instances by name
211
     * @param int $limit The number of results to return (default 0 = no limit)
212
     * @return stdClass[]
213
     */
214
    private static function get_bank_instances(
215
        bool $isshared,
216
        array $incourseids = [],
217
        array $notincourseids = [],
218
        bool $getcategories = false,
219
        int $currentbankid = 0,
220
        array $havingcap = [],
221
        ?context $filtercontext = null,
222
        string $search = '',
223
        int $limit = 0,
224
    ): array {
225
        global $DB;
226
 
227
        $pluginssql = [];
228
        $params = [];
229
 
230
        // Build the SELECT portion of the SQL and include question category joins as required.
231
        if ($getcategories) {
232
            $concat = $DB->sql_concat('qc.id',
233
                "'" . self::CATEGORY_DELIMITER . "'",
234
                'qc.name',
235
                "'" . self::CATEGORY_DELIMITER . "'",
236
                'qc.contextid'
237
            );
238
            $groupconcat = $DB->sql_group_concat($concat, self::CATEGORY_SEPARATOR);
239
            $select = "SELECT cm.id, cm.course, {$groupconcat} AS cats";
240
            $catsql = ' JOIN {context} c ON c.instanceid = cm.id AND c.contextlevel = ' . CONTEXT_MODULE .
241
                ' JOIN {question_categories} qc ON qc.contextid = c.id AND qc.parent <> 0';
242
        } else {
243
            $select = 'SELECT cm.id, cm.course';
244
            $catsql = '';
245
        }
246
 
247
        if ($isshared) {
248
            $plugins = self::get_activity_types_with_shareable_questions();
249
        } else {
250
            $plugins = self::get_activity_types_with_private_questions();
251
        }
252
 
253
        if (empty($plugins)) {
254
            return [];
255
        }
256
 
257
        // Build the joins for all modules of the type requested i.e. those that do or do not share questions.
258
        foreach ($plugins as $key => $plugin) {
259
            $moduleid = $DB->get_field('modules', 'id', ['name' => $plugin]);
260
            $sql = "JOIN {{$plugin}} p{$key} ON p{$key}.id = cm.instance
261
                    AND cm.module = {$moduleid} AND cm.deletioninprogress = 0";
262
            if ($plugin === self::get_default_question_bank_activity_name()) {
263
                $sql .= " AND p{$key}.type <> '" . self::TYPE_PREVIEW . "'";
264
            }
265
            if (!empty($search)) {
266
                $sql .= " AND " . $DB->sql_like("p{$key}.name", ":search{$key}", false);
267
                $params["search{$key}"] = "%{$search}%";
268
            }
269
            $pluginssql[] = $sql;
270
        }
271
        $pluginssql = implode(' ', $pluginssql);
272
 
273
        // Build the SQL to filter out any requested course ids.
274
        if (!empty($notincourseids)) {
275
            [$notincoursesql, $notincourseparams] = $DB->get_in_or_equal($notincourseids, SQL_PARAMS_NAMED, 'param', false);
276
            $notincoursesql = "AND cm.course {$notincoursesql}";
277
            $params = array_merge($params, $notincourseparams);
278
        } else {
279
            $notincoursesql = '';
280
        }
281
 
282
        // Build the SQL to include ONLY records belonging to the requested courses.
283
        if (!empty($incourseids)) {
284
            [$incoursesql, $incourseparams] = $DB->get_in_or_equal($incourseids, SQL_PARAMS_NAMED);
285
            $incoursesql = " AND cm.course {$incoursesql}";
286
            $params = array_merge($params, $incourseparams);
287
        } else {
288
            $incoursesql = '';
289
        }
290
 
291
        // Optionally order the results by the requested bank id.
292
        if (!empty($currentbankid)) {
293
            $orderbysql = " ORDER BY CASE WHEN cm.id = :currentbankid THEN 0 ELSE 1 END ASC, cm.id DESC ";
294
            $params['currentbankid'] = $currentbankid;
295
        } else {
296
            $orderbysql = '';
297
        }
298
 
299
        $sql = "{$select}
300
                FROM {course_modules} cm
301
                JOIN {modules} m ON m.id = cm.module
302
                {$pluginssql}
303
                {$catsql}
304
                WHERE 1=1 {$notincoursesql} {$incoursesql}
305
                GROUP BY cm.id, cm.course
306
                {$orderbysql}";
307
 
308
        $rs = $DB->get_recordset_sql($sql, $params, limitnum: $limit);
309
        $banks = [];
310
 
311
        foreach ($rs as $cm) {
312
            // If capabilities have been supplied as a method argument then ensure the viewing user has at least one of those
313
            // capabilities on the module itself.
314
            if (!empty($havingcap)) {
315
                $context = \context_module::instance($cm->id);
316
                if (!(new question_edit_contexts($context))->have_one_cap($havingcap)) {
317
                    continue;
318
                }
319
            }
320
            // Populate the raw record.
321
            $banks[] = self::get_formatted_bank($cm, $currentbankid, filtercontext: $filtercontext);
322
        }
323
        $rs->close();
324
 
325
        return $banks;
326
    }
327
 
328
    /**
329
     * Get a list of recently viewed question banks that implement FEATURE_PUBLISHES_QUESTIONS.
330
     * If any of the stored contexts don't exist anymore then update the user preference record accordingly.
331
     *
332
     * @param int $userid of the user to get recently viewed banks for.
333
     * @param int $notincourseid if supplied don't return any in this course id
334
     * @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with
335
     *       parameters that will get banks across multiple contexts.
336
     * @return cm_info[]
337
     */
338
    public static function get_recently_used_open_banks(
339
        int $userid,
340
        int $notincourseid = 0,
341
        ?context $filtercontext = null,
342
        array $havingcap = [],
343
    ): array {
344
        $prefs = get_user_preferences(self::RECENTLY_VIEWED, null, $userid);
345
        $contextids = !empty($prefs) ? explode(',', $prefs) : [];
346
        if (empty($contextids)) {
347
            return $contextids;
348
        }
349
        $invalidcontexts = [];
350
        $banks = [];
351
 
352
        foreach ($contextids as $contextid) {
353
            if (!$context = context::instance_by_id($contextid, IGNORE_MISSING)) {
354
                $invalidcontexts[] = $context;
355
                continue;
356
            }
357
            if ($context->contextlevel !== CONTEXT_MODULE) {
358
                throw new \moodle_exception('Invalid question bank contextlevel: ' . $context->contextlevel);
359
            }
360
            [, $cm] = get_module_from_cmid($context->instanceid);
361
            if (!empty($notincourseid) && $notincourseid == $cm->course) {
362
                continue;
363
            }
364
            if (!empty($havingcap) && !(new question_edit_contexts($context))->have_one_cap($havingcap)) {
365
                continue;
366
            }
367
            $record = self::get_formatted_bank($cm, filtercontext: $filtercontext);
368
            $banks[] = $record;
369
        }
370
 
371
        if (!empty($invalidcontexts)) {
372
            $tostore = array_diff($contextids, $invalidcontexts);
373
            $tostore = implode(',', $tostore);
374
            set_user_preference(self::RECENTLY_VIEWED, $tostore, $userid);
375
        }
376
 
377
        return $banks;
378
    }
379
 
380
    /**
381
     * Mark a user as having viewed a question bank in the user_preferences table with key {@see self::RECENTLY_VIEWED}
382
     *
383
     * @param context $bankcontext add this bank context to the viewing user's list of recently viewed.
384
     * @return void
385
     */
386
    public static function add_bank_context_to_recently_viewed(context $bankcontext): void {
387
 
388
        [, $cm] = get_module_from_cmid($bankcontext->instanceid);
389
 
390
        if (!plugin_supports('mod', $cm->modname, FEATURE_PUBLISHES_QUESTIONS)) {
391
            return;
392
        }
393
 
394
        $userprefs = get_user_preferences(self::RECENTLY_VIEWED);
395
        $recentlyviewed = !empty($userprefs) ? explode(',', $userprefs) : [];
396
        $recentlyviewed = array_combine($recentlyviewed, $recentlyviewed);
397
        $tostore = [];
398
        $tostore[] = $bankcontext->id;
399
        if (!empty($recentlyviewed[$bankcontext->id])) {
400
            unset($recentlyviewed[$bankcontext->id]);
401
        }
402
        $tostore = array_merge($tostore, array_values($recentlyviewed));
403
        $tostore = array_slice($tostore, 0, 5);
404
        set_user_preference(self::RECENTLY_VIEWED, implode(',', $tostore));
405
    }
406
 
407
    /**
408
     * Populate the raw record with data for use in rendering.
409
     *
410
     * @param stdClass $cm raw course_modules record to populate data from.
411
     * @param int $currentbankid set an 'enabled' flag on the instance that matched this id.
412
     *     Used in qbank_bulkmove/bulk_move.mustache
413
     * @param ?context $filtercontext Optional context in which to apply filters.
414
     *
415
     * @return stdClass
416
     */
417
    private static function get_formatted_bank(stdClass $cm, int $currentbankid = 0, ?context $filtercontext = null): stdClass {
418
 
419
        $cminfo = cm_info::create($cm);
420
        $concatedcats = !empty($cm->cats) ? explode(self::CATEGORY_SEPARATOR, $cm->cats) : [];
421
        $categories = array_map(static function($concatedcategory) use ($cminfo, $currentbankid) {
422
            $values = explode(self::CATEGORY_DELIMITER, $concatedcategory);
423
            $cat = new stdClass();
424
            $cat->id = $values[0];
425
            $cat->name = $values[1];
426
            $cat->contextid = $values[2];
427
            $cat->enabled = $cminfo->id == $currentbankid ? 'enabled' : 'disabled';
428
            return $cat;
429
        }, $concatedcats);
430
 
431
        $bank = new stdClass();
432
        $filteroptions = ['escape' => false];
433
        if (!is_null($filtercontext)) {
434
            $filteroptions['context'] = $filtercontext;
435
        }
436
        $bank->name = $cminfo->get_formatted_name($filteroptions);
437
        $bank->modid = $cminfo->id;
438
        $bank->contextid = $cminfo->context->id;
439
        if (!isset($filteroptions['context'])) {
440
            $filteroptions['context'] = context_course::instance($cminfo->get_course()->id);
441
        }
442
        $bank->coursenamebankname = format_string($cminfo->get_course()->shortname, true, $filteroptions) . " - {$bank->name}";
443
        $bank->cminfo = $cminfo;
444
        $bank->questioncategories = $categories;
445
        return $bank;
446
    }
447
 
448
    /**
449
     * Get the system type qbank instance for this course, optionally create it if it does not yet exist.
450
     * {@see self::TYPE_SYSTEM}
451
     *
452
     * @param stdClass $course the course to get the default system type bank for.
453
     * @param bool $createifnotexists create a default bank if it does not exist.
454
     * @return cm_info|null
455
     */
456
    public static function get_default_open_instance_system_type(stdClass $course, bool $createifnotexists = false): ?cm_info {
457
 
458
        $modinfo = get_fast_modinfo($course);
459
        $qbanks = $modinfo->get_instances_of(self::get_default_question_bank_activity_name());
460
        $systembank = null;
461
 
462
        if ($systembankids = self::get_qbank_ids_of_type_in_course($course, self::TYPE_SYSTEM)) {
463
            // We should only ever have 1 of these.
464
            $systembankid = reset($systembankids);
465
            // Filter the course modinfo qbanks by the systembankid.
466
            $systembanks = array_filter($qbanks, static fn($bank) => $bank->id === $systembankid);
467
            $systembank = !empty($systembanks) ? reset($systembanks) : null;
468
        }
469
 
470
        if (!$systembank && $createifnotexists) {
471
            $systembank = self::create_default_open_instance(
472
                $course,
473
                self::get_bank_name_string('systembank', 'question'),
474
                self::TYPE_SYSTEM,
475
            );
476
        }
477
 
478
        return $systembank;
479
    }
480
 
481
    /**
482
     * Get the bank that is used for preview purposes only, optionally create it if it does not yet exist.
483
     * {@see \qbank_columnsortorder\column_manager::get_questionbank()}
484
     *
485
     * @param bool $createifnotexists create a default bank if it does not exist.
486
     * @return cm_info|null
487
     */
488
    public static function get_preview_open_instance_type(bool $createifnotexists = false): ?cm_info {
489
 
490
        $site = get_site();
491
        $modinfo = get_fast_modinfo($site);
492
        $qbanks = $modinfo->get_instances_of(self::get_default_question_bank_activity_name());
493
        $previewbank = null;
494
 
495
        if ($previewbankids = self::get_qbank_ids_of_type_in_course($site, self::TYPE_PREVIEW)) {
496
            // We should only ever have 1 of these.
497
            $previewbankid = reset($previewbankids);
498
            // Filter the course modinfo qbanks by the previewbankid.
499
            $previewbanks = array_filter($qbanks, static fn($bank) => $bank->id === $previewbankid);
500
            $previewbank = !empty($previewbanks) ? reset($previewbanks) : null;
501
        }
502
 
503
        if (!$previewbank && $createifnotexists) {
504
            $previewbank = self::create_default_open_instance(
505
                $site,
506
                self::get_bank_name_string('previewbank', 'question'),
507
                self::TYPE_PREVIEW
508
            );
509
        }
510
 
511
        return $previewbank;
512
    }
513
 
514
    /**
515
     * Get course module ids from qbank instances on a course that are of the sub-type provided.
516
     *
517
     * @param stdClass $course the course to search
518
     * @param string $subtype the subtype of the qbank module {@see self::SHARED_TYPES}
519
     * @return int[]
520
     */
521
    private static function get_qbank_ids_of_type_in_course(stdClass $course, string $subtype): array {
522
        global $DB;
523
 
524
        if (!in_array($subtype, self::SHARED_TYPES)) {
525
            throw new \moodle_exception('Invalid question bank type: ' . $subtype);
526
        }
527
 
528
        $modinfo = get_fast_modinfo($course);
529
        $defaultyactivityname = self::get_default_question_bank_activity_name();
530
        $qbanks = $modinfo->get_instances_of($defaultyactivityname);
531
 
532
        $whereclause = "AND m.name = '" . $defaultyactivityname . "'";
533
 
534
        if (!empty($qbanks)) {
535
            $sql = "SELECT cm.id
536
                      FROM {course_modules} cm
537
                      JOIN {modules} m ON m.id = cm.module
538
                      JOIN {{$defaultyactivityname}} q ON q.id = cm.instance
539
                     WHERE cm.course = :course
540
                       AND q.type = :type " .
541
            $whereclause;
542
 
543
            return $DB->get_fieldset_sql($sql, ['type' => $subtype, 'course' => $course->id]);
544
        }
545
 
546
        return [];
547
    }
548
 
549
    /**
550
     * Create a bank on the course from default options.
551
     *
552
     * @param stdClass $course the course that the new module is being created in
553
     * @param string $bankname name of the new module
554
     * @param string $type {@see self::TYPES}
555
     * @return cm_info
556
     */
557
    public static function create_default_open_instance(
558
        stdClass $course,
559
        string $bankname,
560
        string $type = self::TYPE_STANDARD
561
    ): cm_info {
562
        global $DB;
563
 
564
        if (!in_array($type, self::SHARED_TYPES)) {
565
            throw new \RuntimeException('invalid type');
566
        }
567
 
568
        // Preview bank must be created at site course.
569
        if ($type === self::TYPE_PREVIEW) {
570
            if ($qbank = self::get_preview_open_instance_type()) {
571
                return $qbank;
572
            }
573
            $course = get_site();
574
        }
575
 
576
        // We can only have one of these types per course.
577
        if ($type === self::TYPE_SYSTEM && $qbank = self::get_default_open_instance_system_type($course)) {
578
            return $qbank;
579
        }
580
 
581
        $module = $DB->get_record('modules', ['name' => self::get_default_question_bank_activity_name()], '*', MUST_EXIST);
582
        $context = context_course::instance($course->id);
583
 
584
        // STANDARD type needs capability checks.
585
        if ($type === self::TYPE_STANDARD) {
586
            require_capability('moodle/course:manageactivities', $context);
587
            if (!course_allowed_module($course, $module->name)) {
588
                throw new \moodle_exception('moduledisable');
589
            }
590
        }
591
 
592
        if (\core_text::strlen($bankname) > self::BANK_NAME_MAX_LENGTH) {
593
            throw new \coding_exception(
594
                'The provided bankname is too long for the database field.',
595
                'Use question_bank_helper::get_bank_name_string to get a suitably truncated name.',
596
            );
597
        }
598
 
599
        $data = new stdClass();
600
        $data->section = 0;
601
        $data->visible = 0;
602
        $data->course = $course->id;
603
        $data->module = $module->id;
604
        $data->modulename = $module->name;
605
        $data->groupmode = $course->groupmode;
606
        $data->groupingid = $course->defaultgroupingid;
607
        $data->id = '';
608
        $data->instance = '';
609
        $data->coursemodule = '';
610
        $data->downloadcontent = DOWNLOAD_COURSE_CONTENT_ENABLED;
611
        $data->visibleoncoursepage = 0;
612
        $data->name = $bankname;
613
        $data->type = in_array($type, self::SHARED_TYPES) ? $type : self::TYPE_STANDARD;
614
        $data->showdescription = $type === self::TYPE_STANDARD ? 0 : 1;
615
        // Don't create the default category if this is being created by the system as part of a migration or restore,
616
        // existing categories will be migrated to the new context.
617
        $data->skipdefaultcategory = $type === self::TYPE_SYSTEM;
618
 
619
        $mod = add_moduleinfo($data, $course);
620
 
621
        // Have to set this manually as the system because this bank type is not intended to be created directly by a user.
622
        if ($type === self::TYPE_SYSTEM) {
623
            $DB->set_field($module->name, 'intro', get_string('systembankdescription', 'question'), ['id' => $mod->instance]);
624
            $DB->set_field($module->name, 'introformat', FORMAT_HTML, ['id' => $mod->instance]);
625
        }
626
 
627
        return get_fast_modinfo($course)->get_cm($mod->coursemodule);
628
    }
629
 
630
    /**
631
     * Get the url that shows the banks list of a course.
632
     *
633
     * @param int $courseid of the course to get the url for.
634
     * @param bool $createdefault Pass true if you want the URL to create a default qbank instance when referred.
635
     * @return moodle_url
636
     */
637
    public static function get_url_for_qbank_list(int $courseid, bool $createdefault = false): moodle_url {
638
        $url = new moodle_url('/question/banks.php', ['courseid' => $courseid]);
639
        if ($createdefault) {
640
            $url->param('createdefault', true);
641
        }
642
        return $url;
643
    }
644
 
645
    /**
646
     * This task should only ever be called once, on install/upgrade. But we may need to warn the user on some pages
647
     * that some banks may not have been transferred yet if it failed or hasn't yet completed.
648
     *
649
     * @return bool
650
     */
651
    public static function has_bank_migration_task_completed_successfully(): bool {
652
        $defaultbank = self::get_default_question_bank_activity_name();
653
        $task = manager::get_adhoc_tasks("\\mod_{$defaultbank}\\task\\transfer_question_categories");
654
        $subtasks = manager::get_adhoc_tasks("\\mod_{$defaultbank}\\task\\transfer_questions");
655
        return empty($task) && empty($subtasks);
656
    }
657
 
658
    /**
659
     * Get the activity plugin name that will be the type used for default bank creation and management.
660
     *
661
     * @return string
662
     */
663
    public static function get_default_question_bank_activity_name(): string {
664
        global $CFG;
665
        return $CFG->corequestion_defaultqbankmod ?? 'qbank';
666
    }
667
 
668
    /**
669
     * Get the requested language string, with parameters truncated to ensure the result fits in the database.
670
     *
671
     * Since we may be generating a question bank name based on an existing course or category name, we need to ensure
672
     * that the resulting string isn't longer than the maximum module name.
673
     *
674
     * @param string $identifier The string identifier
675
     * @param string $component The string component
676
     * @param mixed|null $params The string parameters (a single string, array or object as accepted by get_string)
677
     * @return string The string truncated to a length that will fit in the database.
678
     */
679
    public static function get_bank_name_string(string $identifier, string $component, mixed $params = null): string {
680
        if (is_object($params)) {
681
            $shortparams = (array) $params;
682
        } else {
683
            $shortparams = $params;
684
        }
685
        $bankname = get_string($identifier, $component, $shortparams);
686
        if (!is_null($shortparams)) {
687
            $trimlength = self::BANK_NAME_MAX_LENGTH - 4;
688
            while (\core_text::strlen($bankname) > self::BANK_NAME_MAX_LENGTH && $trimlength > 0) {
689
                // Gradually shorten the string parameters until the resulting string is short enough.
690
                if (is_array($shortparams)) {
691
                    $shortparams = array_map(fn($param) => shorten_text(trim($param), $trimlength), $shortparams);
692
                } else {
693
                    $shortparams = shorten_text(trim($shortparams), $trimlength);
694
                }
695
                $bankname = get_string($identifier, $component, $shortparams);
696
                $trimlength -= 10;
697
            }
698
        }
699
        // As a failsafe, limit the length of the final string in case the lang string is too long.
700
        return shorten_text($bankname, self::BANK_NAME_MAX_LENGTH);
701
    }
702
}