Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
// This file is part of Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
 
17
/**
18
 * A class for efficiently finds questions at random from the question bank.
19
 *
20
 * @package   core_question
21
 * @copyright 2015 The Open University
22
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
 
25
namespace core_question\local\bank;
26
 
27
/**
28
 * This class efficiently finds questions at random from the question bank.
29
 *
30
 * You can ask for questions at random one at a time. Each time you ask, you
31
 * pass a category id, and whether to pick from that category and all subcategories
32
 * or just that category.
33
 *
34
 * The number of teams each question has been used is tracked, and we will always
35
 * return a question from among those elegible that has been used the fewest times.
36
 * So, if there are questions that have not been used yet in the category asked for,
37
 * one of those will be returned. However, within one instantiation of this class,
38
 * we will never return a given question more than once, and we will never return
39
 * questions passed into the constructor as $usedquestions.
40
 *
41
 * @copyright 2015 The Open University
42
 * @author    2021 Safat Shahin <safatshahin@catalyst-au.net>
43
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
44
 */
45
class random_question_loader {
46
    /** @var \qubaid_condition which usages to consider previous attempts from. */
47
    protected $qubaids;
48
 
49
    /** @var array qtypes that cannot be used by random questions. */
50
    protected $excludedqtypes;
51
 
52
    /** @var array categoryid & include subcategories => num previous uses => questionid => 1. */
53
    protected $availablequestionscache = [];
54
 
55
    /**
56
     * @var array questionid => num recent uses. Questions that have been used,
57
     * but that is not yet recorded in the DB.
58
     */
59
    protected $recentlyusedquestions;
60
 
61
    /**
62
     * Constructor.
63
     *
64
     * @param \qubaid_condition $qubaids the usages to consider when counting previous uses of each question.
65
     * @param array $usedquestions questionid => number of times used count. If we should allow for
66
     *      further existing uses of a question in addition to the ones in $qubaids.
67
     */
68
    public function __construct(\qubaid_condition $qubaids, array $usedquestions = []) {
69
        $this->qubaids = $qubaids;
70
        $this->recentlyusedquestions = $usedquestions;
71
 
72
        foreach (\question_bank::get_all_qtypes() as $qtype) {
73
            if (!$qtype->is_usable_by_random()) {
74
                $this->excludedqtypes[] = $qtype->name();
75
            }
76
        }
77
    }
78
 
79
    /**
80
     * Pick a random question based on filter conditions
81
     *
82
     * @param array $filters filter array
83
     * @return int|null
84
     */
85
    public function get_next_filtered_question_id(array $filters): ?int {
86
        $this->ensure_filtered_questions_loaded($filters);
87
 
88
        $key = $this->get_filtered_questions_key($filters);
89
        if (empty($this->availablequestionscache[$key])) {
90
            return null;
91
        }
92
 
93
        reset($this->availablequestionscache[$key]);
94
        $lowestcount = key($this->availablequestionscache[$key]);
95
        reset($this->availablequestionscache[$key][$lowestcount]);
96
        $questionid = key($this->availablequestionscache[$key][$lowestcount]);
97
        $this->use_question($questionid);
98
        return $questionid;
99
    }
100
 
101
 
102
    /**
103
     * Pick a question at random from the given category, from among those with the fewest uses.
104
     * If an array of tag ids are specified, then only the questions that are tagged with ALL those tags will be selected.
105
     *
106
     * It is up the the caller to verify that the cateogry exists. An unknown category
107
     * behaves like an empty one.
108
     *
109
     * @param int $categoryid the id of a category in the question bank.
110
     * @param bool $includesubcategories wether to pick a question from exactly
111
     *      that category, or that category and subcategories.
112
     * @param array $tagids An array of tag ids. A question has to be tagged with all the provided tagids (if any)
113
     *      in order to be eligible for being picked.
114
     * @return int|null the id of the question picked, or null if there aren't any.
115
     * @deprecated since Moodle 4.3
116
     * @todo Final deprecation on Moodle 4.7 MDL-78091
117
     */
118
    public function get_next_question_id($categoryid, $includesubcategories, $tagids = []): ?int {
119
        debugging(
120
            'Function get_next_question_id() is deprecated, please use get_next_filtered_question_id() instead.',
121
            DEBUG_DEVELOPER
122
        );
123
 
124
        $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids);
125
 
126
        $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
127
        if (empty($this->availablequestionscache[$categorykey])) {
128
            return null;
129
        }
130
 
131
        reset($this->availablequestionscache[$categorykey]);
132
        $lowestcount = key($this->availablequestionscache[$categorykey]);
133
        reset($this->availablequestionscache[$categorykey][$lowestcount]);
134
        $questionid = key($this->availablequestionscache[$categorykey][$lowestcount]);
135
        $this->use_question($questionid);
136
        return $questionid;
137
    }
