random_question_loader.php 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  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. * A class for efficiently finds questions at random from the question bank.
  18. *
  19. * @package core_question
  20. * @copyright 2015 The Open University
  21. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  22. */
  23. namespace core_question\bank;
  24. /**
  25. * This class efficiently finds questions at random from the question bank.
  26. *
  27. * You can ask for questions at random one at a time. Each time you ask, you
  28. * pass a category id, and whether to pick from that category and all subcategories
  29. * or just that category.
  30. *
  31. * The number of teams each question has been used is tracked, and we will always
  32. * return a question from among those elegible that has been used the fewest times.
  33. * So, if there are questions that have not been used yet in the category asked for,
  34. * one of those will be returned. However, within one instantiation of this class,
  35. * we will never return a given question more than once, and we will never return
  36. * questions passed into the constructor as $usedquestions.
  37. *
  38. * @copyright 2015 The Open University
  39. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  40. */
  41. class random_question_loader {
  42. /** @var \qubaid_condition which usages to consider previous attempts from. */
  43. protected $qubaids;
  44. /** @var array qtypes that cannot be used by random questions. */
  45. protected $excludedqtypes;
  46. /** @var array categoryid & include subcategories => num previous uses => questionid => 1. */
  47. protected $availablequestionscache = array();
  48. /**
  49. * @var array questionid => num recent uses. Questions that have been used,
  50. * but that is not yet recorded in the DB.
  51. */
  52. protected $recentlyusedquestions;
  53. /**
  54. * Constructor.
  55. * @param \qubaid_condition $qubaids the usages to consider when counting previous uses of each question.
  56. * @param array $usedquestions questionid => number of times used count. If we should allow for
  57. * further existing uses of a question in addition to the ones in $qubaids.
  58. */
  59. public function __construct(\qubaid_condition $qubaids, array $usedquestions = array()) {
  60. $this->qubaids = $qubaids;
  61. $this->recentlyusedquestions = $usedquestions;
  62. foreach (\question_bank::get_all_qtypes() as $qtype) {
  63. if (!$qtype->is_usable_by_random()) {
  64. $this->excludedqtypes[] = $qtype->name();
  65. }
  66. }
  67. }
  68. /**
  69. * Pick a question at random from the given category, from among those with the fewest uses.
  70. *
  71. * It is up the the caller to verify that the cateogry exists. An unknown category
  72. * behaves like an empty one.
  73. *
  74. * @param int $categoryid the id of a category in the question bank.
  75. * @param bool $includesubcategories wether to pick a question from exactly
  76. * that category, or that category and subcategories.
  77. * @return int|null the id of the question picked, or null if there aren't any.
  78. */
  79. public function get_next_question_id($categoryid, $includesubcategories) {
  80. $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories);
  81. $categorykey = $this->get_category_key($categoryid, $includesubcategories);
  82. if (empty($this->availablequestionscache[$categorykey])) {
  83. return null;
  84. }
  85. reset($this->availablequestionscache[$categorykey]);
  86. $lowestcount = key($this->availablequestionscache[$categorykey]);
  87. reset($this->availablequestionscache[$categorykey][$lowestcount]);
  88. $questionid = key($this->availablequestionscache[$categorykey][$lowestcount]);
  89. $this->use_question($questionid);
  90. return $questionid;
  91. }
  92. /**
  93. * Get the key into {@link $availablequestionscache} for this combination of options.
  94. * @param int $categoryid the id of a category in the question bank.
  95. * @param bool $includesubcategories wether to pick a question from exactly
  96. * that category, or that category and subcategories.
  97. * @return string the cache key.
  98. */
  99. protected function get_category_key($categoryid, $includesubcategories) {
  100. if ($includesubcategories) {
  101. return $categoryid . '|1';
  102. } else {
  103. return $categoryid . '|0';
  104. }
  105. }
  106. /**
  107. * Populate {@link $availablequestionscache} for this combination of options.
  108. * @param int $categoryid the id of a category in the question bank.
  109. * @param bool $includesubcategories wether to pick a question from exactly
  110. * that category, or that category and subcategories.
  111. */
  112. protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories) {
  113. global $DB;
  114. $categorykey = $this->get_category_key($categoryid, $includesubcategories);
  115. if (isset($this->availablequestionscache[$categorykey])) {
  116. // Data is already in the cache, nothing to do.
  117. return;
  118. }
  119. // Load the available questions from the question bank.
  120. if ($includesubcategories) {
  121. $categoryids = question_categorylist($categoryid);
  122. } else {
  123. $categoryids = array($categoryid);
  124. }
  125. list($extraconditions, $extraparams) = $DB->get_in_or_equal($this->excludedqtypes,
  126. SQL_PARAMS_NAMED, 'excludedqtype', false);
  127. $questionidsandcounts = \question_bank::get_finder()->get_questions_from_categories_with_usage_counts(
  128. $categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams);
  129. if (!$questionidsandcounts) {
  130. // No questions in this category.
  131. $this->availablequestionscache[$categorykey] = array();
  132. return;
  133. }
  134. // Put all the questions with each value of $prevusecount in separate arrays.
  135. $idsbyusecount = array();
  136. foreach ($questionidsandcounts as $questionid => $prevusecount) {
  137. if (isset($this->recentlyusedquestions[$questionid])) {
  138. // Recently used questions are never returned.
  139. continue;
  140. }
  141. $idsbyusecount[$prevusecount][] = $questionid;
  142. }
  143. // Now put that data into our cache. For each count, we need to shuffle
  144. // questionids, and make those the keys of an array.
  145. $this->availablequestionscache[$categorykey] = array();
  146. foreach ($idsbyusecount as $prevusecount => $questionids) {
  147. shuffle($questionids);
  148. $this->availablequestionscache[$categorykey][$prevusecount] = array_combine(
  149. $questionids, array_fill(0, count($questionids), 1));
  150. }
  151. ksort($this->availablequestionscache[$categorykey]);
  152. }
  153. /**
  154. * Update the internal data structures to indicate that a given question has
  155. * been used one more time.
  156. *
  157. * @param int $questionid the question that is being used.
  158. */
  159. protected function use_question($questionid) {
  160. if (isset($this->recentlyusedquestions[$questionid])) {
  161. $this->recentlyusedquestions[$questionid] += 1;
  162. } else {
  163. $this->recentlyusedquestions[$questionid] = 1;
  164. }
  165. foreach ($this->availablequestionscache as $categorykey => $questionsforcategory) {
  166. foreach ($questionsforcategory as $numuses => $questionids) {
  167. if (!isset($questionids[$questionid])) {
  168. continue;
  169. }
  170. unset($this->availablequestionscache[$categorykey][$numuses][$questionid]);
  171. if (empty($this->availablequestionscache[$categorykey][$numuses])) {
  172. unset($this->availablequestionscache[$categorykey][$numuses]);
  173. }
  174. }
  175. }
  176. }
  177. /**
  178. * Check whether a given question is available in a given category. If so, mark it used.
  179. *
  180. * @param int $categoryid the id of a category in the question bank.
  181. * @param bool $includesubcategories wether to pick a question from exactly
  182. * that category, or that category and subcategories.
  183. * @param int $questionid the question that is being used.
  184. * @return bool whether the question is available in the requested category.
  185. */
  186. public function is_question_available($categoryid, $includesubcategories, $questionid) {
  187. $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories);
  188. $categorykey = $this->get_category_key($categoryid, $includesubcategories);
  189. foreach ($this->availablequestionscache[$categorykey] as $questionids) {
  190. if (isset($questionids[$questionid])) {
  191. $this->use_question($questionid);
  192. return true;
  193. }
  194. }
  195. return false;
  196. }
  197. }