moodle/question/classes/local/bank/random_question_loader.php
safatshahin dfed4fd040 MDL-71516 core_question: Qbank api implementation
This commit implements the qbank api so that any plugin
can implement its own question bank. This api currently
works parallely with the moodle core classes and the
added qbank in the core, means the moment a plugin
is installed, that object is replaced with the object
from the plugin instead of core, which means the api
has flexibility till the plugins are integrated and the
plugins can be integrated in any order.

All the old classes are still there and not deprecated
as there is a different tracker for the changes to the
quiz and another tracker for class deprecation and
class renaming. Core question units tests are pointing
to the new api structure but the classes are pointing
to the location related to the plugin availability.

Co-Authored-By: Luca Bösch <luca.boesch@bfh.ch>
Co-Authored-By: Guillermo Gomez Arias <guillermogomez@catalyst-au.net>

one more array fix
2021-08-17 18:57:31 +10:00

311 lines
14 KiB
PHP

<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* A class for efficiently finds questions at random from the question bank.
*
* @package core_question
* @copyright 2015 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\local\bank;
/**
* This class efficiently finds questions at random from the question bank.
*
* You can ask for questions at random one at a time. Each time you ask, you
* pass a category id, and whether to pick from that category and all subcategories
* or just that category.
*
* The number of teams each question has been used is tracked, and we will always
* return a question from among those elegible that has been used the fewest times.
* So, if there are questions that have not been used yet in the category asked for,
* one of those will be returned. However, within one instantiation of this class,
* we will never return a given question more than once, and we will never return
* questions passed into the constructor as $usedquestions.
*
* @copyright 2015 The Open University
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class random_question_loader {
/** @var \qubaid_condition which usages to consider previous attempts from. */
protected $qubaids;
/** @var array qtypes that cannot be used by random questions. */
protected $excludedqtypes;
/** @var array categoryid & include subcategories => num previous uses => questionid => 1. */
protected $availablequestionscache = [];
/**
* @var array questionid => num recent uses. Questions that have been used,
* but that is not yet recorded in the DB.
*/
protected $recentlyusedquestions;
/**
* Constructor.
*
* @param \qubaid_condition $qubaids the usages to consider when counting previous uses of each question.
* @param array $usedquestions questionid => number of times used count. If we should allow for
* further existing uses of a question in addition to the ones in $qubaids.
*/
public function __construct(\qubaid_condition $qubaids, array $usedquestions = []) {
$this->qubaids = $qubaids;
$this->recentlyusedquestions = $usedquestions;
foreach (\question_bank::get_all_qtypes() as $qtype) {
if (!$qtype->is_usable_by_random()) {
$this->excludedqtypes[] = $qtype->name();
}
}
}
/**
* Pick a question at random from the given category, from among those with the fewest uses.
* If an array of tag ids are specified, then only the questions that are tagged with ALL those tags will be selected.
*
* It is up the the caller to verify that the cateogry exists. An unknown category
* behaves like an empty one.
*
* @param int $categoryid the id of a category in the question bank.
* @param bool $includesubcategories wether to pick a question from exactly
* that category, or that category and subcategories.
* @param array $tagids An array of tag ids. A question has to be tagged with all the provided tagids (if any)
* in order to be eligible for being picked.
* @return int|null the id of the question picked, or null if there aren't any.
*/
public function get_next_question_id($categoryid, $includesubcategories, $tagids = []): ?int {
$this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids);
$categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
if (empty($this->availablequestionscache[$categorykey])) {
return null;
}
reset($this->availablequestionscache[$categorykey]);
$lowestcount = key($this->availablequestionscache[$categorykey]);
reset($this->availablequestionscache[$categorykey][$lowestcount]);
$questionid = key($this->availablequestionscache[$categorykey][$lowestcount]);
$this->use_question($questionid);
return $questionid;
}
/**
* Get the key into {@see $availablequestionscache} for this combination of options.
*
* @param int $categoryid the id of a category in the question bank.
* @param bool $includesubcategories wether to pick a question from exactly
* that category, or that category and subcategories.
* @param array $tagids an array of tag ids.
* @return string the cache key.
*/
protected function get_category_key($categoryid, $includesubcategories, $tagids = []): string {
if ($includesubcategories) {
$key = $categoryid . '|1';
} else {
$key = $categoryid . '|0';
}
if (!empty($tagids)) {
$key .= '|' . implode('|', $tagids);
}
return $key;
}
/**
* Populate {@see $availablequestionscache} for this combination of options.
*
* @param int $categoryid The id of a category in the question bank.
* @param bool $includesubcategories Whether to pick a question from exactly
* that category, or that category and subcategories.
* @param array $tagids An array of tag ids. If an array is provided, then
* only the questions that are tagged with ALL the provided tagids will be loaded.
*/
protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids = []): void {
global $DB;
$categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
if (isset($this->availablequestionscache[$categorykey])) {
// Data is already in the cache, nothing to do.
return;
}
// Load the available questions from the question bank.
if ($includesubcategories) {
$categoryids = question_categorylist($categoryid);
} else {
$categoryids = [$categoryid];
}
list($extraconditions, $extraparams) = $DB->get_in_or_equal($this->excludedqtypes,
SQL_PARAMS_NAMED, 'excludedqtype', false);
$questionidsandcounts = \question_bank::get_finder()->get_questions_from_categories_and_tags_with_usage_counts(
$categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams, $tagids);
if (!$questionidsandcounts) {
// No questions in this category.
$this->availablequestionscache[$categorykey] = [];
return;
}
// Put all the questions with each value of $prevusecount in separate arrays.
$idsbyusecount = [];
foreach ($questionidsandcounts as $questionid => $prevusecount) {
if (isset($this->recentlyusedquestions[$questionid])) {
// Recently used questions are never returned.
continue;
}
$idsbyusecount[$prevusecount][] = $questionid;
}
// Now put that data into our cache. For each count, we need to shuffle
// questionids, and make those the keys of an array.
$this->availablequestionscache[$categorykey] = [];
foreach ($idsbyusecount as $prevusecount => $questionids) {
shuffle($questionids);
$this->availablequestionscache[$categorykey][$prevusecount] = array_combine(
$questionids, array_fill(0, count($questionids), 1));
}
ksort($this->availablequestionscache[$categorykey]);
}
/**
* Update the internal data structures to indicate that a given question has
* been used one more time.
*
* @param int $questionid the question that is being used.
*/
protected function use_question($questionid): void {
if (isset($this->recentlyusedquestions[$questionid])) {
$this->recentlyusedquestions[$questionid] += 1;
} else {
$this->recentlyusedquestions[$questionid] = 1;
}
foreach ($this->availablequestionscache as $categorykey => $questionsforcategory) {
foreach ($questionsforcategory as $numuses => $questionids) {
if (!isset($questionids[$questionid])) {
continue;
}
unset($this->availablequestionscache[$categorykey][$numuses][$questionid]);
if (empty($this->availablequestionscache[$categorykey][$numuses])) {
unset($this->availablequestionscache[$categorykey][$numuses]);
}
}
}
}
/**
* Get the list of available question ids for the given criteria.
*
* @param int $categoryid The id of a category in the question bank.
* @param bool $includesubcategories Whether to pick a question from exactly
* that category, or that category and subcategories.
* @param array $tagids An array of tag ids. If an array is provided, then
* only the questions that are tagged with ALL the provided tagids will be loaded.
* @return int[] The list of question ids
*/
protected function get_question_ids($categoryid, $includesubcategories, $tagids = []): array {
$this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids);
$categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
$cachedvalues = $this->availablequestionscache[$categorykey];
$questionids = [];
foreach ($cachedvalues as $usecount => $ids) {
$questionids = array_merge($questionids, array_keys($ids));
}
return $questionids;
}
/**
* Check whether a given question is available in a given category. If so, mark it used.
* If an optional list of tag ids are provided, then the question must be tagged with
* ALL of the provided tags to be considered as available.
*
* @param int $categoryid the id of a category in the question bank.
* @param bool $includesubcategories wether to pick a question from exactly
* that category, or that category and subcategories.
* @param int $questionid the question that is being used.
* @param array $tagids An array of tag ids. Only the questions that are tagged with all the provided tagids can be available.
* @return bool whether the question is available in the requested category.
*/
public function is_question_available($categoryid, $includesubcategories, $questionid, $tagids = []): bool {
$this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids);
$categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
foreach ($this->availablequestionscache[$categorykey] as $questionids) {
if (isset($questionids[$questionid])) {
$this->use_question($questionid);
return true;
}
}
return false;
}
/**
* Get the list of available questions for the given criteria.
*
* @param int $categoryid The id of a category in the question bank.
* @param bool $includesubcategories Whether to pick a question from exactly
* that category, or that category and subcategories.
* @param array $tagids An array of tag ids. If an array is provided, then
* only the questions that are tagged with ALL the provided tagids will be loaded.
* @param int $limit Maximum number of results to return.
* @param int $offset Number of items to skip from the begging of the result set.
* @param string[] $fields The fields to return for each question.
* @return \stdClass[] The list of question records
*/
public function get_questions($categoryid, $includesubcategories, $tagids = [], $limit = 100, $offset = 0, $fields = []) {
global $DB;
$questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids);
if (empty($questionids)) {
return [];
}
if (empty($fields)) {
// Return all fields.
$fieldsstring = '*';
} else {
$fieldsstring = implode(',', $fields);
}
return $DB->get_records_list('question', 'id', $questionids, 'id', $fieldsstring, $offset, $limit);
}
/**
* Count the number of available questions for the given criteria.
*
* @param int $categoryid The id of a category in the question bank.
* @param bool $includesubcategories Whether to pick a question from exactly
* that category, or that category and subcategories.
* @param array $tagids An array of tag ids. If an array is provided, then
* only the questions that are tagged with ALL the provided tagids will be loaded.
* @return int The number of questions matching the criteria.
*/
public function count_questions($categoryid, $includesubcategories, $tagids = []): int {
$questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids);
return count($questionids);
}
}