Skip to content

Commit

Permalink
MDL-6340 quiz: avoid reusing random questions between attempts
Browse files Browse the repository at this point in the history
There are several improvements over what we had before:

1. We track all the questions seen in the the student's previous
quiz attempts, so that when they start a new quiz attempt, they get
questions they have not seen before if possible.

2. When there are no more unseen questions, we start repeating, but
always taking from the questions with the fewest attempts so far.

3. A similar logic is applied with variants within one question.

There is lots of credit to go around here. Oleg Sychev's students Alex
Shkarupa, Sergei Bastrykin and Darya Beda all worked on this over
several years, helping to clarify the problem and shape the best
solution. In the end, their various attempts were rewritten into this
final patch by me.
  • Loading branch information
timhunt committed Mar 26, 2015
1 parent 20d3883 commit bb93fc2
Show file tree
Hide file tree
Showing 16 changed files with 1,157 additions and 152 deletions.
74 changes: 74 additions & 0 deletions mod/quiz/classes/question/qubaids_for_users_attempts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?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 qubaid_condition} representing all the attempts by one user at a given quiz.
*
* @package mod_quiz
* @category question
* @copyright 2015 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/

namespace mod_quiz\question;
defined('MOODLE_INTERNAL') || die();


/**
* A {@link qubaid_condition} representing all the attempts by one user at a given quiz.
*
* @copyright 2015 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class qubaids_for_users_attempts extends \qubaid_join {
/**
* Constructor.
*
* This takes the same arguments as {@link quiz_get_user_attempts()}.
*
* @param int $quizid the quiz id.
* @param int $userid the userid.
* @param string $status 'all', 'finished' or 'unfinished' to control
* @param bool $includepreviews defaults to false.
*/
public function __construct($quizid, $userid, $status = 'finished', $includepreviews = false) {
$where = 'quiza.quiz = :quizaquiz AND quiza.userid = :userid';
$params = array('quizaquiz' => $quizid, 'userid' => $userid);

if (!$includepreviews) {
$where .= ' AND preview = 0';
}

switch ($status) {
case 'all':
break;

case 'finished':
$where .= ' AND state IN (:state1, :state2)';
$params['state1'] = \quiz_attempt::FINISHED;
$params['state2'] = \quiz_attempt::ABANDONED;
break;

case 'unfinished':
$where .= ' AND state IN (:state1, :state2)';
$params['state1'] = \quiz_attempt::IN_PROGRESS;
$params['state2'] = \quiz_attempt::OVERDUE;
break;
}

parent::__construct('{quiz_attempts} quiza', 'quiza.uniqueid', $where, $params);
}
}
88 changes: 65 additions & 23 deletions mod/quiz/locallib.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,46 +158,88 @@ function quiz_create_attempt(quiz $quizobj, $attemptnumber, $lastattempt, $timen
*/
function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow,
$questionids = array(), $forcedvariantsbyslot = array()) {

// Usages for this user's previous quiz attempts.
$qubaids = new \mod_quiz\question\qubaids_for_users_attempts(
$quizobj->get_quizid(), $attempt->userid);

// Fully load all the questions in this quiz.
$quizobj->preload_questions();
$quizobj->load_questions();

// Add them all to the $quba.
$questionsinuse = array_keys($quizobj->get_questions());
// First load all the non-random questions.
$randomfound = false;
$slot = 0;
$questions = array();
$maxmark = array();
foreach ($quizobj->get_questions() as $questiondata) {
if ($questiondata->qtype != 'random') {
if (!$quizobj->get_quiz()->shuffleanswers) {
$questiondata->options->shuffleanswers = false;
}
$question = question_bank::make_question($questiondata);
$slot += 1;
$maxmark[$slot] = $questiondata->maxmark;
if ($questiondata->qtype == 'random') {
$randomfound = true;
continue;
}
if (!$quizobj->get_quiz()->shuffleanswers) {
$questiondata->options->shuffleanswers = false;
}
$questions[$slot] = question_bank::make_question($questiondata);
}

} else {
if (!isset($questionids[$quba->next_slot_number()])) {
$forcequestionid = null;
// Then find a question to go in place of each random question.
if ($randomfound) {
$slot = 0;
$usedquestionids = array();
foreach ($questions as $question) {
if (isset($usedquestions[$question->id])) {
$usedquestionids[$question->id] += 1;
} else {
$forcequestionid = $questionids[$quba->next_slot_number()];
$usedquestionids[$question->id] = 1;
}
}
$randomloader = new \core_question\bank\random_question_loader($qubaids, $usedquestionids);

foreach ($quizobj->get_questions() as $questiondata) {
$slot += 1;
if ($questiondata->qtype != 'random') {
continue;
}

$question = question_bank::get_qtype('random')->choose_other_question(
$questiondata, $questionsinuse, $quizobj->get_quiz()->shuffleanswers, $forcequestionid);
if (is_null($question)) {
// Deal with fixed random choices for testing.
if (isset($questionids[$quba->next_slot_number()])) {
if ($randomloader->is_question_available($questiondata->category,
(bool) $questiondata->questiontext, $questionids[$quba->next_slot_number()])) {
$questions[$slot] = question_bank::load_question(
$questionids[$quba->next_slot_number()], $quizobj->get_quiz()->shuffleanswers);
continue;
} else {
throw new coding_exception('Forced question id not available.');
}
}

// Normal case, pick one at random.
$questionid = $randomloader->get_next_question_id($questiondata->category,
(bool) $questiondata->questiontext);
if ($questionid === null) {
throw new moodle_exception('notenoughrandomquestions', 'quiz',
$quizobj->view_url(), $questiondata);
}

$questions[$slot] = question_bank::load_question($questionid,
$quizobj->get_quiz()->shuffleanswers);
}
}

$quba->add_question($question, $questiondata->maxmark);
$questionsinuse[] = $question->id;
// Finally add them all to the usage.
ksort($questions);
foreach ($questions as $slot => $question) {
$newslot = $quba->add_question($question, $maxmark[$slot]);
if ($newslot != $slot) {
throw new coding_exception('Slot numbers have got confused.');
}
}

// Start all the questions.
if ($attempt->preview) {
$variantoffset = rand(1, 100);
} else {
$variantoffset = $attemptnumber;
}
$variantstrategy = new question_variant_pseudorandom_no_repeats_strategy(
$variantoffset, $attempt->userid, $quizobj->get_quizid());
$variantstrategy = new core_question\engine\variants\least_used_strategy($quba, $qubaids);

if (!empty($forcedvariantsbyslot)) {
$forcedvariantsbyseed = question_variant_forced_choices_selection_strategy::prepare_forced_choices_array(
Expand Down
3 changes: 3 additions & 0 deletions mod/quiz/tests/lib_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,9 @@ public function test_quiz_get_completion_state() {
$attemptobj->process_finish($timenow, false);

// Start the failing attempt.
$quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
$quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);

$timenow = time();
$attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $failstudent->id);
quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
Expand Down
Loading

0 comments on commit bb93fc2

Please sign in to comment.