Proyectos de Subversion Moodle

Rev

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