Proyectos de Subversion Moodle

Rev

Rev 11 | | Comparar con el anterior | 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
/**
18
 * Code for handling and processing questions.
19
 *
20
 * This is code that is module independent, i.e., can be used by any module that
21
 * uses questions, like quiz, lesson, etc.
22
 * This script also loads the questiontype classes.
23
 * Code for handling the editing of questions is in question/editlib.php
24
 *
25
 * @package    core
26
 * @subpackage questionbank
27
 * @copyright  1999 onwards Martin Dougiamas and others {@link http://moodle.com}
28
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
29
 */
30
 
31
use core_question\local\bank\question_version_status;
32
use core_question\question_reference_manager;
33
 
34
defined('MOODLE_INTERNAL') || die();
35
 
36
require_once($CFG->dirroot . '/question/engine/lib.php');
37
require_once($CFG->dirroot . '/question/type/questiontypebase.php');
38
 
39
 
40
 
41
// CONSTANTS.
42
 
43
/**
44
 * Constant determines the number of answer boxes supplied in the editing
45
 * form for multiple choice and similar question types.
46
 */
47
define("QUESTION_NUMANS", 10);
48
 
49
/**
50
 * Constant determines the number of answer boxes supplied in the editing
51
 * form for multiple choice and similar question types to start with, with
52
 * the option of adding QUESTION_NUMANS_ADD more answers.
53
 */
54
define("QUESTION_NUMANS_START", 3);
55
 
56
/**
57
 * Constant determines the number of answer boxes to add in the editing
58
 * form for multiple choice and similar question types when the user presses
59
 * 'add form fields button'.
60
 */
61
define("QUESTION_NUMANS_ADD", 3);
62
 
63
/**
64
 * Move one question type in a list of question types. If you try to move one element
65
 * off of the end, nothing will change.
66
 *
67
 * @param array $sortedqtypes An array $qtype => anything.
68
 * @param string $tomove one of the keys from $sortedqtypes
69
 * @param integer $direction +1 or -1
70
 * @return array an array $index => $qtype, with $index from 0 to n in order, and
71
 *      the $qtypes in the same order as $sortedqtypes, except that $tomove will
72
 *      have been moved one place.
73
 */
74
function question_reorder_qtypes($sortedqtypes, $tomove, $direction): array {
75
    $neworder = array_keys($sortedqtypes);
76
    // Find the element to move.
77
    $key = array_search($tomove, $neworder);
78
    if ($key === false) {
79
        return $neworder;
80
    }
81
    // Work out the other index.
82
    $otherkey = $key + $direction;
83
    if (!isset($neworder[$otherkey])) {
84
        return $neworder;
85
    }
86
    // Do the swap.
87
    $swap = $neworder[$otherkey];
88
    $neworder[$otherkey] = $neworder[$key];
89
    $neworder[$key] = $swap;
90
    return $neworder;
91
}
92
 
93
/**
94
 * Save a new question type order to the config_plugins table.
95
 *
96
 * @param array $neworder An arra $index => $qtype. Indices should start at 0 and be in order.
97
 * @param object $config get_config('question'), if you happen to have it around, to save one DB query.
98
 */
99
function question_save_qtype_order($neworder, $config = null): void {
100
    if (is_null($config)) {
101
        $config = get_config('question');
102
    }
103
 
104
    foreach ($neworder as $index => $qtype) {
105
        $sortvar = $qtype . '_sortorder';
106
        if (!isset($config->$sortvar) || $config->$sortvar != $index + 1) {
107
            set_config($sortvar, $index + 1, 'question');
108
        }
109
    }
110
}
111
 
112
// FUNCTIONS.
113
 
114
/**
115
 * Check if the question is used.
116
 *
117
 * @param array $questionids of question ids.
118
 * @return boolean whether any of these questions are being used by any part of Moodle.
119
 */
120
function questions_in_use($questionids): bool {
121
 
122
    // Are they used by the core question system?
123
    if (question_engine::questions_in_use($questionids)) {
124
        return true;
125
    }
126
 
127
    if (question_reference_manager::questions_with_references($questionids)) {
128
        return true;
129
    }
130
 
131
    // Check if any plugins are using these questions.
132
    $callbacksbytype = get_plugins_with_function('questions_in_use');
133
    foreach ($callbacksbytype as $callbacks) {
134
        foreach ($callbacks as $function) {
135
            if ($function($questionids)) {
136
                return true;
137
            }
138
        }
139
    }
140
 
141
    // Finally check legacy callback.
142
    $legacycallbacks = get_plugin_list_with_function('mod', 'question_list_instances');
143
    foreach ($legacycallbacks as $plugin => $function) {
144
        debugging($plugin . ' implements deprecated method ' . $function .
145
                '. ' . $plugin . '_questions_in_use should be implemented instead.', DEBUG_DEVELOPER);
146
 
147
        if (isset($callbacksbytype['mod'][substr($plugin, 4)])) {
148
            continue; // Already done.
149
        }
150
 
151
        foreach ($questionids as $questionid) {
152
            if (!empty($function($questionid))) {
153
                return true;
154
            }
155
        }
156
    }
157
 
158
    return false;
159
}
160
 
161
/**
162
 * Determine whether there are any questions belonging to this context, that is whether any of its
163
 * question categories contain any questions. This will return true even if all the questions are
164
 * hidden.
165
 *
166
 * @param mixed $context either a context object, or a context id.
167
 * @return boolean whether any of the question categories beloning to this context have
168
 *         any questions in them.
169
 */
170
function question_context_has_any_questions($context): bool {
171
    global $DB;
172
    if (is_object($context)) {
173
        $contextid = $context->id;
174
    } else if (is_numeric($context)) {
175
        $contextid = $context;
176
    } else {
177
        throw new moodle_exception('invalidcontextinhasanyquestions', 'question');
178
    }
179
    $sql = 'SELECT qbe.*
180
              FROM {question_bank_entries} qbe
181
              JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
182
             WHERE qc.contextid = ?';
183
    return $DB->record_exists_sql($sql, [$contextid]);
184
}
185
 
186
/**
187
 * Check whether a given grade is one of a list of allowed options. If not,
188
 * depending on $matchgrades, either return the nearest match, or return false
189
 * to signal an error.
190
 *
191
 * @param array $gradeoptionsfull list of valid options
192
 * @param int $grade grade to be tested
193
 * @param string $matchgrades 'error' or 'nearest'
194
 * @return false|int|string either 'fixed' value or false if error.
195
 */
196
function match_grade_options($gradeoptionsfull, $grade, $matchgrades = 'error') {
197
 
198
    if ($matchgrades == 'error') {
199
        // ...(Almost) exact match, or an error.
200
        foreach ($gradeoptionsfull as $value => $option) {
201
            // Slightly fuzzy test, never check floats for equality.
202
            if (abs($grade - $value) < 0.00001) {
203
                return $value; // Be sure the return the proper value.
204
            }
205
        }
206
        // Didn't find a match so that's an error.
207
        return false;
208
 
209
    } else if ($matchgrades == 'nearest') {
210
        // Work out nearest value.
211
        $best = false;
212
        $bestmismatch = 2;
213
        foreach ($gradeoptionsfull as $value => $option) {
214
            $newmismatch = abs($grade - $value);
215
            if ($newmismatch < $bestmismatch) {
216
                $best = $value;
217
                $bestmismatch = $newmismatch;
218
            }
219
        }
220
        return $best;
221
 
222
    } else {
223
        // Unknow option passed.
224
        throw new coding_exception('Unknown $matchgrades ' . $matchgrades .
225
                ' passed to match_grade_options');
226
    }
227
}
228
 
229
/**
230
 * Category is about to be deleted,
231
 * 1/ All questions are deleted for this question category.
232
 * 2/ Any questions that can't be deleted are moved to a new category
233
 * NOTE: this function is called from lib/db/upgrade.php
234
 *
235
 * @param object|core_course_category $category course category object
1441 ariadna 236
 * @param bool $coursedeletion Is the course this category is under being deleted? If so, move saved questions to the site course.
1 efrain 237
 */
1441 ariadna 238
function question_category_delete_safe($category, bool $coursedeletion = false): void {
1 efrain 239
    global $DB;
240
    $criteria = ['questioncategoryid' => $category->id];
241
    $context = context::instance_by_id($category->contextid, IGNORE_MISSING);
242
    $rescue = null; // See the code around the call to question_save_from_deletion.
243
 
244
    // Deal with any questions in the category.
245
    if ($questionentries = $DB->get_records('question_bank_entries', $criteria, '', 'id')) {
246
 
247
        foreach ($questionentries as $questionentry) {
248
            $questionids = $DB->get_records('question_versions',
249
                                                ['questionbankentryid' => $questionentry->id], '', 'questionid');
250
 
251
            // Try to delete each question.
252
            foreach ($questionids as $questionid) {
253
                question_delete_question($questionid->questionid, $category->contextid);
254
            }
255
        }
256
 
257
        // Check to see if there were any questions that were kept because
258
        // they are still in use somehow, even though quizzes in courses
259
        // in this category will already have been deleted. This could
260
        // happen, for example, if questions are added to a course,
261
        // and then that course is moved to another category (MDL-14802).
262
        $questionids = [];
263
        foreach ($questionentries as $questionentry) {
264
            $versions = $DB->get_records('question_versions', ['questionbankentryid' => $questionentry->id], '', 'questionid');
265
            foreach ($versions as $key => $version) {
266
                $questionids[$key] = $version;
267
            }
268
        }
269
        if (!empty($questionids)) {
270
            $name = get_string('unknown', 'question');
271
            if ($context !== false) {
272
                $name = $context->get_context_name();
1441 ariadna 273
                $parentcontext = $context->get_course_context(false);
274
                $course = ($parentcontext && !$coursedeletion) ? get_course($parentcontext->instanceid) : get_site();
1 efrain 275
            }
1441 ariadna 276
            $qbank = core_question\local\bank\question_bank_helper::get_default_open_instance_system_type($course, true);
277
            question_save_from_deletion(array_keys($questionids), $qbank->context->id, $name, $rescue);
1 efrain 278
        }
279
    }
280
 
281
    // Now delete the category.
282
    $DB->delete_records('question_categories', ['id' => $category->id]);
283
}
284
 
