diff -Naur moodle_prerandgroup/backup/moodle2/backup_stepslib.php moodle/backup/moodle2/backup_stepslib.php --- moodle_prerandgroup/backup/moodle2/backup_stepslib.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/backup/moodle2/backup_stepslib.php 2020-08-18 16:32:36.893194794 +1000 @@ -2289,7 +2289,7 @@ 'parent', 'name', 'questiontext', 'questiontextformat', 'generalfeedback', 'generalfeedbackformat', 'defaultmark', 'penalty', 'qtype', 'length', 'stamp', 'version', - 'hidden', 'timecreated', 'timemodified', 'createdby', 'modifiedby', 'idnumber')); + 'hidden', 'timecreated', 'timemodified', 'createdby', 'modifiedby', 'idnumber', 'randomquizgroup')); // attach qtype plugin structure to $question element, only one allowed $this->add_plugin_structure('qtype', $question, false); diff -Naur moodle_prerandgroup/lang/en/question.php moodle/lang/en/question.php --- moodle_prerandgroup/lang/en/question.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/lang/en/question.php 2020-08-18 17:15:07.078982611 +1000 @@ -295,6 +295,8 @@ $string['questiontype'] = 'Question type'; $string['questionuse'] = 'Use question in this activity'; $string['questionvariant'] = 'Question variant'; +$string['randomquizgroup'] = 'Random quiz group'; +$string['randomquizgroup_help'] = 'Allows multiple random questions to be drawn from a related topic.'; $string['reviewresponse'] = 'Review response'; $string['save'] = 'Save'; $string['savechangesandcontinueediting'] = 'Save changes and continue editing'; diff -Naur moodle_prerandgroup/lib/db/install.xml moodle/lib/db/install.xml --- moodle_prerandgroup/lib/db/install.xml 2020-08-14 16:42:37.000000000 +1000 +++ moodle/lib/db/install.xml 2020-08-18 15:48:34.767172180 +1000 @@ -1,5 +1,5 @@ - @@ -1423,6 +1423,7 @@ + @@ -1434,6 +1435,7 @@ + diff -Naur moodle_prerandgroup/lib/db/upgrade.php moodle/lib/db/upgrade.php --- moodle_prerandgroup/lib/db/upgrade.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/lib/db/upgrade.php 2020-08-18 17:29:20.122244483 +1000 @@ -2527,5 +2527,36 @@ upgrade_main_savepoint(true, 2020061501.04); } + if ($oldversion < 2020061501.08) { + + // Define field randomquizgroup to be added to question. + $table = new xmldb_table('question'); + $field = new xmldb_field('randomquizgroup', XMLDB_TYPE_CHAR, '100', null, null, null, null, 'idnumber'); + + // Conditionally launch add field randomquizgroup. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2020061501.08); + } + + if ($oldversion < 2020061501.09) { + + // Define index randomquizgroup (not unique) to be added to question. + $table = new xmldb_table('question'); + $index = new xmldb_index('randomquizgroup', XMLDB_INDEX_NOTUNIQUE, ['randomquizgroup']); + + // Conditionally launch add index randomquizgroup. + if (!$dbman->index_exists($table, $index)) { + $dbman->add_index($table, $index); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2020061501.09); + } + + return true; } diff -Naur moodle_prerandgroup/mod/quiz/addrandomform.php moodle/mod/quiz/addrandomform.php --- moodle_prerandgroup/mod/quiz/addrandomform.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/mod/quiz/addrandomform.php 2020-08-18 17:58:03.423745102 +1000 @@ -54,6 +54,9 @@ $mform->setDefault('category', $this->_customdata['cat']); $mform->addElement('checkbox', 'includesubcategories', '', get_string('recurse', 'quiz')); + $mform->addElement('text', 'randomgrouping', get_string('randomgrouping', 'quiz'), 'maxlength="100" size="10"'); + $mform->addHelpButton('randomgrouping', 'randomgrouping', 'quiz'); + $mform->setType('randomgrouping', PARAM_RAW); $tops = question_get_top_categories_for_contexts(array_column($contexts->all(), 'id')); $mform->hideIf('includesubcategories', 'category', 'in', $tops); diff -Naur moodle_prerandgroup/mod/quiz/addrandom.php moodle/mod/quiz/addrandom.php --- moodle_prerandgroup/mod/quiz/addrandom.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/mod/quiz/addrandom.php 2020-08-18 19:21:15.589214618 +1000 @@ -101,6 +101,10 @@ throw new coding_exception( 'It seems a form was submitted without any button being pressed???'); } + $randomgrouping = trim($data->randomgrouping); + if($randomgrouping == ''){ + $randomgrouping = null; + } if (empty($data->fromtags)) { $data->fromtags = []; @@ -110,7 +114,7 @@ return (int)explode(',', $tagstrings)[0]; }, $data->fromtags); - quiz_add_random_questions($quiz, $addonpage, $categoryid, $data->numbertoadd, $includesubcategories, $tagids); + quiz_add_random_questions($quiz, $addonpage, $categoryid, $data->numbertoadd, $includesubcategories, $tagids, $randomgrouping); quiz_delete_previews($quiz); quiz_update_sumgrades($quiz); redirect($returnurl); diff -Naur moodle_prerandgroup/mod/quiz/attemptlib.php moodle/mod/quiz/attemptlib.php --- moodle_prerandgroup/mod/quiz/attemptlib.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/mod/quiz/attemptlib.php 2020-08-18 19:42:19.384946911 +1000 @@ -148,7 +148,8 @@ $this->questions = question_preload_questions(null, 'slot.maxmark, slot.id AS slotid, slot.slot, slot.page, slot.questioncategoryid AS randomfromcategory, - slot.includingsubcategories AS randomincludingsubcategories', + slot.includingsubcategories AS randomincludingsubcategories, + slot.randomgrouping', '{quiz_slots} slot ON slot.quizid = :quizid AND q.id = slot.questionid', array('quizid' => $this->quiz->id), 'slot.slot'); } diff -Naur moodle_prerandgroup/mod/quiz/backup/moodle2/backup_quiz_stepslib.php moodle/mod/quiz/backup/moodle2/backup_quiz_stepslib.php --- moodle_prerandgroup/mod/quiz/backup/moodle2/backup_quiz_stepslib.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/mod/quiz/backup/moodle2/backup_quiz_stepslib.php 2020-08-18 16:13:07.229478833 +1000 @@ -58,7 +58,7 @@ $qinstances = new backup_nested_element('question_instances'); $qinstance = new backup_nested_element('question_instance', array('id'), array( - 'slot', 'page', 'requireprevious', 'questionid', 'questioncategoryid', 'includingsubcategories', 'maxmark')); + 'slot', 'page', 'requireprevious', 'questionid', 'questioncategoryid', 'includingsubcategories', 'maxmark', 'randomgrouping')); $qinstancetags = new backup_nested_element('tags'); $qinstancetag = new backup_nested_element('tag', array('id'), array('tagid', 'tagname')); diff -Naur moodle_prerandgroup/mod/quiz/classes/form/randomquestion_form.php moodle/mod/quiz/classes/form/randomquestion_form.php --- moodle_prerandgroup/mod/quiz/classes/form/randomquestion_form.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/mod/quiz/classes/form/randomquestion_form.php 2020-08-18 18:00:42.833085577 +1000 @@ -53,6 +53,9 @@ array('contexts' => $usablecontexts, 'top' => true)); $mform->addElement('advcheckbox', 'includesubcategories', get_string('recurse', 'quiz'), null, null, array(0, 1)); + $mform->addElement('text', 'randomgrouping', get_string('randomgrouping', 'quiz'), 'maxlength="100" size="10"'); + $mform->addHelpButton('randomgrouping', 'randomgrouping', 'quiz'); + $mform->setType('randomgrouping', PARAM_RAW); $tops = question_get_top_categories_for_contexts(array_column($contexts->all(), 'id')); $mform->hideIf('includesubcategories', 'category', 'in', $tops); diff -Naur moodle_prerandgroup/mod/quiz/classes/local/structure/slot_random.php moodle/mod/quiz/classes/local/structure/slot_random.php --- moodle_prerandgroup/mod/quiz/classes/local/structure/slot_random.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/mod/quiz/classes/local/structure/slot_random.php 2020-08-18 19:23:30.339052946 +1000 @@ -58,7 +58,7 @@ $properties = array( 'id', 'slot', 'quizid', 'page', 'requireprevious', 'questionid', - 'questioncategoryid', 'includingsubcategories', 'maxmark'); + 'questioncategoryid', 'includingsubcategories', 'maxmark', 'randomgrouping'); foreach ($properties as $property) { if (isset($slotrecord->$property)) { @@ -193,4 +193,4 @@ $trans->allow_commit(); } -} \ No newline at end of file +} diff -Naur moodle_prerandgroup/mod/quiz/db/install.xml moodle/mod/quiz/db/install.xml --- moodle_prerandgroup/mod/quiz/db/install.xml 2020-08-14 16:42:37.000000000 +1000 +++ moodle/mod/quiz/db/install.xml 2020-08-18 15:57:32.142452929 +1000 @@ -1,5 +1,5 @@ - @@ -66,6 +66,7 @@ + diff -Naur moodle_prerandgroup/mod/quiz/db/upgrade.php moodle/mod/quiz/db/upgrade.php --- moodle_prerandgroup/mod/quiz/db/upgrade.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/mod/quiz/db/upgrade.php 2020-08-18 17:37:33.398821290 +1000 @@ -29,7 +29,9 @@ * @param string $oldversion the version we are upgrading from. */ function xmldb_quiz_upgrade($oldversion) { - global $CFG; + global $CFG, $DB; + + $dbman = $DB->get_manager(); // Loads ddl manager and xmldb classes.; // Automatically generated Moodle v3.5.0 release upgrade line. // Put any upgrade step following this. @@ -46,5 +48,21 @@ // Automatically generated Moodle v3.9.0 release upgrade line. // Put any upgrade step following this. + if ($oldversion < 2020061501.01) { + + // Define field randomgrouping to be added to quiz_slots. + $table = new xmldb_table('quiz_slots'); + $field = new xmldb_field('randomgrouping', XMLDB_TYPE_CHAR, '100', null, null, null, null, 'maxmark'); + + // Conditionally launch add field randomgrouping. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Quiz savepoint reached. + upgrade_mod_savepoint(true, 2020061501.01, 'quiz'); + } + + return true; } diff -Naur moodle_prerandgroup/mod/quiz/editrandom.php moodle/mod/quiz/editrandom.php --- moodle_prerandgroup/mod/quiz/editrandom.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/mod/quiz/editrandom.php 2020-08-18 18:05:14.223415793 +1000 @@ -77,6 +77,7 @@ $toform = fullclone($question); $toform->category = "{$category->id},{$category->contextid}"; $toform->includesubcategories = $slot->includingsubcategories; +$toform->randomgrouping = $slot->randomgrouping; $toform->fromtags = array(); $currentslottags = quiz_retrieve_slot_tags($slot->id); foreach ($currentslottags as $slottag) { @@ -114,6 +115,11 @@ // We need to save some data into the quiz_slots table. $slot->questioncategoryid = $fromform->category; $slot->includingsubcategories = $fromform->includesubcategories; + if(trim($fromform->randomgrouping) == '') { + $slot->randomgrouping = null; + } else { + $slot->randomgrouping = trim($fromform->randomgrouping); + } $DB->update_record('quiz_slots', $slot); diff -Naur moodle_prerandgroup/mod/quiz/lang/en/quiz.php moodle/mod/quiz/lang/en/quiz.php --- moodle_prerandgroup/mod/quiz/lang/en/quiz.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/mod/quiz/lang/en/quiz.php 2020-08-18 18:00:07.052782504 +1000 @@ -749,6 +749,8 @@ $string['randomfromcategory'] = 'Random question from category:'; $string['randomfromexistingcategory'] = 'Random question from an existing category'; $string['randomfromunavailabletag'] = '{$a} (unavailable)'; +$string['randomgrouping'] = 'Random grouping'; +$string['randomgrouping_help'] = 'Allows grouping together related random questions. Different groupings will produce different questions.'; $string['randomnumber'] = 'Number of random questions'; $string['randomnosubcat'] = 'Questions from this category only, not its subcategories.'; $string['randomquestion'] = 'Random question'; diff -Naur moodle_prerandgroup/mod/quiz/locallib.php moodle/mod/quiz/locallib.php --- moodle_prerandgroup/mod/quiz/locallib.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/mod/quiz/locallib.php 2020-08-21 14:23:05.642219492 +1000 @@ -159,7 +159,6 @@ */ 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); @@ -208,11 +207,11 @@ } $tagids = quiz_retrieve_slot_tag_ids($questiondata->slotid); - // 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()], $tagids)) { + (bool) $questiondata->questiontext, $questionids[$quba->next_slot_number()], + $tagids, $questiondata->randomgrouping)) { $questions[$slot] = question_bank::load_question( $questionids[$quba->next_slot_number()], $quizobj->get_quiz()->shuffleanswers); continue; @@ -223,7 +222,7 @@ // Normal case, pick one at random. $questionid = $randomloader->get_next_question_id($questiondata->randomfromcategory, - $questiondata->randomincludingsubcategories, $tagids); + $questiondata->randomincludingsubcategories, $tagids, $questiondata->randomgrouping); if ($questionid === null) { throw new moodle_exception('notenoughrandomquestions', 'quiz', $quizobj->view_url(), $questiondata); @@ -2245,9 +2244,11 @@ * @param int $number the number of random questions to add. * @param bool $includesubcategories whether to include questoins from subcategories. * @param int[] $tagids Array of tagids. The question that will be picked randomly should be tagged with all these tags. + * @param string $randomgrouping String identifying random grouping. + * The question that will be picked randomly with the same grouping should all have the same group. */ function quiz_add_random_questions($quiz, $addonpage, $categoryid, $number, - $includesubcategories, $tagids = []) { + $includesubcategories, $tagids = [], $randomgrouping) { global $DB; $category = $DB->get_record('question_categories', array('id' => $categoryid)); @@ -2286,6 +2287,7 @@ $form->fromtags = $tagstrings; $form->defaultmark = 1; $form->hidden = 1; + $form->randomgrouping = $randomgrouping; $form->stamp = make_unique_id_code(); // Set the unique code (not to be changed). $question = new stdClass(); $question->qtype = 'random'; @@ -2301,6 +2303,7 @@ $randomslotdata->questioncategoryid = $categoryid; $randomslotdata->includingsubcategories = $includesubcategories ? 1 : 0; $randomslotdata->maxmark = 1; + $randomslotdata->randomgrouping = $randomgrouping; $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); $randomslot->set_quiz($quiz); diff -Naur moodle_prerandgroup/mod/quiz/version.php moodle/mod/quiz/version.php --- moodle_prerandgroup/mod/quiz/version.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/mod/quiz/version.php 2020-08-18 17:36:58.594494962 +1000 @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2020061500; -$plugin->requires = 2020060900; +$plugin->version = 2020061501; +$plugin->requires = 2020061501; $plugin->component = 'mod_quiz'; diff -Naur moodle_prerandgroup/question/classes/bank/random_question_loader.php moodle/question/classes/bank/random_question_loader.php --- moodle_prerandgroup/question/classes/bank/random_question_loader.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/question/classes/bank/random_question_loader.php 2020-08-21 18:11:00.549755641 +1000 @@ -52,6 +52,12 @@ /** @var array categoryid & include subcategories => num previous uses => questionid => 1. */ protected $availablequestionscache = array(); + /** @var array ategoryid & include subcategories => num previous uses => questionid =>1. */ + protected $availablegroupscache = array(); + + /** @var array randomgrouping => randomquizgroup. */ + protected $groupingscache = array(); + /** * @var array questionid => num recent uses. Questions that have been used, * but that is not yet recorded in the DB. @@ -59,6 +65,11 @@ protected $recentlyusedquestions; /** + * @var array randomquizgroup => num previous uses. + */ + protected $recentlyusedgroups = array(); + + /** * 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 @@ -78,6 +89,8 @@ /** * 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. + * If a grouping is specified, then only questions with the randomquizgroup for that grouping will be selected. + * If a grouping is not yet assigned a group, one will be selected for it. * * It is up the the caller to verify that the cateogry exists. An unknown category * behaves like an empty one. @@ -87,12 +100,29 @@ * 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. + * @param string $grouping A string identifying the grouping. A question has to have the randomquizgroup associated + * with that grouping (if any) in order to be eligible for being picked. + * If the grouping does not yet have a randomquizgroup associated with it, one will chosen at random from + * those available. * @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 = []) { - $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); + public function get_next_question_id($categoryid, $includesubcategories, $tagids = [], $grouping = null) { + if (empty($grouping)) { + $group = null; + } else { + if (isset($this->groupingscache[$grouping])) { + $group = $this->groupingscache[$grouping]; + } else { + $group = $this->get_next_group($categoryid, $includesubcategories, $tagids = [], $grouping); + $this->groupingscache[$grouping] = $group; + if (empty($group)) { + return null; + } + } + } + $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids, $group); - $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); + $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids, $group); if (empty($this->availablequestionscache[$categorykey])) { return null; } @@ -111,9 +141,10 @@ * @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. + * @param string $group the randomquizgroup. * @return string the cache key. */ - protected function get_category_key($categoryid, $includesubcategories, $tagids = []) { + protected function get_category_key($categoryid, $includesubcategories, $tagids = [], $group = null) { if ($includesubcategories) { $key = $categoryid . '|1'; } else { @@ -123,6 +154,10 @@ if (!empty($tagids)) { $key .= '|' . implode('|', $tagids); } + + if (!empty($group)) { + $key .= '|' . $group; + } return $key; } @@ -134,11 +169,13 @@ * 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 string $group A string identifying the randomquizgroup. If a string is provided the + * only the questions that have the randomquizgroup provided will be loaded. */ - protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids = []) { + protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids = [], $group = null) { global $DB; - $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); + $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids, $group); if (isset($this->availablequestionscache[$categorykey])) { // Data is already in the cache, nothing to do. @@ -155,8 +192,8 @@ 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); + $questionidsandcounts = \question_bank::get_finder()->get_questions_from_categories_tags_and_group_with_usage_counts( + $categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams, $tagids, $group); if (!$questionidsandcounts) { // No questions in this category. $this->availablequestionscache[$categorykey] = array(); @@ -221,8 +258,8 @@ * @return int[] The list of question ids */ protected function get_question_ids($categoryid, $includesubcategories, $tagids = []) { - $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); - $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); + $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids, null); + $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids, null); $cachedvalues = $this->availablequestionscache[$categorykey]; $questionids = []; @@ -243,11 +280,27 @@ * 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. + * @param string $grouping A string identifying the grouping. Only the questions which have the randomquizgroup associated + * with that grouping (if any) can be available. + * If the grouping does not yet have a randomquizgroup associated with it, it will be forced to that of the question. * @return bool whether the question is available in the requested category. */ - public function is_question_available($categoryid, $includesubcategories, $questionid, $tagids = []) { - $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); - $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); + public function is_question_available($categoryid, $includesubcategories, $questionid, $tagids = [], $grouping = null) { + if (empty($grouping)) { + $group = null; + } else { + if (isset($this->groupingscache[$grouping])) { + $group = $this->groupingscache[$grouping]; + } else { + $group = $this->get_group_from_question($questionid); + $this->groupingscache[$grouping] = $group; + if (empty($group)) { + return false; + } + } + } + $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids, $group); + $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids, $group); foreach ($this->availablequestionscache[$categorykey] as $questionids) { if (isset($questionids[$questionid])) { @@ -319,4 +372,137 @@ $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids); return count($questionids); } + + /** + * Pick a group 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 string|null the group picked, or null if there aren't any. + */ + protected $quizused = false; + protected function get_next_group($categoryid, $includesubcategories, $tagids = [], $grouping) { + + $this->ensure_groups_for_category_loaded($categoryid, $includesubcategories, $tagids); + + $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); + if (empty($this->availablegroupscache[$categorykey])) { + return null; + } + + reset($this->availablegroupscache[$categorykey]); + $lowestcount = key($this->availablegroupscache[$categorykey]); + reset($this->availablegroupscache[$categorykey][$lowestcount]); + $group = key($this->availablegroupscache[$categorykey][$lowestcount]); + $this->use_group($group); + return $group; + } + + /** + * Populate {@link $availablegroupscache} 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_groups_for_category_loaded($categoryid, $includesubcategories, $tagids = []) { + global $DB; + + $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); + + if (isset($this->availablegroupscache[$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); + + $groupsandcounts = \question_bank::get_finder()->get_groups_from_categories_and_tags_with_usage_counts( + $categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams, $tagids); + if (!$groupsandcounts) { + // No groups in this category. + $this->availablegroupscache[$categorykey] = array(); + return; + } + + // Put all the groups with each value of $prevusecount in separate arrays. + $groupsbyusecount = array(); + foreach ($groupsandcounts as $group => $prevusecount) { + if (isset($this->recentlyusedqgroups[$group])) { + // Recently used groups are never returned. + continue; + } + $groupsbyusecount[$prevusecount][] = $group; + } + + // Now put that data into our cache. For each count, we need to shuffle + // groups, and make those the keys of an array. + $this->availablegroupscache[$categorykey] = array(); + foreach ($groupsbyusecount as $prevusecount => $groups) { + shuffle($groups); + $this->availablegroupscache[$categorykey][$prevusecount] = array_combine( + $groups, array_fill(0, count($groups), 1)); + } + ksort($this->availablegroupscache[$categorykey]); + } + + /** + * Update the internal data structures to indicate that a given group has + * been used one more time. + * + * @param string $group the randomquizgroup that is being used. + */ + protected function use_group($group) { + if (isset($this->recentlyusedgroups[$group])) { + $this->recentlyusedgroups[$group] += 1; + } else { + $this->recentlyusedgroups[$group] = 1; + } + + foreach ($this->availablegroupscache as $categorykey => $groupsforcategory) { + foreach ($groupsforcategory as $numuses => $groups) { + if (!isset($groups[$group])) { + continue; + } + unset($this->availablegroupscache[$categorykey][$numuses][$group]); + if (empty($this->availablegroupscache[$categorykey][$numuses])) { + unset($this->availablegroupscache[$categorykey][$numuses]); + } + } + } + } + + /** + * Obtain the randomquizgroup for a given question from its id. + * + * @param int $questionid the id of a question in the question bank. + * @return string|null the randomquizgroup of the question provided, or null if the question + * doesn't exist or has no randomquizgroup. + */ + protected function get_group_from_question($questionid) { + global $DB; + + $question = $DB->get_record('question', ['id' => $questionid],'randomquizgroup'); + if(empty($question) || empty($question->randomquizgroup)) { + return null; + } + return $question->randomquizgroup; + } + } diff -Naur moodle_prerandgroup/question/engine/bank.php moodle/question/engine/bank.php --- moodle_prerandgroup/question/engine/bank.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/question/engine/bank.php 2020-08-21 18:12:06.966403173 +1000 @@ -539,6 +539,28 @@ */ public function get_questions_from_categories_and_tags_with_usage_counts($categoryids, qubaid_condition $qubaids, $extraconditions = '', $extraparams = array(), $tagids = array()) { + return $this->get_questions_from_categories_tags_and_group_with_usage_counts( + $categoryids, $qubaids, $extraconditions, $extraparams, $tagids); + } + + /** + * Get the ids of all the questions in a list of categories that have ALL the provided tags and group, + * with the number of times they have already been used in a given set of usages. + * + * The result array is returned in order of increasing (count previous uses). + * + * @param array $categoryids an array of question_category ids. + * @param qubaid_condition $qubaids which question_usages to count previous uses from. + * @param string $extraconditions extra conditions to AND with the rest of + * the where clause. Must use named parameters. + * @param array $extraparams any parameters used by $extraconditions. + * @param array $tagids an array of tag ids + * @param array $group a string identifying the randomquizgroup + * @return array questionid => count of number of previous uses. + */ + public function get_questions_from_categories_tags_and_group_with_usage_counts($categoryids, + qubaid_condition $qubaids, $extraconditions = '', $extraparams = array(), + $tagids = array(), $group = null) { global $DB; list($qcsql, $qcparams) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED, 'qc'); @@ -554,6 +576,75 @@ $params = $qcparams; if (!empty($tagids)) { + // We treat each additional tag as an AND condition rather than + // an OR condition. + // + // For example, if the user filters by the tags "foo" and "bar" then + // we reduce the question list to questions that are tagged with both + // "foo" AND "bar". Any question that does not have ALL of the specified + // tags will be omitted. + list($tagsql, $tagparams) = $DB->get_in_or_equal($tagids, SQL_PARAMS_NAMED, 'ti'); + $tagparams['tagcount'] = count($tagids); + $tagparams['questionitemtype'] = 'question'; + $tagparams['questioncomponent'] = 'core_question'; + $where .= " AND q.id IN (SELECT ti.itemid + FROM {tag_instance} ti + WHERE ti.itemtype = :questionitemtype + AND ti.component = :questioncomponent + AND ti.tagid {$tagsql} + GROUP BY ti.itemid + HAVING COUNT(itemid) = :tagcount)"; + $params += $tagparams; + } + + if (!empty($group)) { + $where .= ' AND q.randomquizgroup = :randomquizgroup'; + $params += ['randomquizgroup'=>$group]; + } + + if ($extraconditions) { + $extraconditions = ' AND (' . $extraconditions . ')'; + } + + return $DB->get_records_sql_menu("SELECT $select + FROM $from + WHERE $where $extraconditions + ORDER BY previous_attempts", + $qubaids->from_where_params() + $params + $extraparams); + } + + /** + * Get the groups of all the questions in a list of categories that have ALL the provided tags, + * with the number of times they have already been used in a given set of usages. + * + * The result array is returned in order of increasing (count previous uses). + * + * @param array $categoryids an array of question_category ids. + * @param qubaid_condition $qubaids which question_usages to count previous uses from. + * @param string $extraconditions extra conditions to AND with the rest of + * the where clause. Must use named parameters. + * @param array $extraparams any parameters used by $extraconditions. + * @param array $tagids an array of tag ids + * @return array questionid => count of number of previous uses. + */ + public function get_groups_from_categories_and_tags_with_usage_counts($categoryids, + qubaid_condition $qubaids, $extraconditions = '', $extraparams = array(), $tagids = array()) { + global $DB; + + list($qcsql, $qcparams) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED, 'qc'); + + $select = "q.randomquizgroup, (SELECT COUNT(1) + FROM " . $qubaids->from_question_attempts('qa') . " + WHERE qa.questionid = q.id AND " . $qubaids->where() . " + ) AS previous_attempts"; + $from = "{question} q"; + $where = "q.category {$qcsql} + AND q.parent = 0 + AND q.hidden = 0 + AND q.randomquizgroup IS NOT NULL"; + $params = $qcparams; + + if (!empty($tagids)) { // We treat each additional tag as an AND condition rather than // an OR condition. // diff -Naur moodle_prerandgroup/question/format/xml/format.php moodle/question/format/xml/format.php --- moodle_prerandgroup/question/format/xml/format.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/question/format/xml/format.php 2020-08-18 17:45:20.022000172 +1000 @@ -237,6 +237,7 @@ } $qo->idnumber = $this->getpath($question, ['#', 'idnumber', 0, '#'], null); + $qo->randomquizgroup = $this->getpath($question, ['#', 'randomquizgroup', 0, '#'], null); // Restore files in generalfeedback. $generalfeedback = $this->import_text_with_files($question, @@ -938,6 +939,7 @@ $qo->infoformat = $this->trans_format($question['#']['info'][0]['@']['format']); } $qo->idnumber = $this->getpath($question, array('#', 'idnumber', 0, '#'), null); + $qo->randomquizgroup = $this->getpath($question, ['#', 'randomquizgroup', 0, '#'], null); return $qo; } @@ -1209,6 +1211,7 @@ $expout .= " {$categoryinfo}"; $expout .= " \n"; $expout .= " {$question->idnumber}\n"; + $expout .= " {$question->randomquizgroup}\n"; $expout .= " \n"; return $expout; } @@ -1233,6 +1236,7 @@ $expout .= " {$question->penalty}\n"; $expout .= " {$question->hidden}\n"; $expout .= " {$question->idnumber}\n"; + $expout .= " {$question->randomquizgroup}\n"; // The rest of the output depends on question type. switch($question->qtype) { diff -Naur moodle_prerandgroup/question/type/edit_question_form.php moodle/question/type/edit_question_form.php --- moodle_prerandgroup/question/type/edit_question_form.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/question/type/edit_question_form.php 2020-08-18 17:09:06.524642580 +1000 @@ -201,6 +201,10 @@ $mform->addHelpButton('idnumber', 'idnumber', 'question'); $mform->setType('idnumber', PARAM_RAW); + $mform->addElement('text', 'randomquizgroup', get_string('randomquizgroup', 'question'), 'maxlength="100" size="10"'); + $mform->addHelpButton('randomquizgroup', 'randomquizgroup', 'question'); + $mform->setType('randomquizgroup', PARAM_RAW); + // Any questiontype specific fields. $this->definition_inner($mform); diff -Naur moodle_prerandgroup/question/type/questiontypebase.php moodle/question/type/questiontypebase.php --- moodle_prerandgroup/question/type/questiontypebase.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/question/type/questiontypebase.php 2020-08-18 17:22:33.082610452 +1000 @@ -393,6 +393,14 @@ } } + if (isset($form->randomquizgroup)) { + if (trim($form->randomquizgroup) === '') { + $question->randomquizgroup = null; + } else { + $question->randomquizgroup = trim($form->randomquizgroup); + } + } + // If the question is new, create it. $newquestion = false; if (empty($question->id)) { @@ -909,6 +917,7 @@ $question->version = $questiondata->version; $question->hidden = $questiondata->hidden; $question->idnumber = $questiondata->idnumber; + $question->randomquizgroup = $questiondata->randomquizgroup; $question->timecreated = $questiondata->timecreated; $question->timemodified = $questiondata->timemodified; $question->createdby = $questiondata->createdby; diff -Naur moodle_prerandgroup/question/type/random/lang/en/qtype_random.php moodle/question/type/random/lang/en/qtype_random.php --- moodle_prerandgroup/question/type/random/lang/en/qtype_random.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/question/type/random/lang/en/qtype_random.php 2020-08-20 15:53:51.745273574 +1000 @@ -29,20 +29,34 @@ $string['pluginname_help'] = 'A random question is not a question type as such, but is a way of inserting a randomly-chosen question from a specified category into an activity.'; $string['pluginnameediting'] = 'Editing a random question'; $string['privacy:metadata'] = 'The Random question type plugin does not store any personal data.'; -$string['randomqname'] = 'Random ({$a})'; +$string['randomqname'] = 'Random ({$a->category})'; $string['randomqnamefromtop'] = 'Faulty random question! Please delete this question.'; +$string['randomqnamefromtopgrouping'] = 'Faulty random question! Please delete this question.'; $string['randomqnamefromtoptags'] = 'Faulty random question! Please delete this question.'; +$string['randomqnamefromtoptagsgrouping'] = 'Faulty random question! Please delete this question.'; +$string['randomqnamegrouping'] = 'Random ({$a->category}, grouping: {$a->grouping})'; $string['randomqnametags'] = 'Random ({$a->category}, tags: {$a->tags})'; -$string['randomqplusname'] = 'Random ({$a} and subcategories)'; +$string['randomqnametagsgrouping'] = 'Random ({$a->category}, tags: {$a->tags}, grouping: {$a->grouping})'; +$string['randomqplusname'] = 'Random ({$a->category} and subcategories)'; $string['randomqplusnamecourse'] = 'Random (Any category in this course)'; -$string['randomqplusnamecoursecat'] = 'Random (Any category inside course category {$a})'; +$string['randomqplusnamecoursecat'] = 'Random (Any category inside course category {$a->category})'; +$string['randomqplusnamecoursecatgrouping'] = 'Random (Any category inside course category {$a->category}, grouping: {$a->grouping})'; $string['randomqplusnamecoursecattags'] = 'Random (Any category inside course category {$a->category}, tags: {$a->tags})'; +$string['randomqplusnamecoursecattagsgrouping'] = 'Random (Any category inside course category {$a->category}, tags: {$a->tags}, grouping: {$a->grouping})'; +$string['randomqplusnamecoursegrouping'] = 'Random (Any category in this course, grouping: {$a->grouping})'; $string['randomqplusnamecoursetags'] = 'Random (Any category in this course, tags: {$a->tags})'; +$string['randomqplusnamecoursetagsgrouping'] = 'Random (Any category in this course, tags: {$a->tags}, grouping: {$a->grouping})'; +$string['randomqplusnamegrouping'] = 'Random ({$a->category} and subcategories, grouping: {$a->grouping})'; $string['randomqplusnamemodule'] = 'Random (Any category of this quiz)'; +$string['randomqplusnamemodulegrouping'] = 'Random (Any category of this quiz, grouping: {$a->grouping})'; $string['randomqplusnamemoduletags'] = 'Random (Any category of this quiz, tags: {$a->tags})'; +$string['randomqplusnamemoduletagsgrouping'] = 'Random (Any category of this quiz, tags: {$a->tags}, grouping: {$a->grouping})'; $string['randomqplusnamesystem'] = 'Random (Any system-level category)'; +$string['randomqplusnamesystemgrouping'] = 'Random (Any system-level category, grouping: {$a->grouping})'; $string['randomqplusnamesystemtags'] = 'Random (Any system-level category, tags: {$a->tags})'; +$string['randomqplusnamesystemtagsgrouping'] = 'Random (Any system-level category, tags: {$a->tags}, grouping: {$a->grouping})'; $string['randomqplusnametags'] = 'Random ({$a->category} and subcategories, tags: {$a->tags})'; +$string['randomqplusnametagsgrouping'] = 'Random ({$a->category} and subcategories, tags: {$a->tags}, grouping: {$a->grouping})'; $string['selectedby'] = '{$a->questionname} selected by {$a->randomname}'; $string['selectmanualquestions'] = 'Random questions can use manually graded questions'; $string['taskunusedrandomscleanup'] = 'Remove unused random questions'; diff -Naur moodle_prerandgroup/question/type/random/questiontype.php moodle/question/type/random/questiontype.php --- moodle_prerandgroup/question/type/random/questiontype.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/question/type/random/questiontype.php 2020-08-20 15:54:11.633231549 +1000 @@ -126,9 +126,10 @@ * @param stdClass $category the category this question picks from. (Only ->name is used.) * @param bool $includesubcategories whether this question also picks from subcategories. * @param string[] $tagnames Name of tags this question picks from. + * @param string $grouping Name of the quiz grouping to link similar questions together. * @return string the name this question should have. */ - public function question_name($category, $includesubcategories, $tagnames = []) { + public function question_name($category, $includesubcategories, $tagnames = [], $grouping = null) { $categoryname = ''; if ($category->parent && $includesubcategories) { $stringid = 'randomqplusname'; @@ -160,17 +161,21 @@ $stringid = 'randomqnamefromtop'; } + $a = new stdClass(); + if ($categoryname) { + $a->category = $categoryname; + } + if ($tagnames) { $stringid .= 'tags'; - $a = new stdClass(); - if ($categoryname) { - $a->category = $categoryname; - } $a->tags = implode(', ', array_map(function($tagname) { return explode(',', $tagname)[1]; }, $tagnames)); - } else { - $a = $categoryname ? : null; + } + + if(!empty($grouping)) { + $stringid .= 'grouping'; + $a->grouping = $grouping; } $name = get_string($stringid, 'qtype_random', $a); @@ -197,6 +202,7 @@ $form->includesubcategories = true; } } + $form->randomquizgroup = $form->randomgrouping; $form->tags = array(); @@ -227,7 +233,8 @@ // We also force the question name to be 'Random (categoryname)'. $category = $DB->get_record('question_categories', array('id' => $question->category), '*', MUST_EXIST); - $updateobject->name = $this->question_name($category, $question->includesubcategories, $question->fromtags); + $updateobject->name = $this->question_name($category, $question->includesubcategories, + $question->fromtags, $question->randomquizgroup); return $DB->update_record('question', $updateobject); } diff -Naur moodle_prerandgroup/version.php moodle/version.php --- moodle_prerandgroup/version.php 2020-08-14 16:42:37.000000000 +1000 +++ moodle/version.php 2020-08-18 17:28:46.525937968 +1000 @@ -29,9 +29,9 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2020061501.07; // 20200615 = branching date YYYYMMDD - do not modify! +$version = 2020061501.09; // 20200615 = branching date YYYYMMDD - do not modify! // RR = release increments - 00 in DEV branches. // .XX = incremental changes. -$release = '3.9.1+ (Build: 20200814)'; // Human-friendly version name +$release = '3.9.1+ (Build: 20200818)'; // Human-friendly version name $branch = '39'; // This version's branch. $maturity = MATURITY_STABLE; // This version's maturity level.