138
 
139
    /**
140
     * Key for filtered questions.
141
     * This function replace get_category_key
142
     *
143
     * @param array $filters filter array
144
     * @return String
145
     */
146
    protected function get_filtered_questions_key(array $filters): String {
147
        return sha1(json_encode($filters));
148
    }
149
 
150
    /**
151
     * Get the key into {@see $availablequestionscache} for this combination of options.
152
     *
153
     * @param int $categoryid the id of a category in the question bank.
154
     * @param bool $includesubcategories wether to pick a question from exactly
155
     *      that category, or that category and subcategories.
156
     * @param array $tagids an array of tag ids.
157
     * @return string the cache key.
158
     *
159
     * @deprecated since Moodle 4.3
160
     * @todo Final deprecation on Moodle 4.7 MDL-78091
161
     */
162
    protected function get_category_key($categoryid, $includesubcategories, $tagids = []): string {
163
        debugging(
164
            'Function get_category_key() is deprecated, please get_fitlered_questions_key instead.',
165
            DEBUG_DEVELOPER
166
        );
167
        if ($includesubcategories) {
168
            $key = $categoryid . '|1';
169
        } else {
170
            $key = $categoryid . '|0';
171
        }
172
 
173
        if (!empty($tagids)) {
174
            $key .= '|' . implode('|', $tagids);
175
        }
176
 
177
        return $key;
178
    }
179
 
180
    /**
181
     * Populate {@see $availablequestionscache} according to filter conditions.
182
     *
183
     * @param array $filters filter array
184
     * @return void
185
     */
186
    protected function ensure_filtered_questions_loaded(array $filters) {
187
        global $DB;
188
 
189
        // Check if this is already done.
190
        $key = $this->get_filtered_questions_key($filters);
191
        if (isset($this->availablequestionscache[$key])) {
192
            // Data is already in the cache, nothing to do.
193
            return;
194
        }
195
 
196
        // Build filter conditions.
197
        $params = [];
198
        $filterconditions = [];
199
        foreach (filter_condition_manager::get_condition_classes() as $conditionclass) {
200
            $filter = $conditionclass::get_filter_from_list($filters);
201
            if (is_null($filter)) {
202
                continue;
203
            }
204
 
205
            [$filterwhere, $filterparams] = $conditionclass::build_query_from_filter($filter);
206
            if (!empty($filterwhere)) {
207
                $filterconditions[] = '(' . $filterwhere . ')';
208
            }
209
            if (!empty($filterparams)) {
210
                $params = array_merge($params, $filterparams);
211
            }
212
        }
213
        $filtercondition = $filterconditions ? 'AND ' . implode(' AND ', $filterconditions) : '';
214
 
215
        // Prepare qtype check.
216
        [$qtypecondition, $qtypeparams] = $DB->get_in_or_equal($this->excludedqtypes,
217
            SQL_PARAMS_NAMED, 'excludedqtype', false);
218
        if ($qtypecondition) {
219
            $qtypecondition = 'AND q.qtype ' . $qtypecondition;
220
        }
221
 
222
        $questionidsandcounts = $DB->get_records_sql_menu("
223
                SELECT q.id,
224
                       (
225
                           SELECT COUNT(1)
226
                             FROM {$this->qubaids->from_question_attempts('qa')}
227
                            WHERE qa.questionid = q.id AND {$this->qubaids->where()}
228
                       ) AS previous_attempts
229
 
230
                  FROM {question} q
231
                  JOIN {question_versions} qv ON qv.questionid = q.id
232
                  JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
233
 
234
                 WHERE q.parent = :noparent
235
                   $qtypecondition
236
                   $filtercondition
237
                   AND qv.version = (
238
                           SELECT MAX(version)
239
                             FROM {question_versions}
240
                            WHERE questionbankentryid = qbe.id
241
                              AND status = :ready
242
                       )
243
 
244
              ORDER BY previous_attempts
245
            ", array_merge(
246
                $params,
247
                $this->qubaids->from_where_params(),
248
                ['noparent' => 0, 'ready' => question_version_status::QUESTION_STATUS_READY],
249
                $qtypeparams,
250
            ));
251
 
252
        if (!$questionidsandcounts) {
253
            // No questions in this category.
254
            $this->availablequestionscache[$key] = [];
255
            return;
256
        }
257
 