285
/**
286
 * Tests whether any question in a category is used by any part of Moodle.
287
 *
288
 * @param integer $categoryid a question category id.
289
 * @param boolean $recursive whether to check child categories too.
290
 * @return boolean whether any question in this category is in use.
291
 */
292
function question_category_in_use($categoryid, $recursive = false): bool {
293
    global $DB;
294
 
295
    // Look at each question in the category.
296
    $questionids = question_bank::get_finder()->get_questions_from_categories([$categoryid], null);
297
    if ($questionids) {
298
        if (questions_in_use(array_keys($questionids))) {
299
            return true;
300
        }
301
    }
302
    if (!$recursive) {
303
        return false;
304
    }
305
 
306
    // Look under child categories recursively.
307
    if ($children = $DB->get_records('question_categories',
308
            ['parent' => $categoryid], '', 'id, 1')) {
309
        foreach ($children as $child) {
310
            if (question_category_in_use($child->id, $recursive)) {
311
                return true;
312
            }
313
        }
314
    }
315
 
316
    return false;
317
}
318
 
319
/**
320
 * Check if there is more versions left for the entry.
321
 * If not delete the entry.
322
 *
323
 * @param int $entryid
324
 */
325
function delete_question_bank_entry($entryid): void {
326
    global $DB;
327
    if (!$DB->record_exists('question_versions', ['questionbankentryid' => $entryid])) {
328
        $DB->delete_records('question_bank_entries', ['id' => $entryid]);
329
    }
330
}
331
 
332
/**
333
 * Deletes question and all associated data from the database
334
 *
335
 * It will not delete a question if it is used somewhere, instead it will just delete the reference.
336
 *
337
 * @param int $questionid The id of the question being deleted
338
 */
339
function question_delete_question($questionid): void {
340
    global $DB;
341
 
342
    $question = $DB->get_record('question', ['id' => $questionid]);
343
    if (!$question) {
344
        // In some situations, for example if this was a child of a
345
        // Cloze question that was previously deleted, the question may already
346
        // have gone. In this case, just do nothing.
347
        return;
348
    }
349
 
350
    $sql = 'SELECT qv.id as versionid,
351
                   qv.version,
352
                   qbe.id as entryid,
353
                   qc.id as categoryid,
354
                   ctx.id as contextid
355
              FROM {question} q
356
              LEFT JOIN {question_versions} qv ON qv.questionid = q.id
357
              LEFT JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
358
              LEFT JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
359
              LEFT JOIN {context} ctx ON ctx.id = qc.contextid
360
             WHERE q.id = ?';
361
    $questiondata = $DB->get_record_sql($sql, [$question->id]);
362
 
363
    $questionstocheck = [$question->id];
364
 
365
    if ($question->parent) {
366
        $questionstocheck[] = $question->parent;
367
    }
368
 
369
    // Do not delete a question if it is used by an activity module. Just mark the version hidden.
370
    if (questions_in_use($questionstocheck)) {
371
        $DB->set_field('question_versions', 'status',
372
                question_version_status::QUESTION_STATUS_HIDDEN, ['questionid' => $questionid]);
373
        return;
374
    }
375
 
376
    // This sometimes happens in old sites with bad data.
377
    if (!$questiondata->contextid) {
378
        debugging('Deleting question ' . $question->id . ' which is no longer linked to a context. ' .
379
            'Assuming system context to avoid errors, but this may mean that some data like files, ' .
380
            'tags, are not cleaned up.');
381
        $questiondata->contextid = context_system::instance()->id;
382
        $questiondata->categoryid = 0;
383
    }
384
 
385
    // Delete previews of the question.
386
    $dm = new question_engine_data_mapper();
387
    $dm->delete_previews($question->id);
388
 
389
    // Delete questiontype-specific data.
390
    question_bank::get_qtype($question->qtype, false)->delete_question($question->id, $questiondata->contextid);
391
 
392
    // Now recursively delete all child questions
393
    if ($children = $DB->get_records('question',
394
            array('parent' => $questionid), '', 'id, qtype')) {
395
        foreach ($children as $child) {
396
            if ($child->id != $questionid) {
397
                question_delete_question($child->id);
398
            }
399
        }
400
    }
401
 
402
    // Finally delete the question record itself.
403
    $DB->delete_records('question', ['id' => $question->id]);
404
    $DB->delete_records('question_versions', ['id' => $questiondata->versionid]);
405
    $DB->delete_records('question_references',
406
        [
407
            'version' => $questiondata->version,
408
            'questionbankentryid' => $questiondata->entryid,
409
        ]);
410
    delete_question_bank_entry($questiondata->entryid);
411
    question_bank::notify_question_edited($question->id);
412
 
413
    // Log the deletion of this question.
414
    // Any qbank plugins storing additional question data should observe this event and perform the necessary deletion.
415
    $question->category = $questiondata->categoryid;
416
    $question->contextid = $questiondata->contextid;
417
    $event = \core\event\question_deleted::create_from_question_instance($question);
418
    $event->add_record_snapshot('question', $question);
419
    $event->trigger();
420
}
421
 
422
/**
423
 * All question categories and their questions are deleted for this context id.
424
 *
425
 * @param int $contextid The contextid to delete question categories from
1441 ariadna 426
 * @param bool $coursedeletion Are we calling this as part of deleting the course the context is under?
1 efrain 427
 * @return array only returns an empty array for backwards compatibility.
428
 */
1441 ariadna 429
function question_delete_context($contextid, bool $coursedeletion = false): array {
1 efrain 430
    global $DB;
431
 
432
    $fields = 'id, parent, name, contextid';
433
    if ($categories = $DB->get_records('question_categories', ['contextid' => $contextid], 'parent', $fields)) {
434
        // Sort categories following their tree (parent-child) relationships this will make the feedback more readable.
435
        $categories = sort_categories_by_tree($categories);
436
        foreach ($categories as $category) {
1441 ariadna 437
            question_category_delete_safe($category, $coursedeletion);
1 efrain 438
        }
439
    }
440
    return [];
441
}
442
 
443
/**
444
 * Creates a new category to save the questions in use.
445
 *
446
 * @param array $questionids of question ids
447
 * @param int $newcontextid the context to create the saved category in.
448
 * @param string $oldplace a textual description of the think being deleted,
449
 *      e.g. from get_context_name
450
 * @param object $newcategory
451
 * @return mixed false on
452
 */
453
function question_save_from_deletion($questionids, $newcontextid, $oldplace, $newcategory = null) {
454
    global $DB;
455
 
1441 ariadna 456
    $newcontext = context::instance_by_id($newcontextid);
457
    if ($newcontext->contextlevel !== CONTEXT_MODULE) {
458
        throw new moodle_exception("Invalid contextlevel: {$newcontext->contextlevel} for \$newcontextid {$newcontextid}");
459
    }
460
 
1 efrain 461
    // Make a category in the parent context to move the questions to.
462
    if (is_null($newcategory)) {
463
        $newcategory = new stdClass();
464
        $newcategory->parent = question_get_top_category($newcontextid, true)->id;
465
        $newcategory->contextid = $newcontextid;
466
        // Max length of column name in question_categories is 255.
467
        $newcategory->name = shorten_text(get_string('questionsrescuedfrom', 'question', $oldplace), 255);
468
        $newcategory->info = get_string('questionsrescuedfrominfo', 'question', $oldplace);
469
        $newcategory->sortorder = 999;
470
        $newcategory->stamp = make_unique_id_code();
471
        $newcategory->id = $DB->insert_record('question_categories', $newcategory);
472
    }
473
 
474
    // Move any remaining questions to the 'saved' category.
475
    if (!question_move_questions_to_category($questionids, $newcategory->id)) {
476
        return false;
477
    }
478
    return $newcategory;
479
}
480
 
481
/**
482
 * All question categories and their questions are deleted for this activity.
483
 *
484
 * @param object $cm the course module object representing the activity
485
 * @param bool $notused the argument is not used any more. Kept for backwards compatibility.
1441 ariadna 486
 * @param bool $coursedeletion Are we calling this as part of deleting the course the activity belongs to?
1 efrain 487
 * @return boolean
488
 */
1441 ariadna 489
function question_delete_activity($cm, $notused = false, bool $coursedeletion = false): bool {
1 efrain 490
    $modcontext = context_module::instance($cm->id);
1441 ariadna 491
    question_delete_context($modcontext->id, $coursedeletion);
1 efrain 492
    return true;
493
}
494
 
495
/**
496
 * This function will handle moving all tag instances to a new context for a
497
 * given list of questions.
498
 *
499
 * @param stdClass[] $questions The list of question being moved (must include
500
 *                              the id and contextid)
1441 ariadna 501
 * @param context $newcontext The Moodle context the questions are being moved to, must be module context.
1 efrain 502
 */
503
function question_move_question_tags_to_new_context(array $questions, context $newcontext): void {
1441 ariadna 504
 
505
    if ($newcontext->contextlevel !== CONTEXT_MODULE) {
506
        debugging("Invalid contextlevel: {$newcontext->contextlevel}", DEBUG_DEVELOPER);
507
    }
508
 
1 efrain 509
    $instancesfornewcontext = [];
510
    $questionids = array_map(function($question) {
511
        return $question->id;
512
    }, $questions);
513
    $questionstagobjects = core_tag_tag::get_items_tags('core_question', 'question', $questionids);
514
 
515
    foreach ($questions as $question) {
516
        $tagobjects = $questionstagobjects[$question->id] ?? [];
517
 
518
        foreach ($tagobjects as $tagobject) {
519
            $tagid = $tagobject->taginstanceid;
520
            $tagcontextid = $tagobject->taginstancecontextid;
521
            $istaginnewcontext = $tagcontextid == $newcontext->id;
522
 
523
            if ($istaginnewcontext) {
524
                // This tag instance is already in the correct context so we can
525
                // ignore it.
526
                continue;
527
            }
528
 
1441 ariadna 529
            $instancesfornewcontext[] = $tagid;
1 efrain 530
        }
531
    }
532
 
533
    if (!empty($instancesfornewcontext)) {
534
        // Update the tag instances to the new context id.
535
        core_tag_tag::change_instances_context($instancesfornewcontext, $newcontext);
536
    }
537
}
538
 
