<?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 random question based on filter conditions
* 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.
> * @param array $filters filter array
*
> * @return int|null
* It is up the the caller to verify that the cateogry exists. An unknown category
> */
* behaves like an empty one.
> public function get_next_filtered_question_id(array $filters): ?int {
*
> $this->ensure_filtered_questions_loaded($filters);
* @param int $categoryid the id of a category in the question bank.
>
* @param bool $includesubcategories wether to pick a question from exactly
> $key = $this->get_filtered_questions_key($filters);
* that category, or that category and subcategories.
> if (empty($this->availablequestionscache[$key])) {
* @param array $tagids An array of tag ids. A question has to be tagged with all the provided tagids (if any)
> return null;
* in order to be eligible for being picked.
> }
* @return int|null the id of the question picked, or null if there aren't any.
>
*/
> reset($this->availablequestionscache[$key]);
public function get_next_question_id($categoryid, $includesubcategories, $tagids = []): ?int {
> $lowestcount = key($this->availablequestionscache[$key]);
$this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids);
> reset($this->availablequestionscache[$key][$lowestcount]);
> $questionid = key($this->availablequestionscache[$key][$lowestcount]);
$categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
> $this->use_question($questionid);
if (empty($this->availablequestionscache[$categorykey])) {
> return $questionid;
return null;
> }
}
>
>
reset($this->availablequestionscache[$categorykey]);
> /**
$lowestcount = key($this->availablequestionscache[$categorykey]);
> * @deprecated since Moodle 4.3
reset($this->availablequestionscache[$categorykey][$lowestcount]);
> * @todo Final deprecation on Moodle 4.7 MDL-78091
$questionid = key($this->availablequestionscache[$categorykey][$lowestcount]);
> debugging(
$this->use_question($questionid);
> 'Function get_next_question_id() is deprecated, please use get_next_filtered_question_id() instead.',
return $questionid;
> DEBUG_DEVELOPER
}
> );
>
/**
> * Key for filtered questions.
* Get the key into {@see $availablequestionscache} for this combination of options.
> * This function replace get_category_key
*
> *
* @param int $categoryid the id of a category in the question bank.
> * @param array $filters filter array
* @param bool $includesubcategories wether to pick a question from exactly
> * @return String
* that category, or that category and subcategories.
> */
* @param array $tagids an array of tag ids.
> protected function get_filtered_questions_key(array $filters): String {
* @return string the cache key.
> return sha1(json_encode($filters));
*/
> }
protected function get_category_key($categoryid, $includesubcategories, $tagids = []): string {
>
if ($includesubcategories) {
> /**
$key = $categoryid . '|1';
> *
} else {
> * @deprecated since Moodle 4.3
$key = $categoryid . '|0';
> * @todo Final deprecation on Moodle 4.7 MDL-78091
}
> debugging(
> 'Function get_category_key() is deprecated, please get_fitlered_questions_key instead.',
if (!empty($tagids)) {
> DEBUG_DEVELOPER
$key .= '|' . implode('|', $tagids);
> );
}
return $key;
}
/**
> * Populate {@see $availablequestionscache} according to filter conditions.
* Populate {@see $availablequestionscache} for this combination of options.
> *
*
> * @param array $filters filter array
* @param int $categoryid The id of a category in the question bank.
> * @return void
* @param bool $includesubcategories Whether to pick a question from exactly
> */
* that category, or that category and subcategories.
> protected function ensure_filtered_questions_loaded(array $filters) {
* @param array $tagids An array of tag ids. If an array is provided, then
> global $DB;
* only the questions that are tagged with ALL the provided tagids will be loaded.
>
*/
> $key = $this->get_filtered_questions_key($filters);
protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids = []): void {
> if (isset($this->availablequestionscache[$key])) {
global $DB;
> // Data is already in the cache, nothing to do.
> return;
$categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
> }
>
if (isset($this->availablequestionscache[$categorykey])) {
> [$extraconditions, $extraparams] = $DB->get_in_or_equal($this->excludedqtypes,
// Data is already in the cache, nothing to do.
> SQL_PARAMS_NAMED, 'excludedqtype', false);
return;
>
}
> $previoussql = "SELECT COUNT(1)
> FROM " . $this->qubaids->from_question_attempts('qa') . "
// Load the available questions from the question bank.
> WHERE qa.questionid = q.id AND " . $this->qubaids->where();
if ($includesubcategories) {
> $previousparams = $this->qubaids->from_where_params();
$categoryids = question_categorylist($categoryid);
>
} else {
> // Latest version.
$categoryids = [$categoryid];
> $latestversionsql = "SELECT MAX(v.version)
}
> FROM {question_versions} v
> JOIN {question_bank_entries} be ON be.id = v.questionbankentryid
list($extraconditions, $extraparams) = $DB->get_in_or_equal($this->excludedqtypes,
> WHERE be.id = qbe.id";
SQL_PARAMS_NAMED, 'excludedqtype', false);
>
> $sql = "SELECT q.id, ($previoussql) AS previous_attempts
$questionidsandcounts = \question_bank::get_finder()->get_questions_from_categories_and_tags_with_usage_counts(
> FROM {question} q
$categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams, $tagids);
> JOIN {question_versions} qv ON qv.questionid = q.id
if (!$questionidsandcounts) {
> JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
// No questions in this category.
> WHERE ";
$this->availablequestionscache[$categorykey] = [];
>
return;
> $where = [
}
> 'q.parent = :noparent',
> 'qv.status = :ready',
// Put all the questions with each value of $prevusecount in separate arrays.
> "qv.version = ($latestversionsql)",
$idsbyusecount = [];
> ];
foreach ($questionidsandcounts as $questionid => $prevusecount) {
> $params = array_merge(
if (isset($this->recentlyusedquestions[$questionid])) {
> $previousparams,
// Recently used questions are never returned.
> ['noparent' => 0, 'ready' => question_version_status::QUESTION_STATUS_READY]);
continue;
>
}
> // Get current enabled condition classes.
$idsbyusecount[$prevusecount][] = $questionid;
> $conditionclasses = \core_question\local\bank\filter_condition_manager::get_condition_classes();
}
> // Build filter conditions.
> foreach ($conditionclasses as $conditionclass) {
// Now put that data into our cache. For each count, we need to shuffle
> $filter = $conditionclass::get_filter_from_list($filters);
// questionids, and make those the keys of an array.
> if (is_null($filter)) {
$this->availablequestionscache[$categorykey] = [];
> continue;
foreach ($idsbyusecount as $prevusecount => $questionids) {
> }
shuffle($questionids);
> [$filterwhere, $filterparams] = $conditionclass::build_query_from_filter($filter);
$this->availablequestionscache[$categorykey][$prevusecount] = array_combine(
> if (!empty($filterwhere)) {
$questionids, array_fill(0, count($questionids), 1));
> $where[] = '(' . $filterwhere . ')';
}
> }
ksort($this->availablequestionscache[$categorykey]);
> if (!empty($filterparams)) {
}
> $params = array_merge($params, $filterparams);
> }
/**
> }
* Update the internal data structures to indicate that a given question has
>
* been used one more time.
> // Extra conditions.
*
> if ($extraconditions) {
* @param int $questionid the question that is being used.
> $where[] = 'q.qtype ' . $extraconditions;
*/
> $params = array_merge($params, $extraparams);
protected function use_question($questionid): void {
> }
if (isset($this->recentlyusedquestions[$questionid])) {
>
$this->recentlyusedquestions[$questionid] += 1;
> // Build query.
} else {
> $sql .= implode(' AND ', $where);
$this->recentlyusedquestions[$questionid] = 1;
> $sql .= "ORDER BY previous_attempts";
}
>
> $questionidsandcounts = $DB->get_records_sql_menu($sql, $params);
foreach ($this->availablequestionscache as $categorykey => $questionsforcategory) {
>
foreach ($questionsforcategory as $numuses => $questionids) {
> if (!$questionidsandcounts) {
if (!isset($questionids[$questionid])) {
> // No questions in this category.
continue;
> $this->availablequestionscache[$key] = [];
}
> return;
unset($this->availablequestionscache[$categorykey][$numuses][$questionid]);
> }
if (empty($this->availablequestionscache[$categorykey][$numuses])) {
>
unset($this->availablequestionscache[$categorykey][$numuses]);
> // 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;
/**
> }
* Get the list of available question ids for the given criteria.
> $idsbyusecount[$prevusecount][] = $questionid;
*
> }
* @param int $categoryid The id of a category in the question bank.
>
* @param bool $includesubcategories Whether to pick a question from exactly
> // Now put that data into our cache. For each count, we need to shuffle
* that category, or that category and subcategories.
> // questionids, and make those the keys of an array.
* @param array $tagids An array of tag ids. If an array is provided, then
> $this->availablequestionscache[$key] = [];
* only the questions that are tagged with ALL the provided tagids will be loaded.
> foreach ($idsbyusecount as $prevusecount => $questionids) {
* @return int[] The list of question ids
> shuffle($questionids);
*/
> $this->availablequestionscache[$key][$prevusecount] = array_combine(
protected function get_question_ids($categoryid, $includesubcategories, $tagids = []): array {
> $questionids, array_fill(0, count($questionids), 1));
$this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids);
> }
$categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
> ksort($this->availablequestionscache[$key]);
$cachedvalues = $this->availablequestionscache[$categorykey];
> }
$questionids = [];
>
> /**
foreach ($cachedvalues as $usecount => $ids) {
> * @deprecated since Moodle 4.3
$questionids = array_merge($questionids, array_keys($ids));
> * @todo Final deprecation on Moodle 4.7 MDL-78091
}
> debugging(
> 'Function ensure_questions_for_category_loaded() is deprecated, please use the function ' .
return $questionids;
> 'ensure_filtered_questions_loaded.',
}
> DEBUG_DEVELOPER
> );
/**
>
* Check whether a given question is available in a given category. If so, mark it used.
> * Get filtered questions.
* 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 array $filters filter array
*
> * @return array list of filtered questions
* @param int $categoryid the id of a category in the question bank.
> */
* @param bool $includesubcategories wether to pick a question from exactly
> protected function get_filtered_question_ids(array $filters): array {
* that category, or that category and subcategories.
> $this->ensure_filtered_questions_loaded($filters);
* @param int $questionid the question that is being used.
> $key = $this->get_filtered_questions_key($filters);
* @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.
> $cachedvalues = $this->availablequestionscache[$key];
*/
> $questionids = [];
public function is_question_available($categoryid, $includesubcategories, $questionid, $tagids = []): bool {
>
$this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids);
> foreach ($cachedvalues as $usecount => $ids) {
$categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
> $questionids = array_merge($questionids, array_keys($ids));
> }
foreach ($this->availablequestionscache[$categorykey] as $questionids) {
>
if (isset($questionids[$questionid])) {
> return $questionids;
$this->use_question($questionid);
> }
return true;
>
}
> /**
}
> * @deprecated since Moodle 4.3
> * @todo Final deprecation on Moodle 4.7 MDL-78091
return false;
> debugging(
}
> 'Function get_question_ids() is deprecated, please use get_filtered_question_ids() instead.',
> DEBUG_DEVELOPER
/**
> );
* Get the list of available questions for the given criteria.
>
*
> * @param array $filters filter array
* @param int $categoryid The id of a category in the question bank.
> * @param int $questionid the question that is being used.
* @param bool $includesubcategories Whether to pick a question from exactly
> * @return bool whether the question is available in the requested category.
* that category, or that category and subcategories.
> */
* @param array $tagids An array of tag ids. If an array is provided, then
> public function is_filtered_question_available(array $filters, int $questionid): bool {
* only the questions that are tagged with ALL the provided tagids will be loaded.
> $this->ensure_filtered_questions_loaded($filters);
* @param int $limit Maximum number of results to return.
> $categorykey = $this->get_filtered_questions_key($filters);
* @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.
> foreach ($this->availablequestionscache[$categorykey] as $questionids) {
* @return \stdClass[] The list of question records
> if (isset($questionids[$questionid])) {
*/
> $this->use_question($questionid);
public function get_questions($categoryid, $includesubcategories, $tagids = [], $limit = 100, $offset = 0, $fields = []) {
> return true;
global $DB;
> }
> }
$questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids);
>
if (empty($questionids)) {
> return false;
return [];
> }
}
>
> /**
if (empty($fields)) {
> * Check whether a given question is available in a given category. If so, mark it used.
// Return all fields.
> * If an optional list of tag ids are provided, then the question must be tagged with
$fieldsstring = '*';
> * ALL of the provided tags to be considered as available.
} else {
> *
$fieldsstring = implode(',', $fields);
> * @deprecated since Moodle 4.3
}
> * @todo Final deprecation on Moodle 4.7 MDL-78091
> debugging(
// Create the query to get the questions (validate that at least we have a question id. If not, do not execute the sql).
> 'Function is_question_available() is deprecated, please use is_filtered_question_available() instead.',
$hasquestions = false;
> DEBUG_DEVELOPER
if (!empty($questionids)) {
> );
$hasquestions = true;
> * @param array $filters filter array
}
> * @param int $limit Maximum number of results to return.
if ($hasquestions) {
> * @param int $offset Number of items to skip from the begging of the result set.
list($condition, $param) = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid');
> * @param string[] $fields The fields to return for each question.
$condition = 'WHERE q.id ' . $condition;
> * @return \stdClass[] The list of question records
$sql = "SELECT {$fieldsstring}
> */
FROM (SELECT q.*, qbe.questioncategoryid as category
> public function get_filtered_questions($filters, $limit = 100, $offset = 0, $fields = []) {
FROM {question} q
> global $DB;
JOIN {question_versions} qv ON qv.questionid = q.id
>
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
> $questionids = $this->get_filtered_question_ids($filters);
{$condition}) q ORDER BY q.id";
>
> if (empty($questionids)) {
return $DB->get_records_sql($sql, $param, $offset, $limit);
> return [];
} else {
> }
return [];
>
}
> if (empty($fields)) {
}
> // Return all fields.
> $fieldsstring = '*';
/**
> } else {
* Count the number of available questions for the given criteria.
> $fieldsstring = implode(',', $fields);
*
> }
* @param int $categoryid The id of a category in the question bank.
>
* @param bool $includesubcategories Whether to pick a question from exactly
> // Create the query to get the questions (validate that at least we have a question id. If not, do not execute the sql).
* that category, or that category and subcategories.
> $hasquestions = false;
* @param array $tagids An array of tag ids. If an array is provided, then
> if (!empty($questionids)) {
* only the questions that are tagged with ALL the provided tagids will be loaded.
> $hasquestions = true;
* @return int The number of questions matching the criteria.
> }
*/
> if ($hasquestions) {
public function count_questions($categoryid, $includesubcategories, $tagids = []): int {
> [$condition, $param] = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid');
$questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids);
> $condition = 'WHERE q.id ' . $condition;
return count($questionids);
> $sql = "SELECT {$fieldsstring}
}
> FROM (SELECT q.*, qbe.questioncategoryid as category
}
> FROM {question} q
> JOIN {question_versions} qv ON qv.questionid = q.id
> JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
> {$condition}) q";
>
> return $DB->get_records_sql($sql, $param, $offset, $limit);
> } else {
> return [];
> }
> }
>
> /**
> * Get the list of available questions for the given criteria.
> *
> * @deprecated since Moodle 4.3
> * @todo Final deprecation on Moodle 4.7 MDL-78091
> debugging(
> 'Function get_questions() is deprecated, please use get_filtered_questions() instead.',
> DEBUG_DEVELOPER
> );
> * Count number of filtered questions
> *
> * @param array $filters filter array
> * @return int number of question
> */
> public function count_filtered_questions(array $filters): int {
> $questionids = $this->get_filtered_question_ids($filters);
> return count($questionids);
> }
>
> /**
> * @deprecated since Moodle 4.3
> * @todo Final deprecation on Moodle 4.7 MDL-78091
> debugging(
> 'Function count_questions() is deprecated, please use count_filtered_questions() instead.',
> DEBUG_DEVELOPER
> );