258
        // Put all the questions with each value of $prevusecount in separate arrays.
259
        $idsbyusecount = [];
260
        foreach ($questionidsandcounts as $questionid => $prevusecount) {
261
            if (isset($this->recentlyusedquestions[$questionid])) {
262
                // Recently used questions are never returned.
263
                continue;
264
            }
265
            $idsbyusecount[$prevusecount][] = $questionid;
266
        }
267
 
268
        // Now put that data into our cache. For each count, we need to shuffle
269
        // questionids, and make those the keys of an array.
270
        $this->availablequestionscache[$key] = [];
271
        foreach ($idsbyusecount as $prevusecount => $questionids) {
272
            shuffle($questionids);
273
            $this->availablequestionscache[$key][$prevusecount] = array_combine(
274
                $questionids, array_fill(0, count($questionids), 1));
275
        }
276
        ksort($this->availablequestionscache[$key]);
277
    }
278
 
279
    /**
280
     * Populate {@see $availablequestionscache} for this combination of options.
281
     *
282
     * @param int $categoryid The id of a category in the question bank.
283
     * @param bool $includesubcategories Whether to pick a question from exactly
284
     *      that category, or that category and subcategories.
285
     * @param array $tagids An array of tag ids. If an array is provided, then
286
     *      only the questions that are tagged with ALL the provided tagids will be loaded.
287
     * @deprecated since Moodle 4.3
288
     * @todo Final deprecation on Moodle 4.7 MDL-78091
289
     */
290
    protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids = []): void {
291
        debugging(
292
            'Function ensure_questions_for_category_loaded() is deprecated, please use the function ' .
293
                'ensure_filtered_questions_loaded.',
294
            DEBUG_DEVELOPER
295
        );
296
 
297
        global $DB;
298
 
299
        $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
300
 
301
        if (isset($this->availablequestionscache[$categorykey])) {
302
            // Data is already in the cache, nothing to do.
303
            return;
304
        }
305
 
306
        // Load the available questions from the question bank.
307
        if ($includesubcategories) {
308
            $categoryids = question_categorylist($categoryid);
309
        } else {
310
            $categoryids = [$categoryid];
311
        }
312
 
313
        list($extraconditions, $extraparams) = $DB->get_in_or_equal($this->excludedqtypes,
314
                SQL_PARAMS_NAMED, 'excludedqtype', false);
315
 
316
        $questionidsandcounts = \question_bank::get_finder()->get_questions_from_categories_and_tags_with_usage_counts(
317
                $categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams, $tagids);
318
        if (!$questionidsandcounts) {
319
            // No questions in this category.
320
            $this->availablequestionscache[$categorykey] = [];
321
            return;
322
        }
323
 
324
        // Put all the questions with each value of $prevusecount in separate arrays.
325
        $idsbyusecount = [];
326
        foreach ($questionidsandcounts as $questionid => $prevusecount) {
327
            if (isset($this->recentlyusedquestions[$questionid])) {
328
                // Recently used questions are never returned.
329
                continue;
330
            }
331
            $idsbyusecount[$prevusecount][] = $questionid;
332
        }
333
 
334
        // Now put that data into our cache. For each count, we need to shuffle
335
        // questionids, and make those the keys of an array.
336
        $this->availablequestionscache[$categorykey] = [];
337
        foreach ($idsbyusecount as $prevusecount => $questionids) {
338
            shuffle($questionids);
339
            $this->availablequestionscache[$categorykey][$prevusecount] = array_combine(
340
                    $questionids, array_fill(0, count($questionids), 1));
341
        }
342
        ksort($this->availablequestionscache[$categorykey]);
343
    }
344
 
345
    /**
346
     * Update the internal data structures to indicate that a given question has
347
     * been used one more time.
348
     *
349
     * @param int $questionid the question that is being used.
350
     */
351
    protected function use_question($questionid): void {
352
        if (isset($this->recentlyusedquestions[$questionid])) {
353
            $this->recentlyusedquestions[$questionid] += 1;
354
        } else {
355
            $this->recentlyusedquestions[$questionid] = 1;
356
        }
357
 
358
        foreach ($this->availablequestionscache as $categorykey => $questionsforcategory) {
359
            foreach ($questionsforcategory as $numuses => $questionids) {
360
                if (!isset($questionids[$questionid])) {
361
                    continue;
362
                }
363
                unset($this->availablequestionscache[$categorykey][$numuses][$questionid]);
364
                if (empty($this->availablequestionscache[$categorykey][$numuses])) {
365
                    unset($this->availablequestionscache[$categorykey][$numuses]);
366
                }
367
            }
368
        }
369
    }