539
/**
540
 * Check if an idnumber exist in the category.
541
 *
542
 * @param int $questionidnumber
543
 * @param int $categoryid
544
 * @param int $limitfrom
545
 * @param int $limitnum
546
 * @return array
547
 */
548
function idnumber_exist_in_question_category($questionidnumber, $categoryid, $limitfrom = 0, $limitnum = 1): array {
549
    global $DB;
550
    $response  = false;
551
    $record = [];
552
    // Check if the idnumber exist in the category.
553
    $sql = 'SELECT qbe.idnumber
554
              FROM {question_bank_entries} qbe
555
             WHERE qbe.idnumber LIKE ?
556
               AND qbe.questioncategoryid = ?
557
          ORDER BY qbe.idnumber DESC';
558
    $questionrecord = $DB->record_exists_sql($sql, [$questionidnumber, $categoryid]);
559
    if ((string) $questionidnumber !== '' && $questionrecord) {
560
        $record = $DB->get_records_sql($sql, [$questionidnumber . '_%', $categoryid], 0, 1);
561
        $response  = true;
562
    }
563
 
564
    return [$response, $record];
565
}
566
 
567
/**
568
 * This function should be considered private to the question bank, it is called from
569
 * question/editlib.php question/contextmoveq.php and a few similar places to to the
570
 * work of actually moving questions and associated data. However, callers of this
571
 * function also have to do other work, which is why you should not call this method
572
 * directly from outside the questionbank.
573
 *
574
 * @param array $questionids of question ids.
575
 * @param integer $newcategoryid the id of the category to move to.
576
 * @return bool
577
 */
578
function question_move_questions_to_category($questionids, $newcategoryid): bool {
579
    global $DB;
580
 
581
    $newcategorydata = $DB->get_record('question_categories', ['id' => $newcategoryid]);
582
    if (!$newcategorydata) {
583
        return false;
584
    }
585
    list($questionidcondition, $params) = $DB->get_in_or_equal($questionids);
586
 
587
    $sql = "SELECT qv.id as versionid,
588
                   qbe.id as entryid,
589
                   qc.id as category,
590
                   qc.contextid as contextid,
591
                   q.id,
592
                   q.qtype,
593
                   qbe.idnumber
594
              FROM {question} q
595
              JOIN {question_versions} qv ON qv.questionid = q.id
596
              JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
597
              JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
598
             WHERE q.id $questionidcondition
599
                   OR (q.parent <> 0 AND q.parent $questionidcondition)";
600
 
601
    // Also, we need to move children questions.
602
    $params = array_merge($params, $params);
603
    $questions = $DB->get_records_sql($sql, $params);
604
    foreach ($questions as $question) {
605
        if ($newcategorydata->contextid != $question->contextid) {
606
            question_bank::get_qtype($question->qtype)->move_files(
607
                    $question->id, $question->contextid, $newcategorydata->contextid);
608
        }
609
        // Check whether there could be a clash of idnumbers in the new category.
610
        list($idnumberclash, $rec) = idnumber_exist_in_question_category($question->idnumber, $newcategoryid);
611
        if ($idnumberclash) {
612
            $unique = 1;
613
            if (count($rec)) {
614
                $rec = reset($rec);
615
                $idnumber = $rec->idnumber;
616
                if (strpos($idnumber, '_') !== false) {
617
                    $unique = substr($idnumber, strpos($idnumber, '_') + 1) + 1;
618
                }
619
            }
620
            // For the move process, add a numerical increment to the idnumber. This means that if a question is
621
            // mistakenly moved then the idnumber will not be completely lost.
622
            $qbankentry = new stdClass();
623
            $qbankentry->id = $question->entryid;
624
            $qbankentry->idnumber = $question->idnumber . '_' . $unique;
625
            $DB->update_record('question_bank_entries', $qbankentry);
626
        }
627
 
628
        // Update the entry to the new category id.
629
        $entry = new stdClass();
630
        $entry->id = $question->entryid;
631
        $entry->questioncategoryid = $newcategorydata->id;
632
        $DB->update_record('question_bank_entries', $entry);
633
 
634
        // Log this question move.
635
        $event = \core\event\question_moved::create_from_question_instance($question, context::instance_by_id($question->contextid),
636
                ['oldcategoryid' => $question->category, 'newcategoryid' => $newcategorydata->id]);
637
        $event->trigger();
638
    }
639
 
640
    $newcontext = context::instance_by_id($newcategorydata->contextid);
641
    question_move_question_tags_to_new_context($questions, $newcontext);
642
 
643
    // TODO Deal with datasets.
644
 
645
    // Purge these questions from the cache.
646
    foreach ($questions as $question) {
647
        question_bank::notify_question_edited($question->id);
648
    }
649
 
650
    return true;
651
}
652
 
653
/**
654
 * Update the questioncontextid field for all question_set_references records given a new context id
655
 *
656
 * @param int $oldcategoryid Old category to be moved.
657
 * @param int $newcatgoryid New category that will receive the questions.
658
 * @param int $oldcontextid Old context to be moved.
659
 * @param int $newcontextid New context that will receive the questions.
660
 * @param bool $delete If the action is delete.
661
 * @throws dml_exception
662
 */
663
function move_question_set_references(int $oldcategoryid, int $newcatgoryid,
664
                                      int $oldcontextid, int $newcontextid, bool $delete = false): void {
665
    global $DB;
666
 
667
    if ($delete || $oldcontextid !== $newcontextid) {
668
        $setreferences = $DB->get_recordset('question_set_references', ['questionscontextid' => $oldcontextid]);
669
        foreach ($setreferences as $setreference) {
1441 ariadna 670
            $filter = json_decode($setreference->filtercondition, true);
671
            if (isset($filter['questioncategoryid'])) {
672
                $filter = question_reference_manager::convert_legacy_set_reference_filter_condition($filter);
1 efrain 673
            }
1441 ariadna 674
            $setreference->questionscontextid = $newcontextid;
675
            if (
676
                (int)$filter['filter']['category']['values'][0] === $oldcategoryid
677
                && $oldcategoryid !== $newcatgoryid
678
            ) {
679
                $filter['filter']['category']['values'][0] = $newcatgoryid;
680
                $setreference->filtercondition = json_encode($filter);
681
            }
682
            $DB->update_record('question_set_references', $setreference);
1 efrain 683
        }
684
        $setreferences->close();
685
    }
686
}
687
 
688
/**
689
 * This function helps move a question cateogry to a new context by moving all
690
 * the files belonging to all the questions to the new context.
691
 * Also moves subcategories.
692
 * @param integer $categoryid the id of the category being moved.
693
 * @param integer $oldcontextid the old context id.
694
 * @param integer $newcontextid the new context id.
695
 */
696
function question_move_category_to_context($categoryid, $oldcontextid, $newcontextid): void {
697
    global $DB;
698
 
1441 ariadna 699
    $newcontext = context::instance_by_id($newcontextid);
700
    if ($newcontext->contextlevel !== CONTEXT_MODULE) {
701
        debugging("Invalid contextlevel: {$newcontext->contextlevel}, must use CONTEXT_MODULE", DEBUG_DEVELOPER);
702
    }
703
 
1 efrain 704
    $questions = [];
705
    $sql = "SELECT q.id, q.qtype
706
              FROM {question} q
707
              JOIN {question_versions} qv ON qv.questionid = q.id
708
              JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
709
             WHERE qbe.questioncategoryid = ?";
710
 
711
    $questionids = $DB->get_records_sql_menu($sql, [$categoryid]);
712
    foreach ($questionids as $questionid => $qtype) {
1441 ariadna 713
 
714
        // If the question type is invalid, use "missingtype" so we have a valid qtype to call move_files() on.
715
        if (!\question_bank::is_qtype_installed($qtype)) {
716
            $qtype = 'missingtype';
717
        }
718
 
1 efrain 719
        question_bank::get_qtype($qtype)->move_files($questionid, $oldcontextid, $newcontextid);
720
        // Purge this question from the cache.
721
        question_bank::notify_question_edited($questionid);
722
 
723
        $questions[] = (object) [
724
            'id' => $questionid,
725
            'contextid' => $oldcontextid
726
        ];
727
    }
728
 
729
    question_move_question_tags_to_new_context($questions, $newcontext);
730
 
731
    $subcatids = $DB->get_records_menu('question_categories', ['parent' => $categoryid], '', 'id,1');
732
    foreach ($subcatids as $subcatid => $notused) {
1441 ariadna 733
        move_question_set_references($subcatid, $subcatid, $oldcontextid, $newcontext->id);
1 efrain 734
        $DB->set_field('question_categories', 'contextid', $newcontextid, ['id' => $subcatid]);
735
        question_move_category_to_context($subcatid, $oldcontextid, $newcontextid);
736
    }
737
}
738
 
739
/**
740
 * Given a list of ids, load the basic information about a set of questions from
741
 * the questions table. The $join and $extrafields arguments can be used together
742
 * to pull in extra data. See, for example, the usage in {@see \mod_quiz\quiz_attempt}, and
743
 * read the code below to see how the SQL is assembled. Throws exceptions on error.
744
 *
745
 * @param array $questionids array of question ids to load. If null, then all
746
 * questions matched by $join will be loaded.
747
 * @param string $extrafields extra SQL code to be added to the query.
748
 * @param string $join extra SQL code to be added to the query.
749
 * @param array $extraparams values for any placeholders in $join.
750
 * You must use named placeholders.
751
 * @param string $orderby what to order the results by. Optional, default is unspecified order.
752
 *
753
 * @return array partially complete question objects. You need to call get_question_options
754
 * on them before they can be properly used.
755
 */
