Merge branch 'MDL-6340' of git://github.com/timhunt/moodle

This commit is contained in:
David Monllao
2015-03-31 08:44:01 +08:00
16 changed files with 1158 additions and 153 deletions

View File

@@ -0,0 +1,223 @@
<?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\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
* @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 = array();
/**
* @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 = array()) {
$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.
*
* 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.
* @return int|null the id of the question picked, or null if there aren't any.
*/
public function get_next_question_id($categoryid, $includesubcategories) {
$this->ensure_questions_for_category_loaded($categoryid, $includesubcategories);
$categorykey = $this->get_category_key($categoryid, $includesubcategories);
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 {@link $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.
* @return string the cache key.
*/
protected function get_category_key($categoryid, $includesubcategories) {
if ($includesubcategories) {
return $categoryid . '|1';
} else {
return $categoryid . '|0';
}
}
/**
* Populate {@link $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.
*/
protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories) {
global $DB;
$categorykey = $this->get_category_key($categoryid, $includesubcategories);
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 = array($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_with_usage_counts(
$categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams);
if (!$questionidsandcounts) {
// No questions in this category.
$this->availablequestionscache[$categorykey] = array();
return;
}
// Put all the questions with each value of $prevusecount in separate arrays.
$idsbyusecount = array();
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] = array();
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) {
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]);
}
}
}
}
/**
* Check whether a given question is available in a given category. If so, mark it used.
*
* @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.
* @return bool whether the question is available in the requested category.
*/
public function is_question_available($categoryid, $includesubcategories, $questionid) {
$this->ensure_questions_for_category_loaded($categoryid, $includesubcategories);
$categorykey = $this->get_category_key($categoryid, $includesubcategories);
foreach ($this->availablequestionscache[$categorykey] as $questionids) {
if (isset($questionids[$questionid])) {
$this->use_question($questionid);
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,128 @@
<?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 {@link \question_variant_selection_strategy} that randomly selects variants that were not used yet.
*
* @package core_question
* @copyright 2015 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\engine\variants;
defined('MOODLE_INTERNAL') || die();
/**
* A {@link \question_variant_selection_strategy} that randomly selects variants that were not used yet.
*
* If all variants have been used at least once in the set of usages under
* consideration, then then it picks one of the least often used.
*
* Within one particular use of this class, each seed will always select the
* same variant. This is so that shared datasets work in calculated questions,
* and similar features in question types like varnumeric and STACK.
*
* @copyright 2015 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class least_used_strategy implements \question_variant_selection_strategy {
/** @var array seed => variant number => number of uses. */
protected $variantsusecounts = array();
/** @var array seed => variant number. */
protected $selectedvariant = array();
/**
* Constructor.
* @param question_usage_by_activity $quba the question usage we will be picking variants for.
* @param qubaid_condition $qubaids ids of the usages to consider when counting previous uses of each variant.
*/
public function __construct(\question_usage_by_activity $quba, \qubaid_condition $qubaids) {
$questionidtoseed = array();
foreach ($quba->get_attempt_iterator() as $qa) {
$question = $qa->get_question();
if ($question->get_num_variants() > 1) {
$questionidtoseed[$question->id] = $question->get_variants_selection_seed();
}
}
if (empty($questionidtoseed)) {
return;
}
$this->variantsusecounts = array_fill_keys($questionidtoseed, array());
$variantsused = \question_engine::load_used_variants(array_keys($questionidtoseed), $qubaids);
foreach ($variantsused as $questionid => $usagecounts) {
$seed = $questionidtoseed[$questionid];
foreach ($usagecounts as $variant => $count) {
if (isset($this->variantsusecounts[$seed][$variant])) {
$this->variantsusecounts[$seed][$variant] += $count;
} else {
$this->variantsusecounts[$seed][$variant] = $count;
}
}
}
}
public function choose_variant($maxvariants, $seed) {
if ($maxvariants == 1) {
return 1;
}
if (isset($this->selectedvariant[$seed])) {
return $this->selectedvariant[$seed];
}
if ($maxvariants > 2 * count($this->variantsusecounts[$seed])) {
// Many many more variants exist than have been used so far.
// It will be quicker to just pick until we miss a collision.
do {
$variant = rand(1, $maxvariants);
} while (isset($this->variantsusecounts[$seed][$variant]));
} else {
// We need to work harder to find a least-used one.
$leastusedvariants = array();
for ($variant = 1; $variant <= $maxvariants; ++$variant) {
if (!isset($this->variantsusecounts[$seed][$variant])) {
$leastusedvariants[$variant] = 1;
}
}
if (empty($leastusedvariants)) {
// All variants used at least once, try again.
$leastuses = min($this->variantsusecounts[$seed]);
foreach ($this->variantsusecounts[$seed] as $variant => $uses) {
if ($uses == $leastuses) {
$leastusedvariants[$variant] = 1;
}
}
}
$variant = array_rand($leastusedvariants);
}
$this->selectedvariant[$seed] = $variant;
if (isset($variantsusecounts[$seed][$variant])) {
$variantsusecounts[$seed][$variant] += 1;
} else {
$variantsusecounts[$seed][$variant] = 1;
}
return $variant;
}
}