370
 
371
    /**
372
     * Get filtered questions.
373
     *
374
     * @param array $filters filter array
375
     * @return array list of filtered questions
376
     */
377
    protected function get_filtered_question_ids(array $filters): array {
378
        $this->ensure_filtered_questions_loaded($filters);
379
        $key = $this->get_filtered_questions_key($filters);
380
 
381
        $cachedvalues = $this->availablequestionscache[$key];
382
        $questionids = [];
383
 
384
        foreach ($cachedvalues as $usecount => $ids) {
385
            $questionids = array_merge($questionids, array_keys($ids));
386
        }
387
 
388
        return $questionids;
389
    }
390
 
391
    /**
392
     * Get the list of available question ids for the given criteria.
393
     *
394
     * @param int $categoryid The id of a category in the question bank.
395
     * @param bool $includesubcategories Whether to pick a question from exactly
396
     *      that category, or that category and subcategories.
397
     * @param array $tagids An array of tag ids. If an array is provided, then
398
     *      only the questions that are tagged with ALL the provided tagids will be loaded.
399
     * @return int[] The list of question ids
400
     * @deprecated since Moodle 4.3
401
     * @todo Final deprecation on Moodle 4.7 MDL-78091
402
     */
403
    protected function get_question_ids($categoryid, $includesubcategories, $tagids = []): array {
404
        debugging(
405
            'Function get_question_ids() is deprecated, please use get_filtered_question_ids() instead.',
406
            DEBUG_DEVELOPER
407
        );
408
 
409
        $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids);
410
        $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
411
        $cachedvalues = $this->availablequestionscache[$categorykey];
412
        $questionids = [];
413
 
414
        foreach ($cachedvalues as $usecount => $ids) {
415
            $questionids = array_merge($questionids, array_keys($ids));
416
        }
417
 
418
        return $questionids;
419
    }
420
 
421
    /**
422
     * Check whether a given question is available in a given category. If so, mark it used.
423
     * If an optional list of tag ids are provided, then the question must be tagged with
424
     * ALL of the provided tags to be considered as available.
425
     *
426
     * @param array $filters filter array
427
     * @param int $questionid the question that is being used.
428
     * @return bool whether the question is available in the requested category.
429
     */
430
    public function is_filtered_question_available(array $filters, int $questionid): bool {
431
        $this->ensure_filtered_questions_loaded($filters);
432
        $categorykey = $this->get_filtered_questions_key($filters);
433
 
434
        foreach ($this->availablequestionscache[$categorykey] as $questionids) {
435
            if (isset($questionids[$questionid])) {
436
                $this->use_question($questionid);
437
                return true;
438
            }
439
        }
440
 
441
        return false;
442
    }
443
 
444
    /**
445
     * Check whether a given question is available in a given category. If so, mark it used.
446
     * If an optional list of tag ids are provided, then the question must be tagged with
447
     * ALL of the provided tags to be considered as available.
448
     *
449
     * @param int $categoryid the id of a category in the question bank.
450
     * @param bool $includesubcategories wether to pick a question from exactly
451
     *      that category, or that category and subcategories.
452
     * @param int $questionid the question that is being used.
453
     * @param array $tagids An array of tag ids. Only the questions that are tagged with all the provided tagids can be available.
454
     * @return bool whether the question is available in the requested category.
455
     * @deprecated since Moodle 4.3
456
     * @todo Final deprecation on Moodle 4.7 MDL-78091
457
     */
458
    public function is_question_available($categoryid, $includesubcategories, $questionid, $tagids = []): bool {
459
        debugging(
460
            'Function is_question_available() is deprecated, please use is_filtered_question_available() instead.',
461
            DEBUG_DEVELOPER
462
        );
463
        $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids);
464
        $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
465
 
466
        foreach ($this->availablequestionscache[$categorykey] as $questionids) {
467
            if (isset($questionids[$questionid])) {
468
                $this->use_question($questionid);
469
                return true;
470
            }
471
        }
472
 
473
        return false;
474
    }
475
 
476
    /**
477
     * Get the list of available questions for the given criteria.
478
     *
479
     * @param array $filters filter array
480
     * @param int $limit Maximum number of results to return.
481
     * @param int $offset Number of items to skip from the begging of the result set.
482
     * @param string[] $fields The fields to return for each question.
483
     * @return \stdClass[] The list of question records
484
     */