756
function question_preload_questions($questionids = null, $extrafields = '', $join = '', $extraparams = [], $orderby = ''): array {
757
    global $DB;
758
 
759
    if ($questionids === null) {
760
        $extracondition = '';
761
        $params = [];
762
    } else {
763
        if (empty($questionids)) {
764
            return [];
765
        }
766
 
767
        list($questionidcondition, $params) = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'qid0000');
768
        $extracondition = 'WHERE q.id ' . $questionidcondition;
769
    }
770
 
771
    if ($join) {
772
        $join = 'JOIN ' . $join;
773
    }
774
 
775
    if ($extrafields) {
776
        $extrafields = ', ' . $extrafields;
777
    }
778
 
779
    if ($orderby) {
780
        $orderby = 'ORDER BY ' . $orderby;
781
    }
782
 
783
    $sql = "SELECT q.*,
784
                   qc.id as category,
785
                   qv.status,
786
                   qv.id as versionid,
787
                   qv.version,
788
                   qv.questionbankentryid,
789
                   qc.contextid as contextid
790
                   {$extrafields}
791
              FROM {question} q
792
              JOIN {question_versions} qv
793
                ON qv.questionid = q.id
794
              JOIN {question_bank_entries} qbe
795
                ON qbe.id = qv.questionbankentryid
796
              JOIN {question_categories} qc
797
                ON qc.id = qbe.questioncategoryid
798
              {$join}
799
              {$extracondition}
800
              {$orderby}";
801
 
802
    // Load the questions.
803
    $questions = $DB->get_records_sql($sql, $extraparams + $params);
804
    foreach ($questions as $question) {
805
        $question->_partiallyloaded = true;
806
    }
807
 
808
    return $questions;
809
}
810
 
811
/**
812
 * Load a set of questions, given a list of ids. The $join and $extrafields arguments can be used
813
 * together to pull in extra data. See, for example, the usage in mod/quiz/attempt.php, and
814
 * read the code below to see how the SQL is assembled. Throws exceptions on error.
815
 *
816
 * @param array $questionids array of question ids.
817
 * @param string $extrafields extra SQL code to be added to the query.
818
 * @param string $join extra SQL code to be added to the query.
819
 * @return array|string question objects.
820
 */
821
function question_load_questions($questionids, $extrafields = '', $join = '') {
822
    $questions = question_preload_questions($questionids, $extrafields, $join);
823
 
824
    // Load the question type specific information.
825
    if (!get_question_options($questions)) {
826
        return get_string('questionloaderror', 'question');
827
    }
828
 
829
    return $questions;
830
}
831
 
832
/**
833
 * Private function to factor common code out of get_question_options().
834
 *
835
 * @param object $question the question to tidy.
836
 * @param stdClass $category The question_categories record for the given $question.
837
 * @param \core_tag_tag[]|null $tagobjects The tags for the given $question.
1441 ariadna 838
 * @param stdClass[]|null $filtercourses deprecated argument and should not be used
1 efrain 839
 */
1441 ariadna 840
function _tidy_question($question, $category, ?array $tagobjects = null, ?array $filtercourses = null): void {
841
 
842
    if ($filtercourses !== null) {
843
        debugging("Filtercourses is a deprecated argument in " . __FUNCTION__, DEBUG_DEVELOPER);
844
    }
845
 
1 efrain 846
    // Convert numeric fields to float. This prevents these being displayed as 1.0000000.
847
    $question->defaultmark += 0;
848
    $question->penalty += 0;
849
 
850
    // Indicate the question is now fully initialised.
851
    if (isset($question->_partiallyloaded)) {
852
        unset($question->_partiallyloaded);
853
    }
854
 
855
    $question->categoryobject = $category;
856
 
857
    // Add any tags we have been passed.
858
    if (!is_null($tagobjects)) {
859
        $categorycontext = context::instance_by_id($category->contextid);
860
        $sortedtagobjects = question_sort_tags($tagobjects, $categorycontext, $filtercourses);
861
        $question->tagobjects = $sortedtagobjects->tagobjects;
862
        $question->tags = $sortedtagobjects->tags;
863
    }
864
 
865
    // Load question-type specific fields.
866
    if (question_bank::is_qtype_installed($question->qtype)) {
867
        question_bank::get_qtype($question->qtype)->get_question_options($question);
868
    } else {
869
        $question->questiontext = html_writer::tag('p', get_string('warningmissingtype',
870
                'qtype_missingtype')) . $question->questiontext;
871
    }
872
}
873
 
874
/**
875
 * Updates the question objects with question type specific
876
 * information by calling {@see get_question_options()}
877
 *
878
 * Can be called either with an array of question objects or with a single
879
 * question object.
880
 *
881
 * @param mixed $questions Either an array of question objects to be updated
882
 *         or just a single question object
883
 * @param bool $loadtags load the question tags from the tags table. Optional, default false.
1441 ariadna 884
 * @param stdClass[] $filtercourses deprecated argument and should not be used
1 efrain 885
 * @return bool Indicates success or failure.
886
 */
887
function get_question_options(&$questions, $loadtags = false, $filtercourses = null) {
888
    global $DB;
889
 
1441 ariadna 890
    if ($filtercourses !== null) {
891
        debugging("Filtercourses is a deprecated argument in " . __FUNCTION__, DEBUG_DEVELOPER);
892
    }
893
 
1 efrain 894
    $questionlist = is_array($questions) ? $questions : [$questions];
895
    $categoryids = [];
896
    $questionids = [];
897
 
898
    if (empty($questionlist)) {
899
        return true;
900
    }
901
 
902
    foreach ($questionlist as $question) {
903
        $questionids[] = $question->id;
904
        if (isset($question->category)) {
905
            $qcategoryid = $question->category;
906
        } else {
907
            $qcategoryid = get_question_bank_entry($question->id)->questioncategoryid;
908
            $question->questioncategoryid = $qcategoryid;
909
        }
910
 
911
        if (!in_array($qcategoryid, $categoryids)) {
912
            $categoryids[] = $qcategoryid;
913
        }
914
    }
915
 
916
    $categories = $DB->get_records_list('question_categories', 'id', $categoryids);
917
 
918
    if ($loadtags && core_tag_tag::is_enabled('core_question', 'question')) {
919
        $tagobjectsbyquestion = core_tag_tag::get_items_tags('core_question', 'question', $questionids);
920
    } else {
921
        $tagobjectsbyquestion = null;
922
    }
923
 
924
    foreach ($questionlist as $question) {
925
        if (is_null($tagobjectsbyquestion)) {
926
            $tagobjects = null;
927
        } else {
928
            $tagobjects = $tagobjectsbyquestion[$question->id];
929
        }
930
        $qcategoryid = $question->category ?? $question->questioncategoryid ??
931
            get_question_bank_entry($question->id)->questioncategoryid;
932
 
933
        _tidy_question($question, $categories[$qcategoryid], $tagobjects, $filtercourses);
934
    }
935
 
936
    return true;
937
}
938
 
939
/**
940
 * Sort question tags by course or normal tags.
941
 *
942
 * This function also search tag instances that may have a context id that don't match either a course or
943
 * question context and fix the data setting the correct context id.
944
 *
945
 * @param \core_tag_tag[] $tagobjects The tags for the given $question.
946
 * @param stdClass $categorycontext The question categories context.
1441 ariadna 947
 * @param stdClass[]|null $filtercourses deprecated argument and should not be used.
1 efrain 948
 * @return stdClass $sortedtagobjects Sorted tag objects.
949
 */
950
function question_sort_tags($tagobjects, $categorycontext, $filtercourses = null): stdClass {
951
 
1441 ariadna 952
    if ($filtercourses !== null) {
953
        debugging("Filtercourses is a deprecated argument in " . __FUNCTION__, DEBUG_DEVELOPER);
954
    }
955
 
1 efrain 956
    $sortedtagobjects = new stdClass();
957
    $sortedtagobjects->tagobjects = [];
958
    $sortedtagobjects->tags = [];
959
    $taginstanceidstonormalise = [];
960
 
961
    foreach ($tagobjects as $tagobject) {
962
        $tagcontextid = $tagobject->taginstancecontextid;
963
        $tagcontext = context::instance_by_id($tagcontextid);
1441 ariadna 964
            // All tag instances belong to the context that the question was created in.
1 efrain 965
            $sortedtagobjects->tagobjects[] = $tagobject;
966
            $sortedtagobjects->tags[$tagobject->id] = $tagobject->get_display_name();
967
 
968
            // Due to legacy tag implementations that don't force the recording
969
            // of a context id, some tag instances may have context ids that don't
970
            // match either a course context or the question context. In this case
971
            // we should take the opportunity to fix up the data and set the correct
972
            // context id.
973
            if ($tagcontext->id != $categorycontext->id) {
974
                $taginstanceidstonormalise[] = $tagobject->taginstanceid;
975
                // Update the object properties to reflect the DB update that will
976
                // happen below.
977
                $tagobject->taginstancecontextid = $categorycontext->id;
978
            }
1441 ariadna 979
 
1 efrain 980
    }
981
 
982
    if (!empty($taginstanceidstonormalise)) {
983
        // If we found any tag instances with incorrect context id data then we can
984
        // correct those values now by setting them to the question context id.
985
        core_tag_tag::change_instances_context($taginstanceidstonormalise, $categorycontext);
986
    }
987
 
988
    return $sortedtagobjects;
989
}
990
 
991
/**
992
 * Print the icon for the question type
993
 *
994
 * @param object $question The question object for which the icon is required.
995
 *       Only $question->qtype is used.
996
 * @return string the HTML for the img tag.
997
 */
998
function print_question_icon($question): string {
999
    global $PAGE;
1000
 
1001
    if (gettype($question->qtype) == 'object') {
1002
        $qtype = $question->qtype->name();
1003
    } else {
1004
        // Assume string.
1005
        $qtype = $question->qtype;
1006
    }
1007
 
1008
    return $PAGE->get_renderer('question', 'bank')->qtype_icon($qtype);
1009
}
1010
 
1011
// CATEGORY FUNCTIONS.
1012
 
1013
/**
1014
 * Returns the categories with their names ordered following parent-child relationships.
1015
 * finally it tries to return pending categories (those being orphaned, whose parent is
1016
 * incorrect) to avoid missing any category from original array.
1017
 *
1018
 * @param array $categories
1019
 * @param int $id
1020
 * @param int $level
1021
 * @return array
1022
 */
1023
function sort_categories_by_tree(&$categories, $id = 0, $level = 1): array {
1024
    global $DB;
1025
 
1026
    $children = [];
1027
    $keys = array_keys($categories);
1028
 
1029
    foreach ($keys as $key) {
1030
        if (!isset($categories[$key]->processed) && $categories[$key]->parent == $id) {
1031
            $children[$key] = $categories[$key];
1032
            $categories[$key]->processed = true;
1033
            $children = $children + sort_categories_by_tree(
1034
                    $categories, $children[$key]->id, $level + 1);
1035
        }
1036
    }
1037
    // If level = 1, we have finished, try to look for non processed categories (bad parent) and sort them too.
1038
    if ($level == 1) {
1039
        foreach ($keys as $key) {
1040
            // If not processed and it's a good candidate to start (because its parent doesn't exist in the course).
1041
            if (!isset($categories[$key]->processed) && !$DB->record_exists('question_categories',
1042
                    array('contextid' => $categories[$key]->contextid,
1043
                            'id' => $categories[$key]->parent))) {
1044
                $children[$key] = $categories[$key];
1045
                $categories[$key]->processed = true;
1046
                $children = $children + sort_categories_by_tree(
1047
                        $categories, $children[$key]->id, $level + 1);
1048
            }
1049
        }
1050
    }
1051
    return $children;
1052
}
1053
 
1054
/**
1441 ariadna 1055
 * Get the default category for the context. Optionally create one if it does not exist.
1 efrain 1056
 *
1441 ariadna 1057
 * @param int $contextid a context id.
1058
 * @param bool $createifnotexists create the default catagory if it does not exist.
1059
 * @return stdClass|bool the default question category for that context, or false if none.
1 efrain 1060
 */
1441 ariadna 1061
function question_get_default_category($contextid, bool $createifnotexists = false) {
1 efrain 1062
    global $DB;
1441 ariadna 1063
 
1064
    $context = \core\context::instance_by_id($contextid);
1065
    if ($context->contextlevel !== CONTEXT_MODULE) {
1066
        debugging(
1067
            "Invalid context level {$context->contextlevel} for default category. Please use CONTEXT_MODULE",
1068
            DEBUG_DEVELOPER
1069
        );
1 efrain 1070
        return false;
1071
    }
1441 ariadna 1072
 
1073
    $defaultcats = $DB->get_records_select('question_categories', 'contextid = ? AND parent <> 0', [$contextid], 'id', '*', 0, 1);
1074
 
1075
    $defaultcat = reset($defaultcats);
1076
 
1077
    if (empty($defaultcat) && $createifnotexists) {
1078
 
1079
        // We need to make a top category first if it doesn't exist.
1080
        $topcategory = question_get_top_category($context->id, true);
1081
 
1082
        // We don't have one, so we need to make one.
1083
        $defaultcat = new stdClass();
1084
        $contextname = $context->get_context_name(false, true);
1085
        // Max length of name field is 255.
1086
        $defaultcat->name = shorten_text(get_string('defaultfor', 'question', $contextname), 255);
1087
        $defaultcat->info = get_string('defaultinfofor', 'question', $contextname);
1088
        $defaultcat->contextid = $context->id;
1089
        $defaultcat->parent = $topcategory->id;
1090
        // By default, all categories get this number, and are sorted alphabetically.
1091
        $defaultcat->sortorder = 999;
1092
        $defaultcat->stamp = make_unique_id_code();
1093
        $defaultcat->id = $DB->insert_record('question_categories', $defaultcat);
1094
    }
1095
 
1096
    return $defaultcat;
1 efrain 1097
}
1098
 
1099
/**
1100
 * Gets the top category in the given context.
1101
 * This function can optionally create the top category if it doesn't exist.
1102
 *
1103
 * @param int $contextid A context id.
1104
 * @param bool $create Whether create a top category if it doesn't exist.
1105
 * @return bool|stdClass The top question category for that context, or false if none.
1106
 */
1107
function question_get_top_category($contextid, $create = false) {
1108
    global $DB;
1109
    $category = $DB->get_record('question_categories', ['contextid' => $contextid, 'parent' => 0]);
1110
 
1441 ariadna 1111
    $context = context::instance_by_id($contextid);
1112
    if ($context->contextlevel !== CONTEXT_MODULE) {
1113
        debugging(
1114
            "Invalid context level: {$context->contextlevel} for question_get_top_category, must be CONTEXT_MODULE",
1115
            DEBUG_DEVELOPER
1116
        );
1117
        return false;
1118
    }
1119
 
1 efrain 1120
    if (!$category && $create) {
1121
        // We need to make one.
1122
        $category = new stdClass();
1123
        $category->name = 'top'; // A non-real name for the top category. It will be localised at the display time.
1124
        $category->info = '';
1125
        $category->contextid = $contextid;
1126
        $category->parent = 0;
1127
        $category->sortorder = 0;
1128
        $category->stamp = make_unique_id_code();
1129
        $category->id = $DB->insert_record('question_categories', $category);
1130
    }
1131
 
1132
    return $category;
1133
}
1134
 
1135
/**
1136
 * Gets the list of top categories in the given contexts in the array("categoryid,categorycontextid") format.
1137
 *
1138
 * @param array $contextids List of context ids
1139
 * @return array
1140
 */
1141
function question_get_top_categories_for_contexts($contextids): array {
1142
    global $DB;
1143
 
1144
    $concatsql = $DB->sql_concat_join("','", ['id', 'contextid']);
1145
    list($insql, $params) = $DB->get_in_or_equal($contextids);
1146
    $sql = "SELECT $concatsql
1147
              FROM {question_categories}
1148
             WHERE contextid $insql
1149
               AND parent = 0";
1150
 
1151
    $topcategories = $DB->get_fieldset_sql($sql, $params);
1152
 
1153
    return $topcategories;
1154
}
1155
 
1156
/**
1157
 * Get the list of categories.
1158
 *
1159
 * @param int $categoryid
1160
 * @return array of question category ids of the category and all subcategories.
1161
 */
1162
function question_categorylist($categoryid): array {
1163
    global $DB;
1164
 
1165
    // Final list of category IDs.
1166
    $categorylist = [];
1167
 
1168
    // A list of category IDs to check for any sub-categories.
1169
    $subcategories = [$categoryid];
1170
    $contextid = $DB->get_field('question_categories', 'contextid', ['id' => $categoryid]);
1171
 
1172
    while ($subcategories) {
1173
        foreach ($subcategories as $subcategory) {
1174
            // If anything from the temporary list was added already, then we have a loop.
1175
            if (isset($categorylist[$subcategory])) {
1176
                throw new coding_exception("Category id=$subcategory is already on the list - loop of categories detected.");
1177
            }
1178
            $categorylist[$subcategory] = $subcategory;
1179
        }
1180
 
1181
        [$in, $params] = $DB->get_in_or_equal($subcategories);
1182
        $params[] = $contextid;
1183
 
1184
        // Order by id is not strictly needed, but it will be cheap, and makes the results deterministic.
1185
        $subcategories = $DB->get_records_select_menu('question_categories',
1186
                "parent $in AND contextid = ?", $params, 'id', 'id,id AS id2');
1187
    }
1188
 
1189
    return $categorylist;
1190
}
1191
 
1192
/**
1193
 * Get all parent categories of a given question category in descending order.
1194
 *
1195
 * @param int $categoryid for which you want to find the parents.
1196
 * @return array of question category ids of all parents categories.
1197
 */
1198
function question_categorylist_parents(int $categoryid): array {
1199
    global $DB;
1200
 
1201
    $category = $DB->get_record('question_categories', ['id' => $categoryid]);
1202
    $contextid = $category->contextid;
1203
 
1204
    $categorylist = [];
1205
    while ($category->parent) {
1206
        $category = $DB->get_record('question_categories', ['id' => $category->parent]);
1207
        if (!$category || $category->contextid != $contextid) {
1208
            break;
1209
        }
1210
        $categorylist[] = $category->id;
1211
    }
1212
 
1213
    // Present the list in descending order (the top category at the top).
1214
    return array_reverse($categorylist);
1215
}
1216
 
1217
// Import/Export Functions.
1218
 
1219
/**
1220
 * Get list of available import or export formats
1221
 * @param string $type 'import' if import list, otherwise export list assumed
1222
 * @return array sorted list of import/export formats available
1223
 */
1224
function get_import_export_formats($type): array {
1225
    global $CFG;
1226
    require_once($CFG->dirroot . '/question/format.php');
1227
 
1228
    $formatclasses = core_component::get_plugin_list_with_class('qformat', '', 'format.php');
1229
 
1230
    $fileformatname = array();
1231
    foreach ($formatclasses as $component => $formatclass) {
1232
 
1233
        $format = new $formatclass();
1234
        if ($type == 'import') {
1235
            $provided = $format->provide_import();
1236
        } else {
1237
            $provided = $format->provide_export();
1238
        }
1239
 
1240
        if ($provided) {
1241
            list($notused, $fileformat) = explode('_', $component, 2);
1242
            $fileformatnames[$fileformat] = get_string('pluginname', $component);
1243
        }
1244
    }
1245
 
1246
    core_collator::asort($fileformatnames);
1247
    return $fileformatnames;
1248
}
1249
 
1250
 
1251
/**
1252
 * Create a reasonable default file name for exporting questions from a particular
1253
 * category.
1254
 * @param object $course the course the questions are in.
1255
 * @param object $category the question category.
1256
 * @return string the filename.
1257
 */