485
    public function get_filtered_questions($filters, $limit = 100, $offset = 0, $fields = []) {
486
        global $DB;
487
 
488
        $questionids = $this->get_filtered_question_ids($filters);
489
 
490
        if (empty($questionids)) {
491
            return [];
492
        }
493
 
494
        if (empty($fields)) {
495
            // Return all fields.
496
            $fieldsstring = '*';
497
        } else {
498
            $fieldsstring = implode(',', $fields);
499
        }
500
 
501
        // Create the query to get the questions (validate that at least we have a question id. If not, do not execute the sql).
502
        $hasquestions = false;
503
        if (!empty($questionids)) {
504
            $hasquestions = true;
505
        }
506
        if ($hasquestions) {
507
            [$condition, $param] = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid');
508
            $condition = 'WHERE q.id ' . $condition;
509
            $sql = "SELECT {$fieldsstring}
510
                      FROM (SELECT q.*, qbe.questioncategoryid as category
511
                      FROM {question} q
512
                      JOIN {question_versions} qv ON qv.questionid = q.id
513
                      JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
514
                      {$condition}) q
515
                  ORDER BY q.id";
516
 
517
            return $DB->get_records_sql($sql, $param, $offset, $limit);
518
        } else {
519
            return [];
520
        }
521
    }
522
 
523
    /**
524
     * Get the list of available questions for the given criteria.
525
     *
526
     * @param int $categoryid The id of a category in the question bank.
527
     * @param bool $includesubcategories Whether to pick a question from exactly
528
     *      that category, or that category and subcategories.
529
     * @param array $tagids An array of tag ids. If an array is provided, then
530
     *      only the questions that are tagged with ALL the provided tagids will be loaded.
531
     * @param int $limit Maximum number of results to return.
532
     * @param int $offset Number of items to skip from the begging of the result set.
533
     * @param string[] $fields The fields to return for each question.
534
     * @return \stdClass[] The list of question records
535
     * @deprecated since Moodle 4.3
536
     * @todo Final deprecation on Moodle 4.7 MDL-78091
537
     */
538
    public function get_questions($categoryid, $includesubcategories, $tagids = [], $limit = 100, $offset = 0, $fields = []) {
539
        debugging(
540
            'Function get_questions() is deprecated, please use get_filtered_questions() instead.',
541
            DEBUG_DEVELOPER
542
        );
543
        global $DB;
544
 
545
        $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids);
546
        if (empty($questionids)) {
547
            return [];
548
        }
549
 
550
        if (empty($fields)) {
551
            // Return all fields.
552
            $fieldsstring = '*';
553
        } else {
554
            $fieldsstring = implode(',', $fields);
555
        }
556
 
557
        // Create the query to get the questions (validate that at least we have a question id. If not, do not execute the sql).
558
        $hasquestions = false;
559
        if (!empty($questionids)) {
560
            $hasquestions = true;
561
        }
562
        if ($hasquestions) {
563
            list($condition, $param) = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid');
564
            $condition = 'WHERE q.id ' . $condition;
565
            $sql = "SELECT {$fieldsstring}
566
                      FROM (SELECT q.*, qbe.questioncategoryid as category
567
                      FROM {question} q
568
                      JOIN {question_versions} qv ON qv.questionid = q.id
569
                      JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
570
                      {$condition}) q ORDER BY q.id";
571
 
572
            return $DB->get_records_sql($sql, $param, $offset, $limit);
573
        } else {
574
            return [];
575
        }
576
    }
577
 
578
    /**
579
     * Count number of filtered questions
580
     *
581
     * @param array $filters filter array
582
     * @return int number of question
583
     */
584
    public function count_filtered_questions(array $filters): int {
585
        $questionids = $this->get_filtered_question_ids($filters);
586
        return count($questionids);
587
    }
588
 
589
    /**
590
     * Count the number of available questions for the given criteria.
591
     *
592
     * @param int $categoryid The id of a category in the question bank.
593
     * @param bool $includesubcategories Whether to pick a question from exactly
594
     *      that category, or that category and subcategories.
595
     * @param array $tagids An array of tag ids. If an array is provided, then
596
     *      only the questions that are tagged with ALL the provided tagids will be loaded.
597
     * @return int The number of questions matching the criteria.
598
     * @deprecated since Moodle 4.3
599
     * @todo Final deprecation on Moodle 4.7 MDL-78091
600
     */
601
    public function count_questions($categoryid, $includesubcategories, $tagids = []): int {
602
        debugging(
603
            'Function count_questions() is deprecated, please use count_filtered_questions() instead.',
604
            DEBUG_DEVELOPER
605
        );
606
        $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids);
607
        return count($questionids);
608
    }
609
}