1258
function question_default_export_filename($course, $category): string {
1259
    // We build a string that is an appropriate name (questions) from the lang pack,
1260
    // then the corse shortname, then the question category name, then a timestamp.
1261
 
1262
    $base = clean_filename(get_string('exportfilename', 'question'));
1263
 
1264
    $dateformat = str_replace(' ', '_', get_string('exportnameformat', 'question'));
1265
    $timestamp = clean_filename(userdate(time(), $dateformat, 99, false));
1266
 
1267
    $shortname = clean_filename($course->shortname);
1268
    if ($shortname == '' || $shortname == '_' ) {
1269
        $shortname = $course->id;
1270
    }
1271
 
1272
    $categoryname = clean_filename(format_string($category->name));
1273
 
1274
    return "{$base}-{$shortname}-{$categoryname}-{$timestamp}";
1275
}
1276
 
1277
/**
1278
 * Check capability on category.
1279
 *
1280
 * @param int|stdClass|question_definition $questionorid object or id.
1281
 *      If an object is passed, it should include ->contextid and ->createdby.
1441 ariadna 1282
 * @param string $cap 'add', 'edit', 'view', 'use', 'move', or 'tag'.
1 efrain 1283
 * @param int $notused no longer used.
1284
 * @return bool this user has the capability $cap for this question $question?
1285
 */
1286
function question_has_capability_on($questionorid, $cap, $notused = -1): bool {
1287
    global $USER, $DB;
1288
 
1289
    if (is_numeric($questionorid)) {
1290
        $questionid = (int)$questionorid;
1291
    } else if (is_object($questionorid)) {
1292
        // All we really need in this function is the contextid and author of the question.
1293
        // We won't bother fetching other details of the question if these 2 fields are provided.
11 efrain 1294
        if (isset($questionorid->contextid) && property_exists($questionorid, 'createdby')) {
1 efrain 1295
            $question = $questionorid;
1296
        } else if (!empty($questionorid->id)) {
1297
            $questionid = $questionorid->id;
1298
        }
1299
    }
1300
 
1301
    // At this point, either $question or $questionid is expected to be set.
1302
    if (isset($questionid)) {
1303
        try {
1304
            $question = question_bank::load_question_data($questionid);
1305
        } catch (Exception $e) {
1306
            // Let's log the exception for future debugging,
1307
            // but not during Behat, or we can't test these cases.
1308
            if (!defined('BEHAT_SITE_RUNNING')) {
1309
                debugging($e->getMessage(), DEBUG_NORMAL, $e->getTrace());
1310
            }
1311
 
1312
            $sql = 'SELECT q.id,
1313
                           q.createdby,
1314
                           qc.contextid
1315
                      FROM {question} q
1316
                      JOIN {question_versions} qv
1317
                        ON qv.questionid = q.id
1318
                      JOIN {question_bank_entries} qbe
1319
                        ON qbe.id = qv.questionbankentryid
1320
                      JOIN {question_categories} qc
1321
                        ON qc.id = qbe.questioncategoryid
1322
                     WHERE q.id = :id';
1323
 
1324
            // Well, at least we tried. Seems that we really have to read from DB.
11 efrain 1325
            $question = $DB->get_record_sql($sql, ['id' => $questionid], MUST_EXIST);
1 efrain 1326
        }
1327
    }
1328
 
1329
    if (!isset($question)) {
1330
        throw new coding_exception('$questionorid parameter needs to be an integer or an object.');
1331
    }
1332
 
1333
    $context = context::instance_by_id($question->contextid);
1334
 
1335
    // These are existing questions capabilities that are set per category.
1336
    // Each of these has a 'mine' and 'all' version that is appended to the capability name.
1337
    $capabilitieswithallandmine = ['edit' => 1, 'view' => 1, 'use' => 1, 'move' => 1, 'tag' => 1, 'comment' => 1];
1338
 
1339
    if (!isset($capabilitieswithallandmine[$cap])) {
1340
        return has_capability('moodle/question:' . $cap, $context);
1341
    } else {
1342
        return has_capability('moodle/question:' . $cap . 'all', $context) ||
1343
            ($question->createdby == $USER->id && has_capability('moodle/question:' . $cap . 'mine', $context));
1344
    }
1345
}
1346
 
1347
/**
1348
 * Require capability on question.
1349
 *
1350
 * @param int|stdClass|question_definition $question object or id.
1351
 *      If an object is passed, it should include ->contextid and ->createdby.
1352
 * @param string $cap 'add', 'edit', 'view', 'use', 'move' or 'tag'.
1353
 * @return bool true if the user has the capability. Throws exception if not.
1354
 */
1355
function question_require_capability_on($question, $cap): bool {
1356
    if (!question_has_capability_on($question, $cap)) {
1357
        throw new moodle_exception('nopermissions', '', '', $cap);
1358
    }
1359
    return true;
1360
}
1361
 
1362
/**
1363
 * Gets the question edit url.
1364
 *
1365
 * @param object $context a context
1366
 * @return string|bool|void A URL for editing questions in this context.
1367
 */
1368
function question_edit_url($context) {
1369
    global $CFG, $SITE;
1370
    if (!has_any_capability(question_get_question_capabilities(), $context)) {
1371
        return false;
1372
    }
1441 ariadna 1373
 
1374
    if ($context->contextlevel !== CONTEXT_MODULE) {
1375
        debugging(
1376
            "Invalid contextlevel: {$context->contextlevel} provided for question_edit_url, must be CONTEXT_MODULE",
1377
            DEBUG_DEVELOPER
1378
        );
1379
        return false;
1380
    }
1381
 
1 efrain 1382
    $baseurl = $CFG->wwwroot . '/question/edit.php?';
1441 ariadna 1383
    $defaultcategory = question_get_default_category($context->id, true);
1 efrain 1384
    if ($defaultcategory) {
1385
        $baseurl .= 'cat=' . $defaultcategory->id . ',' . $context->id . '&amp;';
1386
    }
1387
    switch ($context->contextlevel) {
1388
        case CONTEXT_SYSTEM:
1389
            return $baseurl . 'courseid=' . $SITE->id;
1390
        case CONTEXT_COURSECAT:
1391
            // This is nasty, becuase we can only edit questions in a course
1392
            // context at the moment, so for now we just return false.
1393
            return false;
1394
        case CONTEXT_COURSE:
1395
            return $baseurl . 'courseid=' . $context->instanceid;
1396
        case CONTEXT_MODULE:
1397
            return $baseurl . 'cmid=' . $context->instanceid;
1398
    }
1399
 
1441 ariadna 1400
    return $baseurl . 'cmid=' . $context->instanceid;
1 efrain 1401
}
1402
 
1403
/**
1404
 * Adds question bank setting links to the given navigation node if caps are met
1405
 * and loads the navigation from the plugins.
1406
 * Qbank plugins can extend the navigation_plugin_base and add their own navigation node,
1407
 * this method will help to autoload those nodes in the question bank navigation.
1408
 *
1409
 * @param navigation_node $navigationnode The navigation node to add the question branch to
1410
 * @param object $context
1411
 * @param string $baseurl the url of the base where the api is implemented from
1412
 * @return navigation_node Returns the question branch that was added
1413
 */
1414
function question_extend_settings_navigation(navigation_node $navigationnode, $context, $baseurl = '/question/edit.php') {
1415
    global $PAGE;
1416
 
1441 ariadna 1417
    $iscourse = $context->contextlevel === CONTEXT_COURSE;
1418
 
1419
    if ($iscourse) {
1 efrain 1420
        $params = ['courseid' => $context->instanceid];
1421
    } else if ($context->contextlevel == CONTEXT_MODULE) {
1422
        $params = ['cmid' => $context->instanceid];
1423
    } else {
1424
        return;
1425
    }
1426
 
1427
    if (($cat = $PAGE->url->param('cat')) && preg_match('~\d+,\d+~', $cat)) {
1428
        $params['cat'] = $cat;
1429
    }
1430
 
1441 ariadna 1431
    $questionnode = $navigationnode->add(get_string($iscourse ? 'questionbank_plural' : 'questionbank', 'question'),
1 efrain 1432
            new moodle_url($baseurl, $params), navigation_node::TYPE_CONTAINER, null, 'questionbank');
1433
 
1434
    $corenavigations = [
1435
            'questions' => [
1436
                    'title' => get_string('questions', 'question'),
1437
                    'url' => new moodle_url($baseurl)
1438
            ],
1439
            'categories' => [],
1440
            'import' => [],
1441
            'export' => []
1442
    ];
1443
 
1444
    $plugins = \core_component::get_plugin_list_with_class('qbank', 'plugin_feature', 'plugin_feature.php');
1445
    foreach ($plugins as $componentname => $plugin) {
1446
        $pluginentrypoint = new $plugin();
1447
        $pluginentrypointobject = $pluginentrypoint->get_navigation_node();
1448
        // Don't need the plugins without navigation node.
1449
        if ($pluginentrypointobject === null) {
1450
            unset($plugins[$componentname]);
1451
            continue;
1452
        }
1453
        foreach ($corenavigations as $key => $corenavigation) {
1454
            if ($pluginentrypointobject->get_navigation_key() === $key) {
1455
                unset($plugins[$componentname]);
1456
                if (!\core\plugininfo\qbank::is_plugin_enabled($componentname)) {
1457
                    unset($corenavigations[$key]);
1458
                    break;
1459
                }
1460
                $corenavigations[$key] = [
1461
                    'title' => $pluginentrypointobject->get_navigation_title(),
1462
                    'url'   => $pluginentrypointobject->get_navigation_url()
1463
                ];
1464
            }
1465
        }
1466
    }
1467
 
1468
    // Mitigate the risk of regression.
1469
    foreach ($corenavigations as $node => $corenavigation) {
1470
        if (empty($corenavigation)) {
1471
            unset($corenavigations[$node]);
1472
        }
1473
    }
1474
 
1475
    // Community/additional plugins have navigation node.
1476
    $pluginnavigations = [];
1477
    foreach ($plugins as $componentname => $plugin) {
1478
        $pluginentrypoint = new $plugin();
1479
        $pluginentrypointobject = $pluginentrypoint->get_navigation_node();
1480
        // Don't need the plugins without navigation node.
1481
        if ($pluginentrypointobject === null || !\core\plugininfo\qbank::is_plugin_enabled($componentname)) {
1482
            unset($plugins[$componentname]);
1483
            continue;
1484
        }
1485
        $pluginnavigations[$pluginentrypointobject->get_navigation_key()] = [
1486
            'title' => $pluginentrypointobject->get_navigation_title(),
1487
            'url'   => $pluginentrypointobject->get_navigation_url(),
1488
            'capabilities' => $pluginentrypointobject->get_navigation_capabilities()
1489
        ];
1490
    }
1491
 
1492
    $contexts = new core_question\local\bank\question_edit_contexts($context);
1493
    foreach ($corenavigations as $key => $corenavigation) {
1494
        if ($contexts->have_one_edit_tab_cap($key)) {
1495
            $questionnode->add($corenavigation['title'], new moodle_url(
1496
                    $corenavigation['url'], $params), navigation_node::TYPE_SETTING, null, $key);
1497
        }
1498
    }
1499
 
1500
    foreach ($pluginnavigations as $key => $pluginnavigation) {
1501
        if (is_array($pluginnavigation['capabilities'])) {
1502
            if (!$contexts->have_one_cap($pluginnavigation['capabilities'])) {
1503
                continue;
1504
            }
1505
        }
1506
        $questionnode->add($pluginnavigation['title'], new moodle_url(
1507
                $pluginnavigation['url'], $params), navigation_node::TYPE_SETTING, null, $key);
1508
    }
1509
 
1510
    return $questionnode;
1511
}
1512
 
1513
/**
1514
 * Get the array of capabilities for question.
1515
 *
1516
 * @return array all the capabilities that relate to accessing particular questions.
1517
 */
1518
function question_get_question_capabilities(): array {
1519
    return [
1520
        'moodle/question:add',
1521
        'moodle/question:editmine',
1522
        'moodle/question:editall',
1523
        'moodle/question:viewmine',
1524
        'moodle/question:viewall',
1525
        'moodle/question:usemine',
1526
        'moodle/question:useall',
1527
        'moodle/question:movemine',
1528
        'moodle/question:moveall',
1529
        'moodle/question:tagmine',
1530
        'moodle/question:tagall',
1531
        'moodle/question:commentmine',
1532
        'moodle/question:commentall',
1533
    ];
1534
}
1535
 
1536
/**
1537
 * Get the question bank caps.
1538
 *
1539
 * @return array all the question bank capabilities.
1540
 */
1541
function question_get_all_capabilities(): array {
1542
    $caps = question_get_question_capabilities();
1543
    $caps[] = 'moodle/question:managecategory';
1544
    $caps[] = 'moodle/question:flag';
1545
    return $caps;
1546
}
1547
 
1548
/**
1549
 * Helps call file_rewrite_pluginfile_urls with the right parameters.
1550
 *
1551
 * @package  core_question
1552
 * @category files
1553
 * @param string $text text being processed
1554
 * @param string $file the php script used to serve files
1555
 * @param int $contextid context ID
1556
 * @param string $component component
1557
 * @param string $filearea filearea
1558
 * @param array $ids other IDs will be used to check file permission
1559
 * @param int $itemid item ID
1560
 * @param array $options options
1561
 * @return string
1562
 */
1563
function question_rewrite_question_urls($text, $file, $contextid, $component, $filearea,
1441 ariadna 1564
                                        array $ids, $itemid, ?array $options=null): string {
1 efrain 1565
 
1566
    $idsstr = '';
1567
    if (!empty($ids)) {
1568
        $idsstr .= implode('/', $ids);
1569
    }
1570
    if ($itemid !== null) {
1571
        $idsstr .= '/' . $itemid;
1572
    }
1573
    return file_rewrite_pluginfile_urls($text, $file, $contextid, $component,
1574
            $filearea, $idsstr, $options);
1575
}
1576
 
1577
/**
1578
 * Rewrite the PLUGINFILE urls in part of the content of a question, for use when
1579
 * viewing the question outside an attempt (for example, in the question bank
1580
 * listing or in the quiz statistics report).
1581
 *
1582
 * @param string $text the question text.
1583
 * @param int $questionid the question id.
1584
 * @param int $filecontextid the context id of the question being displayed.
1585
 * @param string $filecomponent the component that owns the file area.
1586
 * @param string $filearea the file area name.
1587
 * @param int|null $itemid the file's itemid
1588
 * @param int $previewcontextid the context id where the preview is being displayed.
1589
 * @param string $previewcomponent component responsible for displaying the preview.
1590
 * @param array $options text and file options ('forcehttps'=>false)
1591
 * @return string $questiontext with URLs rewritten.
1592
 */
1593
function question_rewrite_question_preview_urls($text, $questionid, $filecontextid, $filecomponent, $filearea, $itemid,
1594
                                                $previewcontextid, $previewcomponent, $options = null): string {
1595
 
1596
    $path = "preview/$previewcontextid/$previewcomponent/$questionid";
1597
    if ($itemid) {
1598
        $path .= '/' . $itemid;
1599
    }
1600
 
1601
    return file_rewrite_pluginfile_urls($text, 'pluginfile.php', $filecontextid,
1602
            $filecomponent, $filearea, $path, $options);
1603
}
1604
 
1605
/**
1606
 * Called by pluginfile.php to serve files related to the 'question' core
1607
 * component and for files belonging to qtypes.
1608
 *
1609
 * For files that relate to questions in a question_attempt, then we delegate to
1610
 * a function in the component that owns the attempt (for example in the quiz,
1611
 * or in core question preview) to get necessary inforation.
1612
 *
1613
 * (Note that, at the moment, all question file areas relate to questions in
1614
 * attempts, so the If at the start of the last paragraph is always true.)
1615
 *
1616
 * Does not return, either calls send_file_not_found(); or serves the file.
1617
 *
1618
 * @category files
1619
 * @param stdClass $course course settings object
1620
 * @param stdClass $context context object
1621
 * @param string $component the name of the component we are serving files for.
1622
 * @param string $filearea the name of the file area.
1623
 * @param array $args the remaining bits of the file path.
1624
 * @param bool $forcedownload whether the user must be forced to download the file.
1625
 * @param array $options additional options affecting the file serving
1626
 * @return array|bool
1627
 */
1628
function question_pluginfile($course, $context, $component, $filearea, $args, $forcedownload, $options = []) {
1629
    global $DB, $CFG;
1630
 
1631
    // Special case, sending a question bank export.
1632
    if ($filearea === 'export') {
1633
        list($context, $course, $cm) = get_context_info_array($context->id);
1634
        require_login($course, false, $cm);
1635
 
1636
        require_once($CFG->dirroot . '/question/editlib.php');
1637
        $contexts = new core_question\local\bank\question_edit_contexts($context);
1638
        // Check export capability.
1639
        $contexts->require_one_edit_tab_cap('export');
1640
        $categoryid = (int)array_shift($args);
1641
        $format      = array_shift($args);
1642
        $cattofile   = array_shift($args);
1643
        $contexttofile = array_shift($args);
1644
        $filename    = array_shift($args);
1645
 
1646
        // Load parent class for import/export.
1647
        require_once($CFG->dirroot . '/question/format.php');
1648
        require_once($CFG->dirroot . '/question/editlib.php');
1649
        require_once($CFG->dirroot . '/question/format/' . $format . '/format.php');
1650
 
1651
        $classname = 'qformat_' . $format;
1652
        if (!class_exists($classname)) {
1653
            send_file_not_found();
1654
        }
1655
 
1656
        $qformat = new $classname();
1657
 
1658
        if (!$category = $DB->get_record('question_categories', array('id' => $categoryid))) {
1659
            send_file_not_found();
1660
        }
1661
 
1662
        $qformat->setCategory($category);
1663
        $qformat->setContexts($contexts->having_one_edit_tab_cap('export'));
1664
        $qformat->setCourse($course);
1665
 
1666
        if ($cattofile == 'withcategories') {
1667
            $qformat->setCattofile(true);
1668
        } else {
1669
            $qformat->setCattofile(false);
1670
        }
1671
 
1672
        if ($contexttofile == 'withcontexts') {
1673
            $qformat->setContexttofile(true);
1674
        } else {
1675
            $qformat->setContexttofile(false);
1676
        }
1677
 
1678
        if (!$qformat->exportpreprocess()) {
1679
            send_file_not_found();
1680
            throw new moodle_exception('exporterror', 'question', $thispageurl->out());
1681
        }
1682
 
1683
        // Export data to moodle file pool.
1684
        if (!$content = $qformat->exportprocess()) {
1685
            send_file_not_found();
1686
        }
1687
 
1688
        send_file($content, $filename, 0, 0, true, true, $qformat->mime_type());
1689
    }
1690
 
1691
    // Normal case, a file belonging to a question.
1692
    $qubaidorpreview = array_shift($args);
1693
 
1694
    // Two sub-cases: 1. A question being previewed outside an attempt/usage.
1695
    if ($qubaidorpreview === 'preview') {
1696
        $previewcontextid = (int)array_shift($args);
1697
        $previewcomponent = array_shift($args);
1698
        $questionid = (int) array_shift($args);
1699
        $previewcontext = context_helper::instance_by_id($previewcontextid);
1700
 
1701
        $result = component_callback($previewcomponent, 'question_preview_pluginfile', array(
1702
                $previewcontext, $questionid,
1703
                $context, $component, $filearea, $args,
1704
                $forcedownload, $options), 'callbackmissing');
1705
 
1706
        if ($result === 'callbackmissing') {
1707
            throw new coding_exception("Component {$previewcomponent} does not define the callback " .
1708
                    "{$previewcomponent}_question_preview_pluginfile callback. " .
1709
                    "Which is required if you are using question_rewrite_question_preview_urls.", DEBUG_DEVELOPER);
1710
        }
1711
 
1712
        send_file_not_found();
1713
    }
1714
 
1715
    // 2. A question being attempted in the normal way.
1716
    $qubaid = (int)$qubaidorpreview;
1717
    $slot = (int)array_shift($args);
1718
 
1719
    $module = $DB->get_field('question_usages', 'component',
1720
            array('id' => $qubaid));
1721
    if (!$module) {
1722
        send_file_not_found();
1723
    }
1724
 
1725
    if ($module === 'core_question_preview') {
1726
        return qbank_previewquestion\helper::question_preview_question_pluginfile($course, $context,
1727
                $component, $filearea, $qubaid, $slot, $args, $forcedownload, $options);
1728
 
1729
    } else {
1730
        $dir = core_component::get_component_directory($module);
1731
        if (!file_exists("$dir/lib.php")) {
1732
            send_file_not_found();
1733
        }
1734
        include_once("$dir/lib.php");
1735
 
1736
        $filefunction = $module . '_question_pluginfile';
1737
        if (function_exists($filefunction)) {
1738
            $filefunction($course, $context, $component, $filearea, $qubaid, $slot,
1739
                $args, $forcedownload, $options);
1740
        }
1741
 
1742
        // Okay, we're here so lets check for function without 'mod_'.
1743
        if (strpos($module, 'mod_') === 0) {
1744
            $filefunctionold  = substr($module, 4) . '_question_pluginfile';
1745
            if (function_exists($filefunctionold)) {
1746
                $filefunctionold($course, $context, $component, $filearea, $qubaid, $slot,
1747
                    $args, $forcedownload, $options);
1748
            }
1749
        }
1750
 
1751
        send_file_not_found();
1752
    }
1753
}
1754
 
1755
/**
1756
 * Serve questiontext files in the question text when they are displayed in this report.
1757
 *
1758
 * @param context $previewcontext the context in which the preview is happening.
1759
 * @param int $questionid the question id.
1760
 * @param context $filecontext the file (question) context.
1761
 * @param string $filecomponent the component the file belongs to.
1762
 * @param string $filearea the file area.
1763
 * @param array $args remaining file args.
1764
 * @param bool $forcedownload
1765
 * @param array $options additional options affecting the file serving.
1766
 */
1767
function core_question_question_preview_pluginfile($previewcontext, $questionid, $filecontext, $filecomponent,
1768
                                                    $filearea, $args, $forcedownload, $options = []): void {
1769
    global $DB;
1770
    $sql = 'SELECT q.*,
1771
                   qc.contextid
1772
              FROM {question} q
1773
              JOIN {question_versions} qv
1774
                ON qv.questionid = q.id
1775
              JOIN {question_bank_entries} qbe
1776
                ON qbe.id = qv.questionbankentryid
1777
              JOIN {question_categories} qc
1778
                ON qc.id = qbe.questioncategoryid
1779
             WHERE q.id = :id
1780
               AND qc.contextid = :contextid';
1781
 
1782
    // Verify that contextid matches the question.
1783
    $question = $DB->get_record_sql($sql, ['id' => $questionid, 'contextid' => $filecontext->id], MUST_EXIST);
1784
 
1785
    // Check the capability.
1786
    list($context, $course, $cm) = get_context_info_array($previewcontext->id);
1787
    require_login($course, false, $cm);
1788
 
1789
    question_require_capability_on($question, 'use');
1790
 
1791
    $fs = get_file_storage();
1792
    $relativepath = implode('/', $args);
1793
    $fullpath = "/{$filecontext->id}/{$filecomponent}/{$filearea}/{$relativepath}";
1794
    if (!$file = $fs->get_file_by_hash(sha1($fullpath)) or $file->is_directory()) {
1795
        send_file_not_found();
1796
    }
1797
 
1798
    send_stored_file($file, 0, 0, $forcedownload, $options);
1799
}
1800
 
1801
/**
1802
 * Return a list of page types
1803
 * @param string $pagetype current page type
1804
 * @param stdClass $parentcontext Block's parent context
1805
 * @param stdClass $currentcontext Current context of block
1806
 * @return array
1807
 */
1808
function question_page_type_list($pagetype, $parentcontext, $currentcontext): array {
1809
    global $CFG;
1810
    $types = [
1811
        'question-*' => get_string('page-question-x', 'question'),
1812
        'question-edit' => get_string('page-question-edit', 'question'),
1813
        'question-category' => get_string('page-question-category', 'question'),
1814
        'question-export' => get_string('page-question-export', 'question'),
1815
        'question-import' => get_string('page-question-import', 'question')
1816
    ];
1817
    if ($currentcontext->contextlevel == CONTEXT_COURSE) {
1818
        require_once($CFG->dirroot . '/course/lib.php');
1819
        return array_merge(course_page_type_list($pagetype, $parentcontext, $currentcontext), $types);
1820
    } else {
1821
        return $types;
1822
    }
1823
}
1824
 
1825
/**
1826
 * Does an activity module use the question bank?
1827
 *
1828
 * @param string $modname The name of the module (without mod_ prefix).
1829
 * @return bool true if the module uses questions.
1830
 */
1831
function question_module_uses_questions($modname): bool {
1832
    if (plugin_supports('mod', $modname, FEATURE_USES_QUESTIONS)) {
1833
        return true;
1834
    }
1835
 
1836
    $component = 'mod_'.$modname;
1837
    if (component_callback_exists($component, 'question_pluginfile')) {
1838
        debugging("{$component} uses questions but doesn't declare FEATURE_USES_QUESTIONS", DEBUG_DEVELOPER);
1839
        return true;
1840
    }
1841
 
1842
    return false;
1843
}
1844
 
1845
/**
1846
 * If $oldidnumber ends in some digits then return the next available idnumber of the same form.
1847
 *
1848
 * So idnum -> null (no digits at the end) idnum0099 -> idnum0100 (if that is unused,
1849
 * else whichever of idnum0101, idnume0102, ... is unused. idnum9 -> idnum10.
1850
 *
1851
 * @param string|null $oldidnumber a question idnumber, or can be null.
1852
 * @param int $categoryid a question category id.
1853
 * @return string|null suggested new idnumber for a question in that category, or null if one cannot be found.
1854
 */
1855
function core_question_find_next_unused_idnumber(?string $oldidnumber, int $categoryid): ?string {
1856
    global $DB;
1857
 
1858
    // The the old idnumber is not of the right form, bail now.
1859
    if ($oldidnumber === null || !preg_match('~\d+$~', $oldidnumber, $matches)) {
1860
        return null;
1861
    }
1862
 
1863
    // Find all used idnumbers in one DB query.
1864
    $usedidnumbers = $DB->get_records_select_menu('question_bank_entries', 'questioncategoryid = ? AND idnumber IS NOT NULL',
1865
            [$categoryid], '', 'idnumber, 1');
1866
 
1867
    // Find the next unused idnumber.
1868
    $numberbit = 'X' . $matches[0]; // Need a string here so PHP does not do '0001' + 1 = 2.
1869
    $stem = substr($oldidnumber, 0, -strlen($matches[0]));
1870
    do {
1871
 
1872
        // If we have got to something9999, insert an extra digit before incrementing.
1873
        if (preg_match('~^(.*[^0-9])(9+)$~', $numberbit, $matches)) {
1874
            $numberbit = $matches[1] . '0' . $matches[2];
1875
        }
1876
        $numberbit++;
1877
        $newidnumber = $stem . substr($numberbit, 1);
1878
    } while (isset($usedidnumbers[$newidnumber]));
1879
 
1880
    return (string) $newidnumber;
1881
}
1882
 
1883
/**
1884
 * Get the question_bank_entry object given a question id.
1885
 *
1886
 * @param int $questionid Question id.
1887
 * @return false|mixed
1888
 * @throws dml_exception
1889
 */
1890
function get_question_bank_entry(int $questionid): object {
1891
    global $DB;
1892
 
1893
    $sql = "SELECT qbe.*
1894
              FROM {question} q
1895
              JOIN {question_versions} qv ON qv.questionid = q.id
1896
              JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
1897
             WHERE q.id = :id";
1898
 
1899
    $qbankentry = $DB->get_record_sql($sql, ['id' => $questionid]);
1900
 
1901
    return $qbankentry;
1902
}
1903
 
1904
/**
1905
 * Get the question versions given a question id in a descending sort .
1906
 *
1907
 * @param int $questionid Question id.
1908
 * @return array
1909
 * @throws dml_exception
1910
 */
1911
function get_question_version($questionid): array {
1912
    global $DB;
1913
 
1914
    $version = $DB->get_records('question_versions', ['questionid' => $questionid]);
1915
    krsort($version);
1916
 
1917
    return $version;
1918
}
1919
 
1920
/**
1921
 * Get the next version number to create base on a Question bank entry id.
1922
 *
1923
 * @param int $questionbankentryid Question bank entry id.
1924
 * @return int next version number.
1925
 * @throws dml_exception
1926
 */
1927
function get_next_version(int $questionbankentryid): int {
1928
    global $DB;
1929
 
1930
    $sql = "SELECT MAX(qv.version)
1931
              FROM {question_versions} qv
1932
              JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
1933
             WHERE qbe.id = :id";
1934
 
1935
    $nextversion = $DB->get_field_sql($sql, ['id' => $questionbankentryid]);
1936
 
1937
    if ($nextversion) {
1938
        return (int)$nextversion + 1;
1939
    }
1940
 
1941
    return 1;
1942
}
1943
 
1944
/**
1945
 * Checks if question is the latest version.
1946
 *
1947
 * @param string $version Question version to check.
1948
 * @param string $questionbankentryid Entry to check against.
1949
 * @return bool
1950
 */
1951
function is_latest(string $version, string $questionbankentryid): bool {
1952
    global $DB;
1953
 
1954
    $sql = 'SELECT MAX(version) AS max
1955
                  FROM {question_versions}
1956
                 WHERE questionbankentryid = ?';
1957
    $latestversion = $DB->get_record_sql($sql, [$questionbankentryid]);
1958
 
1959
    if (isset($latestversion->max)) {
1960
        return ($version === $latestversion->max) ? true : false;
1961
    }
1962
    return false;
1963
}