Merge branch 'master_MDL-71696-versioning-integration' of https://github.com/catalyst/moodle-MDL-70329

This commit is contained in:
Sara Arjona 2022-02-03 13:25:27 +01:00
commit b841a811be
223 changed files with 7768 additions and 2899 deletions

View file

@ -0,0 +1,2 @@
define ("mod_quiz/question_slot",["exports","core/ajax","core/notification"],function(a,b,c){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;c=function(a){return a&&a.__esModule?a:{default:a}}(c);var d=function(a,c){return(0,b.call)([{methodname:"mod_quiz_set_question_version",args:{slotid:a,newversion:c}}])[0]},e=function(){document.addEventListener("change",function(a){if(!a.target.matches("[data-action=\"mod_quiz-select_slot\"][data-slot-id]")){return}var b=a.target.dataset.slotId,e=parseInt(a.target.value);d(b,e).then(function(){location.reload()}).catch(c.default.exception)})};a.init=function init(){if(!1){return}e()}});
//# sourceMappingURL=question_slot.min.js.map

View file

@ -0,0 +1 @@
{"version":3,"sources":["../src/question_slot.js"],"names":["setQuestionVersion","slotId","newVersion","methodname","args","slotid","newversion","registerEventListeners","document","addEventListener","e","target","matches","dataset","parseInt","value","then","location","reload","catch","Notification","exception","init"],"mappings":"yKAyBA,uD,GASMA,CAAAA,CAAkB,CAAG,SAACC,CAAD,CAASC,CAAT,QAAwB,WAAU,CAAC,CAC1DC,UAAU,CAAE,+BAD8C,CAE1DC,IAAI,CAAE,CACFC,MAAM,CAAEJ,CADN,CAEFK,UAAU,CAAEJ,CAFV,CAFoD,CAAD,CAAV,EAM/C,CAN+C,CAAxB,C,CAWrBK,CAAsB,CAAG,UAAM,CACjCC,QAAQ,CAACC,gBAAT,CAA0B,QAA1B,CAAoC,SAAAC,CAAC,CAAI,CACrC,GAAI,CAACA,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiB,sDAAjB,CAAL,CAA6E,CACzE,MACH,CAHoC,GAK/BX,CAAAA,CAAM,CAAGS,CAAC,CAACC,MAAF,CAASE,OAAT,CAAiBZ,MALK,CAM/BC,CAAU,CAAGY,QAAQ,CAACJ,CAAC,CAACC,MAAF,CAASI,KAAV,CANU,CAQrCf,CAAkB,CAACC,CAAD,CAASC,CAAT,CAAlB,CACKc,IADL,CACU,UAAM,CACRC,QAAQ,CAACC,MAAT,EAEH,CAJL,EAKKC,KALL,CAKWC,UAAaC,SALxB,CAMH,CAdD,CAeH,C,QAQmB,QAAPC,CAAAA,IAAO,EAAM,CACtB,MAAsB,CAClB,MACH,CAEDf,CAAsB,EACzB,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Render the question slot template for each question in the quiz edit view.\n *\n * @module mod_quiz/question_slot\n * @copyright 2021 Catalyst IT Australia Pty Ltd\n * @author Guillermo Gomez Arias <guillermogomez@catalyst-au.net>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {call as fetchMany} from 'core/ajax';\nimport Notification from 'core/notification';\n\n/**\n * Set the question version for the slot.\n *\n * @param {Number} slotId\n * @param {Number} newVersion\n * @return {Array} The modified question version\n */\nconst setQuestionVersion = (slotId, newVersion) => fetchMany([{\n methodname: 'mod_quiz_set_question_version',\n args: {\n slotid: slotId,\n newversion: newVersion,\n }\n}])[0];\n\n/**\n * Replace the container with a new version.\n */\nconst registerEventListeners = () => {\n document.addEventListener('change', e => {\n if (!e.target.matches('[data-action=\"mod_quiz-select_slot\"][data-slot-id]')) {\n return;\n }\n\n const slotId = e.target.dataset.slotId;\n const newVersion = parseInt(e.target.value);\n\n setQuestionVersion(slotId, newVersion)\n .then(() => {\n location.reload();\n return;\n })\n .catch(Notification.exception);\n });\n};\n\n/** @property {Boolean} eventsRegistered If the event has been registered or not */\nlet eventsRegistered = false;\n\n/**\n * Entrypoint of the js.\n */\nexport const init = () => {\n if (eventsRegistered) {\n return;\n }\n\n registerEventListeners();\n};\n"],"file":"question_slot.min.js"}

View file

@ -0,0 +1,76 @@
// 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/>.
/**
* Render the question slot template for each question in the quiz edit view.
*
* @module mod_quiz/question_slot
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Guillermo Gomez Arias <guillermogomez@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {call as fetchMany} from 'core/ajax';
import Notification from 'core/notification';
/**
* Set the question version for the slot.
*
* @param {Number} slotId
* @param {Number} newVersion
* @return {Array} The modified question version
*/
const setQuestionVersion = (slotId, newVersion) => fetchMany([{
methodname: 'mod_quiz_set_question_version',
args: {
slotid: slotId,
newversion: newVersion,
}
}])[0];
/**
* Replace the container with a new version.
*/
const registerEventListeners = () => {
document.addEventListener('change', e => {
if (!e.target.matches('[data-action="mod_quiz-select_slot"][data-slot-id]')) {
return;
}
const slotId = e.target.dataset.slotId;
const newVersion = parseInt(e.target.value);
setQuestionVersion(slotId, newVersion)
.then(() => {
location.reload();
return;
})
.catch(Notification.exception);
});
};
/** @property {Boolean} eventsRegistered If the event has been registered or not */
let eventsRegistered = false;
/**
* Entrypoint of the js.
*/
export const init = () => {
if (eventsRegistered) {
return;
}
registerEventListeners();
};

View file

@ -145,12 +145,16 @@ class quiz {
* Load just basic information about all the questions in this quiz.
*/
public function preload_questions() {
$this->questions = question_preload_questions(null,
'slot.maxmark, slot.id AS slotid, slot.slot, slot.page,
slot.questioncategoryid AS randomfromcategory,
slot.includingsubcategories AS randomincludingsubcategories',
'{quiz_slots} slot ON slot.quizid = :quizid AND q.id = slot.questionid',
array('quizid' => $this->quiz->id), 'slot.slot');
$specificquestionids = \mod_quiz\question\bank\qbank_helper::get_specific_version_question_ids($this->quiz->id);
$latestquestionids = \mod_quiz\question\bank\qbank_helper::get_always_latest_version_question_ids($this->quiz->id);
$questionids = array_merge($specificquestionids, $latestquestionids);
$questiondata = [];
if (!empty($questionids)) {
$questiondata = \mod_quiz\question\bank\qbank_helper::get_question_structure_data($this->quiz->id, $questionids, true);
}
$allquestiondata = \mod_quiz\question\bank\qbank_helper::question_array_sort(
\mod_quiz\question\bank\qbank_helper::question_load_random_questions($this->quiz->id, $questiondata), 'slot');
$this->questions = $allquestiondata;
}
/**
@ -535,21 +539,9 @@ class quiz {
// To control if we need to look in categories for questions.
$qcategories = array();
// We must be careful with random questions, if we find a random question we must assume that the quiz may content
// any of the questions in the referenced category (or subcategories).
foreach ($this->get_questions() as $questiondata) {
if ($questiondata->qtype == 'random' and $includepotential) {
$includesubcategories = (bool) $questiondata->questiontext;
if (!isset($qcategories[$questiondata->category])) {
$qcategories[$questiondata->category] = false;
}
if ($includesubcategories) {
$qcategories[$questiondata->category] = true;
}
} else {
if (!in_array($questiondata->qtype, $questiontypes)) {
$questiontypes[] = $questiondata->qtype;
}
if (!in_array($questiondata->qtype, $questiontypes)) {
$questiontypes[] = $questiondata->qtype;
}
}
@ -709,8 +701,7 @@ class quiz_attempt {
$this->quba = question_engine::load_questions_usage_by_activity($this->attempt->uniqueid);
$this->slots = $DB->get_records('quiz_slots',
array('quizid' => $this->get_quizid()), 'slot',
'slot, id, requireprevious, questionid, includingsubcategories');
array('quizid' => $this->get_quizid()), 'slot', 'slot, id, requireprevious');
$this->sections = array_values($DB->get_records('quiz_sections',
array('quizid' => $this->get_quizid()), 'firstslot'));
@ -1314,11 +1305,11 @@ class quiz_attempt {
*/
public function is_blocked_by_previous_question($slot) {
return $slot > 1 && isset($this->slots[$slot]) && $this->slots[$slot]->requireprevious &&
!$this->slots[$slot]->section->shufflequestions &&
!$this->slots[$slot - 1]->section->shufflequestions &&
$this->get_navigation_method() != QUIZ_NAVMETHOD_SEQ &&
!$this->get_question_state($slot - 1)->is_finished() &&
$this->quba->can_question_finish_during_attempt($slot - 1);
!$this->slots[$slot]->section->shufflequestions &&
!$this->slots[$slot - 1]->section->shufflequestions &&
$this->get_navigation_method() != QUIZ_NAVMETHOD_SEQ &&
!$this->get_question_state($slot - 1)->is_finished() &&
$this->quba->can_question_finish_during_attempt($slot - 1);
}
/**
@ -1807,8 +1798,7 @@ class quiz_attempt {
$question->length = $replacedquestion->length;
$question->penalty = 0;
$question->stamp = '';
$question->version = 0;
$question->hidden = 0;
$question->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
$question->timecreated = null;
$question->timemodified = null;
$question->createdby = null;
@ -2108,25 +2098,9 @@ class quiz_attempt {
$transaction = $DB->start_delegated_transaction();
// Choose the replacement question.
$questiondata = $DB->get_record('question',
array('id' => $this->slots[$slot]->questionid));
if ($questiondata->qtype != 'random') {
$newqusetionid = $questiondata->id;
} else {
$tagids = quiz_retrieve_slot_tag_ids($this->slots[$slot]->id);
$randomloader = new \core_question\local\bank\random_question_loader($qubaids, array());
$newqusetionid = $randomloader->get_next_question_id($questiondata->category,
(bool) $questiondata->questiontext, $tagids);
if ($newqusetionid === null) {
throw new moodle_exception('notenoughrandomquestions', 'quiz',
$this->quizobj->view_url(), $questiondata);
}
}
// Add the question to the usage. It is important we do this before we choose a variant.
$newquestion = question_bank::load_question($newqusetionid);
$newquestion = question_bank::load_question(
\mod_quiz\question\bank\qbank_helper::choose_question_for_redo($this->slots[$slot]->id, $qubaids));
$newslot = $this->quba->add_question_in_place_of_other($slot, $newquestion);
// Choose the variant.

View file

@ -15,22 +15,13 @@
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Define all the backup steps that will be used by the backup_quiz_activity_task.
*
* @package mod_quiz
* @subpackage backup-moodle2
* @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Define all the backup steps that will be used by the backup_quiz_activity_task
*
* @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class backup_quiz_activity_structure_step extends backup_questions_activity_structure_step {
protected function define_structure() {
@ -39,7 +30,7 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru
$userinfo = $this->get_setting_value('userinfo');
// Define each element separated.
$quiz = new backup_nested_element('quiz', array('id'), array(
$quiz = new backup_nested_element('quiz', ['id'], [
'name', 'intro', 'introformat', 'timeopen', 'timeclose', 'timelimit',
'overduehandling', 'graceperiod', 'preferredbehaviour', 'canredoquestions', 'attempts_number',
'attemptonlast', 'grademethod', 'decimalpoints', 'questiondecimalpoints',
@ -50,46 +41,44 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru
'sumgrades', 'grade', 'timecreated',
'timemodified', 'password', 'subnet', 'browsersecurity',
'delay1', 'delay2', 'showuserpicture', 'showblocks', 'completionattemptsexhausted',
'completionminattempts', 'allowofflineattempts'));
'completionminattempts', 'allowofflineattempts']);
// Define elements for access rule subplugin settings.
$this->add_subplugin_structure('quizaccess', $quiz, true);
$qinstances = new backup_nested_element('question_instances');
$qinstance = new backup_nested_element('question_instance', array('id'), array(
'slot', 'page', 'requireprevious', 'questionid', 'questioncategoryid', 'includingsubcategories', 'maxmark'));
$qinstance = new backup_nested_element('question_instance', ['id'],
['slot', 'page', 'requireprevious', 'questionid', 'questioncategoryid', 'includingsubcategories', 'maxmark']);
$qinstancetags = new backup_nested_element('tags');
$qinstancetag = new backup_nested_element('tag', array('id'), array('tagid', 'tagname'));
$this->add_question_references($qinstance, 'mod_quiz', 'slot');
$this->add_question_set_references($qinstance, 'mod_quiz', 'slot');
$sections = new backup_nested_element('sections');
$section = new backup_nested_element('section', array('id'), array(
'firstslot', 'heading', 'shufflequestions'));
$section = new backup_nested_element('section', ['id'], ['firstslot', 'heading', 'shufflequestions']);
$feedbacks = new backup_nested_element('feedbacks');
$feedback = new backup_nested_element('feedback', array('id'), array(
'feedbacktext', 'feedbacktextformat', 'mingrade', 'maxgrade'));
$feedback = new backup_nested_element('feedback', ['id'], ['feedbacktext', 'feedbacktextformat', 'mingrade', 'maxgrade']);
$overrides = new backup_nested_element('overrides');
$override = new backup_nested_element('override', array('id'), array(
$override = new backup_nested_element('override', ['id'], [
'userid', 'groupid', 'timeopen', 'timeclose',
'timelimit', 'attempts', 'password'));
'timelimit', 'attempts', 'password']);
$grades = new backup_nested_element('grades');
$grade = new backup_nested_element('grade', array('id'), array(
'userid', 'gradeval', 'timemodified'));
$grade = new backup_nested_element('grade', ['id'], ['userid', 'gradeval', 'timemodified']);
$attempts = new backup_nested_element('attempts');
$attempt = new backup_nested_element('attempt', array('id'), array(
$attempt = new backup_nested_element('attempt', ['id'], [
'userid', 'attemptnum', 'uniqueid', 'layout', 'currentpage', 'preview',
'state', 'timestart', 'timefinish', 'timemodified', 'timemodifiedoffline',
'timecheckstate', 'sumgrades', 'gradednotificationsenttime'));
'timecheckstate', 'sumgrades', 'gradednotificationsenttime']);
// This module is using questions, so produce the related question states and sessions
// attaching them to the $attempt element based in 'uniqueid' matching.
@ -102,9 +91,6 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru
$quiz->add_child($qinstances);
$qinstances->add_child($qinstance);
$qinstance->add_child($qinstancetags);
$qinstancetags->add_child($qinstancetag);
$quiz->add_child($sections);
$sections->add_child($section);
@ -121,22 +107,16 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru
$attempts->add_child($attempt);
// Define sources.
$quiz->set_source_table('quiz', array('id' => backup::VAR_ACTIVITYID));
$quiz->set_source_table('quiz', ['id' => backup::VAR_ACTIVITYID]);
$qinstance->set_source_table('quiz_slots',
array('quizid' => backup::VAR_PARENTID));
$qinstance->set_source_table('quiz_slots', ['quizid' => backup::VAR_PARENTID]);
$qinstancetag->set_source_table('quiz_slot_tags',
array('slotid' => backup::VAR_PARENTID));
$section->set_source_table('quiz_sections', ['quizid' => backup::VAR_PARENTID]);
$section->set_source_table('quiz_sections',
array('quizid' => backup::VAR_PARENTID));
$feedback->set_source_table('quiz_feedback',
array('quizid' => backup::VAR_PARENTID));
$feedback->set_source_table('quiz_feedback', ['quizid' => backup::VAR_PARENTID]);
// Quiz overrides to backup are different depending of user info.
$overrideparams = array('quiz' => backup::VAR_PARENTID);
$overrideparams = ['quiz' => backup::VAR_PARENTID];
if (!$userinfo) { // Without userinfo, skip user overrides.
$overrideparams['userid'] = backup_helper::is_sqlparam(null);
@ -152,12 +132,11 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru
// All the rest of elements only happen if we are including user info.
if ($userinfo) {
$grade->set_source_table('quiz_grades', array('quiz' => backup::VAR_PARENTID));
$grade->set_source_table('quiz_grades', ['quiz' => backup::VAR_PARENTID]);
$attempt->set_source_sql('
SELECT *
FROM {quiz_attempts}
WHERE quiz = :quiz AND preview = 0',
array('quiz' => backup::VAR_PARENTID));
WHERE quiz = :quiz AND preview = 0', ['quiz' => backup::VAR_PARENTID]);
}
// Define source alias.

View file

@ -14,20 +14,11 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* @package mod_quiz
* @subpackage backup-moodle2
* @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Structure step to restore one quiz activity
*
* @package mod_quiz
* @subpackage backup-moodle2
* @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
@ -60,10 +51,16 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st
// A chance for access subplugings to set up their quiz data.
$this->add_subplugin_structure('quizaccess', $quiz);
$paths[] = new restore_path_element('quiz_question_instance',
'/activity/quiz/question_instances/question_instance');
$paths[] = new restore_path_element('quiz_slot_tags',
$quizquestioninstance = new restore_path_element('quiz_question_instance',
'/activity/quiz/question_instances/question_instance');
$paths[] = $quizquestioninstance;
if ($this->task->get_old_moduleversion() < 2021091700) {
$paths[] = new restore_path_element('quiz_slot_tags',
'/activity/quiz/question_instances/question_instance/tags/tag');
} else {
$this->add_question_references($quizquestioninstance, $paths);
$this->add_question_set_references($quizquestioninstance, $paths);
}
$paths[] = new restore_path_element('quiz_section', '/activity/quiz/sections/section');
$paths[] = new restore_path_element('quiz_feedback', '/activity/quiz/feedbacks/feedback');
$paths[] = new restore_path_element('quiz_override', '/activity/quiz/overrides/override');
@ -99,6 +96,11 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st
return $this->prepare_activity_structure($paths);
}
/**
* Process the quiz data.
*
* @param stdClass|array $data
*/
protected function process_quiz($data) {
global $CFG, $DB, $USER;
@ -303,6 +305,64 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st
}
}
/**
* Process the data for pre 4.0 quiz data where the question_references and question_set_references table introduced.
*
* @param stdClass|array $data
*/
protected function process_quiz_question_legacy_instance($data) {
global $DB;
$questionid = $this->get_mappingid('question', $data->questionid);
$sql = 'SELECT qbe.id as questionbankentryid,
qc.contextid as questioncontextid,
qc.id as category,
qv.version,
q.qtype,
q.id as questionid
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE q.id = ?';
$question = $DB->get_record_sql($sql, [$questionid]);
$module = $DB->get_record('quiz', ['id' => $data->quizid]);
if ($question->qtype === 'random') {
// Set reference data.
$questionsetreference = new \stdClass();
$questionsetreference->usingcontextid = context_module::instance(get_coursemodule_from_instance(
"quiz", $module->id, $module->course)->id)->id;
$questionsetreference->component = 'mod_quiz';
$questionsetreference->questionarea = 'slot';
$questionsetreference->itemid = $data->id;
$questionsetreference->questionscontextid = $question->questioncontextid;
$filtercondition = new stdClass();
$filtercondition->questioncategoryid = $question->category;
$filtercondition->includingsubcategories = $data->includingsubcategories;
$questionsetreference->filtercondition = json_encode($filtercondition);
$DB->insert_record('question_set_references', $questionsetreference);
// Cleanup leftover random qtype data from question table.
question_delete_question($question->questionid);
} else {
// Reference data.
$questionreference = new \stdClass();
$questionreference->usingcontextid = context_module::instance(get_coursemodule_from_instance(
"quiz", $module->id, $module->course)->id)->id;
$questionreference->component = 'mod_quiz';
$questionreference->questionarea = 'slot';
$questionreference->itemid = $data->id;
$questionreference->questionbankentryid = $question->questionbankentryid;
$questionreference->version = $question->version;
$DB->insert_record('question_references', $questionreference);
}
}
/**
* Process quiz slots.
*
* @param stdClass|array $data
*/
protected function process_quiz_question_instance($data) {
global $CFG, $DB;
@ -325,7 +385,7 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st
$page += 1;
continue;
}
if ($item == $data->questionid) {
if (isset($data->questionid) && $item == $data->questionid) {
$data->slot = $slot;
$data->page = $page;
break;
@ -344,20 +404,15 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st
}
$data->quizid = $this->get_new_parentid('quiz');
$questionmapping = $this->get_mapping('question', $data->questionid);
$data->questionid = $questionmapping ? $questionmapping->newitemid : false;
if (isset($data->questioncategoryid)) {
$data->questioncategoryid = $this->get_mappingid('question_category', $data->questioncategoryid);
} else if ($questionmapping && $questionmapping->info->qtype == 'random') {
// Backward compatibility for backups created using Moodle 3.4 or earlier.
$data->questioncategoryid = $this->get_mappingid('question_category', $questionmapping->parentitemid);
$data->includingsubcategories = $questionmapping->info->questiontext ? 1 : 0;
}
$newitemid = $DB->insert_record('quiz_slots', $data);
// Add mapping, restore of slot tags (for random questions) need it.
$this->set_mapping('quiz_question_instance', $oldid, $newitemid);
if ($this->task->get_old_moduleversion() < 2022020300) {
$data->id = $newitemid;
$this->process_quiz_question_legacy_instance($data);
}
}
/**
@ -370,7 +425,7 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st
$data = (object)$data;
$data->slotid = $this->get_new_parentid('quiz_question_instance');
$slotid = $this->get_new_parentid('quiz_question_instance');
if ($this->task->is_samesite() && $tag = core_tag_tag::get($data->tagid, 'id, name')) {
$data->tagname = $tag->name;
} else if ($tag = core_tag_tag::get_by_name(0, $data->tagname, 'id, name')) {
@ -379,8 +434,22 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st
$data->tagid = null;
$data->tagname = $tag->name;
}
$tagstring = "{$data->tagid},{$data->tagname}";
$setreferencedata = $DB->get_record('question_set_references', ['itemid' => $slotid]);
$DB->insert_record('quiz_slot_tags', $data);
$filtercondition = json_decode($setreferencedata->filtercondition);
$tagstrings = [];
if (isset($filtercondition->tags)) {
$tags = explode(',', $filtercondition->tags);
foreach ($tags as $tag) {
$tagstrings [] = $tag;
}
}
$tagstrings [] = $tagstring;
$filtercondition->tags = $tagstrings;
$setreferencedata->filtercondition = json_encode($filtercondition);
$DB->update_record('question_set_references', $setreferencedata);
}
protected function process_quiz_section($data) {

View file

@ -0,0 +1,105 @@
<?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/>.
namespace mod_quiz\external;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . '/externallib.php');
require_once($CFG->dirroot . '/question/engine/lib.php');
require_once($CFG->dirroot . '/question/engine/datalib.php');
require_once($CFG->libdir . '/questionlib.php');
use external_api;
use external_description;
use external_function_parameters;
use external_single_structure;
use external_value;
use stdClass;
/**
* External api for changing the question version in the quiz.
*
* @package mod_quiz
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class submit_question_version extends external_api {
/**
* Parameters for the submit_question_version.
*
* @return \external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters (
[
'slotid' => new external_value(PARAM_INT, ''),
'newversion' => new external_value(PARAM_INT, '')
]
);
}
/**
* Set the questions slot parameters to display the question template.
*
* @param int $slotid Slot id to display.
* @param int $newversion
* @return array
*/
public static function execute(int $slotid, int $newversion): array {
global $DB;
$params = [
'slotid' => $slotid,
'newversion' => $newversion
];
$params = self::validate_parameters(self::execute_parameters(), $params);
$response = ['result' => false];
// Get the required data.
$referencedata = $DB->get_record('question_references', ['itemid' => $params['slotid']]);
$slotdata = $DB->get_record('quiz_slots', ['id' => $slotid]);
// Capability check.
list($course, $cm) = get_course_and_cm_from_instance($slotdata->quizid, 'quiz');
$context = \context_module::instance($cm->id);
self::validate_context($context);
require_capability('mod/quiz:manage', $context);
$reference = new stdClass();
$reference->id = $referencedata->id;
if ($params['newversion'] === 0) {
$reference->version = null;
} else {
$reference->version = $params['newversion'];
}
$response['result'] = $DB->update_record('question_references', $reference);
return $response;
}
/**
* Define the webservice response.
*
* @return external_description
*/
public static function execute_returns() {
return new external_single_structure(
[
'result' => new external_value(PARAM_BOOL, '')
]
);
}
}

View file

@ -14,23 +14,14 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Defines the \mod_quiz\local\structure\slot_random class.
*
* @package mod_quiz
* @copyright 2018 Shamim Rezaie <shamim@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_quiz\local\structure;
defined('MOODLE_INTERNAL') || die();
/**
* Class slot_random, represents a random question slot type.
*
* @package mod_quiz
* @copyright 2018 Shamim Rezaie <shamim@moodle.com>
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class slot_random {
@ -38,6 +29,11 @@ class slot_random {
/** @var \stdClass Slot's properties. A record retrieved from the quiz_slots table. */
protected $record;
/**
* @var \stdClass set reference record
*/
protected $referencerecord;
/**
* @var \stdClass The quiz this question slot belongs to.
*/
@ -48,6 +44,11 @@ class slot_random {
*/
protected $tags = [];
/**
* @var string filter condition
*/
protected $filtercondition = null;
/**
* slot_random constructor.
*
@ -55,16 +56,22 @@ class slot_random {
*/
public function __construct($slotrecord = null) {
$this->record = new \stdClass();
$this->referencerecord = new \stdClass();
$properties = array(
'id', 'slot', 'quizid', 'page', 'requireprevious', 'questionid',
'questioncategoryid', 'includingsubcategories', 'maxmark');
$slotproperties = ['id', 'slot', 'quizid', 'page', 'requireprevious', 'maxmark'];
$setreferenceproperties = ['usingcontextid', 'questionscontextid'];
foreach ($properties as $property) {
foreach ($slotproperties as $property) {
if (isset($slotrecord->$property)) {
$this->record->$property = $slotrecord->$property;
}
}
foreach ($setreferenceproperties as $referenceproperty) {
if (isset($slotrecord->$referenceproperty)) {
$this->referencerecord->$referenceproperty = $slotrecord->$referenceproperty;
}
}
}
/**
@ -122,6 +129,19 @@ class slot_random {
$this->tags = \core_tag_tag::get_bulk($tagids, 'id, name');
}
/**
* Set filter condition.
*
* @param \stdClass $filters
*/
public function set_filter_condition($filters) {
if (!empty($this->tags)) {
$filters->tags = $this->tags;
}
$this->filtercondition = json_encode($filters);
}
/**
* Inserts the quiz slot at the $page page.
* It is required to call this function if you are building a quiz slot object from scratch.
@ -179,17 +199,11 @@ class slot_random {
$this->record->id = $DB->insert_record('quiz_slots', $this->record);
if (!empty($this->tags)) {
$recordstoinsert = [];
foreach ($this->tags as $tag) {
$recordstoinsert[] = (object)[
'slotid' => $this->record->id,
'tagid' => $tag->id,
'tagname' => $tag->name
];
}
$DB->insert_records('quiz_slot_tags', $recordstoinsert);
}
$this->referencerecord->component = 'mod_quiz';
$this->referencerecord->questionarea = 'slot';
$this->referencerecord->itemid = $this->record->id;
$this->referencerecord->filtercondition = $this->filtercondition;
$DB->insert_record('question_set_references', $this->referencerecord);
$trans->allow_commit();

View file

@ -25,8 +25,10 @@
namespace mod_quiz\output;
defined('MOODLE_INTERNAL') || die();
use mod_quiz\question\bank\qbank_helper;
use \mod_quiz\structure;
use \html_writer;
use \qbank_previewquestion\helper;
use renderable;
/**
@ -46,13 +48,13 @@ class edit_renderer extends \plugin_renderer_base {
*
* @param \quiz $quizobj object containing all the quiz settings information.
* @param structure $structure object containing the structure of the quiz.
* @param \question_edit_contexts $contexts the relevant question bank contexts.
* @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts.
* @param \moodle_url $pageurl the canonical URL of this page.
* @param array $pagevars the variables from {@link question_edit_setup()}.
* @return string HTML to output.
*/
public function edit_page(\quiz $quizobj, structure $structure,
\question_edit_contexts $contexts, \moodle_url $pageurl, array $pagevars) {
\core_question\local\bank\question_edit_contexts $contexts, \moodle_url $pageurl, array $pagevars) {
$output = '';
// Information at the top.
@ -493,7 +495,7 @@ class edit_renderer extends \plugin_renderer_base {
*
* @param structure $structure object containing the structure of the quiz.
* @param \stdClass $section information about the section.
* @param \question_edit_contexts $contexts the relevant question bank contexts.
* @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts.
* @param array $pagevars the variables from {@link \question_edit_setup()}.
* @param \moodle_url $pageurl the canonical URL of this page.
* @return string HTML to output.
@ -513,7 +515,7 @@ class edit_renderer extends \plugin_renderer_base {
*
* @param structure $structure object containing the structure of the quiz.
* @param int $slot which slot we are outputting.
* @param \question_edit_contexts $contexts the relevant question bank contexts.
* @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts.
* @param array $pagevars the variables from {@link \question_edit_setup()}.
* @param \moodle_url $pageurl the canonical URL of this page.
* @return string HTML to output.
@ -546,7 +548,7 @@ class edit_renderer extends \plugin_renderer_base {
*
* @param structure $structure object containing the structure of the quiz.
* @param int $slot the first slot on the page we are outputting.
* @param \question_edit_contexts $contexts the relevant question bank contexts.
* @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts.
* @param array $pagevars the variables from {@link \question_edit_setup()}.
* @param \moodle_url $pageurl the canonical URL of this page.
* @return string HTML to output.
@ -580,12 +582,12 @@ class edit_renderer extends \plugin_renderer_base {
* @param structure $structure object containing the structure of the quiz.
* @param int $page the page number that this menu will add to.
* @param \moodle_url $pageurl the canonical URL of this page.
* @param \question_edit_contexts $contexts the relevant question bank contexts.
* @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts.
* @param array $pagevars the variables from {@link \question_edit_setup()}.
* @return string HTML to output.
*/
public function add_menu_actions(structure $structure, $page, \moodle_url $pageurl,
\question_edit_contexts $contexts, array $pagevars) {
\core_question\local\bank\question_edit_contexts $contexts, array $pagevars) {
$actions = $this->edit_menu_actions($structure, $page, $pageurl, $pagevars);
if (empty($actions)) {
@ -727,7 +729,11 @@ class edit_renderer extends \plugin_renderer_base {
* @param \moodle_url $pageurl the canonical URL of this page.
* @return string HTML to output.
*/
public function question(structure $structure, $slot, \moodle_url $pageurl) {
public function question(structure $structure, int $slot, \moodle_url $pageurl) {
global $DB;
// Get the data required by the question_slot template.
$slotid = $structure->get_slot_id_for_slot($slot);
$output = '';
$output .= html_writer::start_tag('div');
@ -735,50 +741,92 @@ class edit_renderer extends \plugin_renderer_base {
$output .= $this->question_move_icon($structure, $slot);
}
$output .= html_writer::start_div('mod-indent-outer');
$checkbox = new \core\output\checkbox_toggleall($this->togglegroup, false, [
'id' => 'selectquestion-' . $structure->get_displayed_number_for_slot($slot),
'name' => 'selectquestion[]',
'value' => $structure->get_displayed_number_for_slot($slot),
'classes' => 'select-multiple-checkbox',
]);
$output .= $this->render($checkbox);
$output .= $this->question_number($structure->get_displayed_number_for_slot($slot));
$data = [
'slotid' => $slotid,
'canbeedited' => $structure->can_be_edited(),
'checkbox' => $this->get_checkbox_render($structure, $slot),
'questionnumber' => $this->question_number($structure->get_displayed_number_for_slot($slot)),
'questionname' => $this->get_question_name_for_slot($structure, $slot, $pageurl),
'questionicons' => $this->get_action_icon($structure, $slot, $pageurl),
'questiondependencyicon' => ($structure->can_be_edited() ? $this->question_dependency_icon($structure, $slot) : ''),
'versionselection' => false
];
// This div is used to indent the content.
$output .= html_writer::div('', 'mod-indent');
$data['versionoptions'] = [];
if ($structure->get_slot_by_number($slot)->qtype !== 'random') {
$data['versionselection'] = true;
$data['versionoption'] = qbank_helper::get_question_version_info($structure->get_question_in_slot($slot)->id, $slotid);
$this->page->requires->js_call_amd('mod_quiz/question_slot', 'init', [$slotid]);
}
// Render the question slot template.
$output .= $this->render_from_template('mod_quiz/question_slot', $data);
$output .= html_writer::end_tag('div');
return $output;
}
/**
* Get the checkbox render.
*
* @param structure $structure object containing the structure of the quiz.
* @param int $slot the slot on the page we are outputting.
* @return string HTML to output.
*/
public function get_checkbox_render(structure $structure, int $slot) : string {
$checkbox = new \core\output\checkbox_toggleall($this->togglegroup, false,
[
'id' => 'selectquestion-' . $structure->get_displayed_number_for_slot($slot),
'name' => 'selectquestion[]',
'value' => $structure->get_displayed_number_for_slot($slot),
'classes' => 'select-multiple-checkbox',
]);
return $this->render($checkbox);
}
/**
* Get the question name for the slot.
*
* @param structure $structure object containing the structure of the quiz.
* @param int $slot the slot on the page we are outputting.
* @param \moodle_url $pageurl the canonical URL of this page.
* @return string HTML to output.
*/
public function get_question_name_for_slot(structure $structure, int $slot, \moodle_url $pageurl) : string {
// Display the link to the question (or do nothing if question has no url).
if ($structure->get_question_type_for_slot($slot) == 'random') {
if ($structure->get_question_type_for_slot($slot) === 'random') {
$questionname = $this->random_question($structure, $slot, $pageurl);
} else {
$questionname = $this->question_name($structure, $slot, $pageurl);
}
// Start the div for the activity title, excluding the edit icons.
$output .= html_writer::start_div('activityinstance');
$output .= $questionname;
// Closing the tag which contains everything but edit icons. Content part of the module should not be part of this.
$output .= html_writer::end_tag('div'); // .activityinstance.
return $questionname;
}
/**
* Get the action icons render.
*
* @param structure $structure object containing the structure of the quiz.
* @param int $slot the slot on the page we are outputting.
* @param \moodle_url $pageurl the canonical URL of this page.
* @return string HTML to output.
*/
public function get_action_icon(structure $structure, int $slot, \moodle_url $pageurl) : string {
// Action icons.
$qtype = $structure->get_question_type_for_slot($slot);
$questionicons = '';
$questionicons .= $this->question_preview_icon($structure->get_quiz(), $structure->get_question_in_slot($slot));
if ($qtype !== 'random') {
$questionicons .= $this->question_preview_icon($structure->get_quiz(), $structure->get_question_in_slot($slot),
null, null, $qtype);
}
if ($structure->can_be_edited()) {
$questionicons .= $this->question_remove_icon($structure, $slot, $pageurl);
}
$questionicons .= $this->marked_out_of_field($structure, $slot);
$output .= html_writer::span($questionicons, 'actions'); // Required to add js spinner icon.
if ($structure->can_be_edited()) {
$output .= $this->question_dependency_icon($structure, $slot);
}
// End of indentation div.
$output .= html_writer::end_tag('div');
$output .= html_writer::end_tag('div');
return $output;
return $questionicons;
}
/**
@ -814,9 +862,10 @@ class edit_renderer extends \plugin_renderer_base {
* @param \stdClass $question data from the question and quiz_slots tables.
* @param bool $label if true, show the preview question label after the icon
* @param int $variant which question variant to preview (optional).
* @param string $qtype the type of question
* @return string HTML to output.
*/
public function question_preview_icon($quiz, $question, $label = null, $variant = null) {
public function question_preview_icon($quiz, $question, $label = null, $variant = null, $qtype = null) {
$url = quiz_question_preview_url($quiz, $question, $variant);
// Do we want a label?
@ -975,10 +1024,8 @@ class edit_renderer extends \plugin_renderer_base {
* @return string HTML to output.
*/
public function random_question(structure $structure, $slotnumber, $pageurl) {
$question = $structure->get_question_in_slot($slotnumber);
$slot = $structure->get_slot_by_number($slotnumber);
$slottags = $structure->get_slot_tags_for_slot_id($slot->id);
$editurl = new \moodle_url('/mod/quiz/editrandom.php',
array('returnurl' => $pageurl->out_as_local_url(), 'slotid' => $slot->id));
@ -986,6 +1033,9 @@ class edit_renderer extends \plugin_renderer_base {
$temp->questiontext = '';
$instancename = quiz_question_tostring($temp);
$setreference = qbank_helper::get_random_question_data_from_slot($slot->id);
$filtercondition = json_decode($setreference->filtercondition);
$configuretitle = get_string('configurerandomquestion', 'quiz');
$qtype = \question_bank::get_qtype($question->qtype, false);
$namestr = $qtype->local_name();
@ -994,20 +1044,25 @@ class edit_renderer extends \plugin_renderer_base {
$editicon = $this->pix_icon('t/edit', $configuretitle, 'moodle', array('title' => ''));
$qbankurlparams = array(
'cmid' => $structure->get_cmid(),
'cat' => $question->category . ',' . $question->contextid,
'recurse' => !empty($question->questiontext)
'cmid' => $structure->get_cmid(),
'cat' => $filtercondition->questioncategoryid . ',' . $setreference->questionscontextid,
'recurse' => !empty($setreference->questionscontextid)
);
$slottags = [];
if (isset($filtercondition->tags)) {
$slottags = $filtercondition->tags;
}
foreach ($slottags as $index => $slottag) {
$qbankurlparams["qtagids[{$index}]"] = $slottag->tagid;
$slottag = explode(',', $slottag);
$qbankurlparams["qtagids[{$index}]"] = $slottag[0];
}
// If this is a random question, display a link to show the questions
// selected from in the question bank.
$qbankurl = new \moodle_url('/question/edit.php', $qbankurlparams);
$qbanklink = ' ' . \html_writer::link($qbankurl,
get_string('seequestions', 'quiz'), array('class' => 'mod_quiz_random_qbank_link'));
get_string('seequestions', 'quiz'), array('class' => 'mod_quiz_random_qbank_link'));
return html_writer::link($editurl, $icon . $editicon, array('title' => $configuretitle)) .
' ' . $instancename . ' ' . $qbanklink;
@ -1083,13 +1138,13 @@ class edit_renderer extends \plugin_renderer_base {
* is handled with the specific code for those.)
*
* @param structure $structure object containing the structure of the quiz.
* @param \question_edit_contexts $contexts the relevant question bank contexts.
* @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts.
* @param array $pagevars the variables from {@link \question_edit_setup()}.
* @param \moodle_url $pageurl the canonical URL of this page.
* @return bool Always returns true
*/
protected function initialise_editing_javascript(structure $structure,
\question_edit_contexts $contexts, array $pagevars, \moodle_url $pageurl) {
\core_question\local\bank\question_edit_contexts $contexts, array $pagevars, \moodle_url $pageurl) {
$config = new \stdClass();
$config->resourceurl = '/mod/quiz/edit_rest.php';
@ -1189,13 +1244,13 @@ class edit_renderer extends \plugin_renderer_base {
* HTML for a page, with ids stripped, so it can be used as a javascript template.
*
* @param structure $structure object containing the structure of the quiz.
* @param \question_edit_contexts $contexts the relevant question bank contexts.
* @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts.
* @param array $pagevars the variables from {@link \question_edit_setup()}.
* @param \moodle_url $pageurl the canonical URL of this page.
* @return string HTML for a new page.
*/
protected function new_page_template(structure $structure,
\question_edit_contexts $contexts, array $pagevars, \moodle_url $pageurl) {
\core_question\local\bank\question_edit_contexts $contexts, array $pagevars, \moodle_url $pageurl) {
if (!$structure->has_questions()) {
return '';
}

View file

@ -25,6 +25,9 @@
namespace mod_quiz\question\bank;
use core_question\local\bank\question_version_status;
use mod_quiz\question\bank\filter\custom_category_condition;
/**
* Subclass to customise the view of the question bank for the quiz editing screen.
*
@ -44,8 +47,8 @@ class custom_view extends \core_question\local\bank\view {
const MAX_TEXT_LENGTH = 200;
/**
* Constructor for custom_view.
* @param \question_edit_contexts $contexts
* Constructor.
* @param \core_question\local\bank\question_edit_contexts $contexts
* @param \moodle_url $pageurl
* @param \stdClass $course course settings
* @param \stdClass $cm activity settings.
@ -251,4 +254,81 @@ class custom_view extends \core_question\local\bank\view {
$this->sort[$sort] = $order;
}
}
protected function build_query(): void {
// Get the required tables and fields.
$joins = [];
$fields = ['qv.status', 'qc.id', 'qv.version', 'qv.id as versionid', 'qbe.id as questionbankentryid'];
if (!empty($this->requiredcolumns)) {
foreach ($this->requiredcolumns as $column) {
$extrajoins = $column->get_extra_joins();
foreach ($extrajoins as $prefix => $join) {
if (isset($joins[$prefix]) && $joins[$prefix] != $join) {
throw new \coding_exception('Join ' . $join . ' conflicts with previous join ' . $joins[$prefix]);
}
$joins[$prefix] = $join;
}
$fields = array_merge($fields, $column->get_required_fields());
}
}
$fields = array_unique($fields);
// Build the order by clause.
$sorts = [];
foreach ($this->sort as $sort => $order) {
list($colname, $subsort) = $this->parse_subsort($sort);
$sorts[] = $this->requiredcolumns[$colname]->sort_expression($order < 0, $subsort);
}
// Build the where clause.
$latestversion = 'qv.version = (SELECT MAX(v.version)
FROM {question_versions} v
JOIN {question_bank_entries} be
ON be.id = v.questionbankentryid
WHERE be.id = qbe.id)';
$readyonly = "qv.status = '" . question_version_status::QUESTION_STATUS_READY . "' ";
$tests = ['q.parent = 0', $latestversion, $readyonly];
$this->sqlparams = [];
foreach ($this->searchconditions as $searchcondition) {
if ($searchcondition->where()) {
$tests[] = '((' . $searchcondition->where() .'))';
}
if ($searchcondition->params()) {
$this->sqlparams = array_merge($this->sqlparams, $searchcondition->params());
}
}
// Build the SQL.
$sql = ' FROM {question} q ' . implode(' ', $joins);
$sql .= ' WHERE ' . implode(' AND ', $tests);
$this->countsql = 'SELECT count(1)' . $sql;
$this->loadsql = 'SELECT ' . implode(', ', $fields) . $sql . ' ORDER BY ' . implode(', ', $sorts);
}
public function wanted_filters($cat, $tagids, $showhidden, $recurse, $editcontexts, $showquestiontext): void {
global $CFG;
list(, $contextid) = explode(',', $cat);
$catcontext = \context::instance_by_id($contextid);
$thiscontext = $this->get_most_specific_context();
// Category selection form.
$this->display_question_bank_header();
// Display tag filter if usetags setting is enabled/enablefilters is true.
if ($this->enablefilters) {
if (is_array($this->customfilterobjects)) {
foreach ($this->customfilterobjects as $filterobjects) {
$this->searchconditions[] = $filterobjects;
}
} else {
if ($CFG->usetags) {
array_unshift($this->searchconditions,
new \core_question\bank\search\tag_condition([$catcontext, $thiscontext], $tagids));
}
array_unshift($this->searchconditions, new \core_question\bank\search\hidden_condition(!$showhidden));
array_unshift($this->searchconditions, new custom_category_condition(
$cat, $recurse, $editcontexts, $this->baseurl, $this->course));
}
}
$this->display_options_form($showquestiontext);
}
}

View file

@ -0,0 +1,45 @@
<?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/>.
namespace mod_quiz\question\bank\filter;
/**
* A custom filter condition for quiz to select question categories.
*
* This is required as quiz will only use ready questions and the count should show according to that.
*
* @package mod_quiz
* @category question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class custom_category_condition extends \core_question\bank\search\category_condition {
public function display_options() {
global $PAGE;
$displaydata = [];
$catmenu = custom_category_condition_helper::question_category_options($this->contexts, true, 0,
true, -1, false);
$displaydata['categoryselect'] = \html_writer::select($catmenu, 'category', $this->cat, [],
['class' => 'searchoptions custom-select', 'id' => 'id_selectacategory']);
$displaydata['categorydesc'] = '';
if ($this->category) {
$displaydata['categorydesc'] = $this->print_category_info($this->category);
}
return $PAGE->get_renderer('core_question', 'bank')->render_category_condition($displaydata);
}
}

View file

@ -0,0 +1,129 @@
<?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/>.
namespace mod_quiz\question\bank\filter;
use core_question\local\bank\question_version_status;
/**
* A custom filter condition helper for quiz to select question categories.
*
* This is required as quiz will only use ready questions and the count should show according to that.
*
* @package mod_quiz
* @category question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class custom_category_condition_helper extends \qbank_managecategories\helper {
public static function question_category_options(array $contexts, bool $top = false, int $currentcat = 0,
bool $popupform = false, int $nochildrenof = -1,
bool $escapecontextnames = true): array {
global $CFG;
$pcontexts = [];
foreach ($contexts as $context) {
$pcontexts[] = $context->id;
}
$contextslist = join(', ', $pcontexts);
$categories = self::get_categories_for_contexts($contextslist, 'parent, sortorder, name ASC', $top);
if ($top) {
$categories = self::question_fix_top_names($categories);
}
$categories = self::question_add_context_in_key($categories);
$categories = self::add_indented_names($categories, $nochildrenof);
// Sort cats out into different contexts.
$categoriesarray = [];
foreach ($pcontexts as $contextid) {
$context = \context::instance_by_id($contextid);
$contextstring = $context->get_context_name(true, true, $escapecontextnames);
foreach ($categories as $category) {
if ($category->contextid == $contextid) {
$cid = $category->id;
if ($currentcat != $cid || $currentcat == 0) {
$a = new \stdClass;
$a->name = format_string($category->indentedname, true,
['context' => $context]);
if ($category->idnumber !== null && $category->idnumber !== '') {
$a->idnumber = s($category->idnumber);
}
if (!empty($category->questioncount)) {
$a->questioncount = $category->questioncount;
}
if (isset($a->idnumber) && isset($a->questioncount)) {
$formattedname = get_string('categorynamewithidnumberandcount', 'question', $a);
} else if (isset($a->idnumber)) {
$formattedname = get_string('categorynamewithidnumber', 'question', $a);
} else if (isset($a->questioncount)) {
$formattedname = get_string('categorynamewithcount', 'question', $a);
} else {
$formattedname = $a->name;
}
$categoriesarray[$contextstring][$cid] = $formattedname;
}
}
}
}
if ($popupform) {
$popupcats = [];
foreach ($categoriesarray as $contextstring => $optgroup) {
$group = [];
foreach ($optgroup as $key => $value) {
$key = str_replace($CFG->wwwroot, '', $key);
$group[$key] = $value;
}
$popupcats[] = [$contextstring => $group];
}
return $popupcats;
} else {
return $categoriesarray;
}
}
public static function get_categories_for_contexts($contexts, string $sortorder = 'parent, sortorder, name ASC',
bool $top = false, int $showallversions = 0): array {
global $DB;
$topwhere = $top ? '' : 'AND c.parent <> 0';
$statuscondition = "AND qv.status = '". question_version_status::QUESTION_STATUS_READY . "' ";
$sql = "SELECT c.*,
(SELECT COUNT(1)
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE q.parent = '0'
$statuscondition
AND c.id = qbe.questioncategoryid
AND ($showallversions = 1
OR (qv.version = (SELECT MAX(v.version)
FROM {question_versions} v
JOIN {question_bank_entries} be ON be.id = v.questionbankentryid
WHERE be.id = qbe.id)
)
)
) AS questioncount
FROM {question_categories} c
WHERE c.contextid IN ($contexts) $topwhere
ORDER BY $sortorder";
return $DB->get_records_sql($sql);
}
}

View file

@ -0,0 +1,427 @@
<?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/>.
namespace mod_quiz\question\bank;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/mod/quiz/accessmanager.php');
require_once($CFG->dirroot . '/mod/quiz/attemptlib.php');
/**
* Helper class for question bank and its associated data.
*
* @package mod_quiz
* @category question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class qbank_helper {
/**
* Check if the slot is a random question or not.
*
* @param int $slotid
* @return bool
*/
public static function is_random($slotid): bool {
global $DB;
$params = [
'itemid' => $slotid,
'component' => 'mod_quiz',
'questionarea' => 'slot'
];
return $DB->record_exists('question_set_references', $params);
}
/**
* Get the version options for the question.
*
* @param int $questionid
* @return array
*/
public static function get_version_options($questionid): array {
global $DB;
$sql = "SELECT qv.id AS versionid, qv.version
FROM {question_versions} qv
WHERE qv.questionbankentryid = (SELECT DISTINCT qbe.id
FROM {question_bank_entries} qbe
JOIN {question_versions} qv ON qbe.id = qv.questionbankentryid
JOIN {question} q ON qv.questionid = q.id
WHERE q.id = ?)
ORDER BY qv.version DESC";
return $DB->get_records_sql($sql, [$questionid]);
}
/**
* Sort the elements of an array according to a key.
*
* @param array $arrays
* @param string $on
* @param int $order
* @return array
*/
public static function question_array_sort($arrays, $on, $order = SORT_ASC): array {
$element = [];
foreach ($arrays as $array) {
$element[$array->$on] = $array;
}
ksort($element, $order);
return $element;
}
/**
* Get the question id from slot id.
*
* @param int $slotid
* @return mixed
*/
public static function get_question_for_redo($slotid) {
global $DB;
$params = [
'itemid' => $slotid,
'component' => 'mod_quiz',
'questionarea' => 'slot'
];
$referencerecord = $DB->get_record('question_references', $params);
if ($referencerecord->version === null) {
$questionsql = 'SELECT q.id
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
WHERE qv.version = (SELECT MAX(v.version)
FROM {question_versions} v
JOIN {question_bank_entries} be
ON be.id = v.questionbankentryid
WHERE be.id = qv.questionbankentryid)
AND qv.questionbankentryid = ?';
$questionid = $DB->get_record_sql($questionsql, [$referencerecord->questionbankentryid])->id;
} else {
$questionid = $DB->get_field('question_versions', 'questionid',
['questionbankentryid' => $referencerecord->questionbankentryid,
'version' => $referencerecord->version]);
}
return $questionid;
}
/**
* Get random question object from the slot id.
*
* @param int $slotid
* @return false|mixed|\stdClass
*/
public static function get_random_question_data_from_slot($slotid) {
global $DB;
$params = [
'itemid' => $slotid,
'component' => 'mod_quiz',
'questionarea' => 'slot'
];
return $DB->get_record('question_set_references', $params);
}
/**
* Get the question ids for specific question version.
*
* @param int $quizid
* @return array
*/
public static function get_specific_version_question_ids($quizid) {
global $DB;
$questionids = [];
$sql = 'SELECT qv.questionid
FROM {quiz_slots} qs
JOIN {question_references} qr ON qr.itemid = qs.id
JOIN {question_versions} qv ON qv.questionbankentryid = qr.questionbankentryid
AND qv.version = qr.version
WHERE qr.version IS NOT NULL
AND qs.quizid = ?
AND qr.component = ?
AND qr.questionarea = ?';
$questions = $DB->get_records_sql($sql, [$quizid, 'mod_quiz', 'slot']);
foreach ($questions as $question) {
$questionids [] = $question->questionid;
}
return $questionids;
}
/**
* Get the question ids for always latest options.
*
* @param int $quizid
* @return array
*/
public static function get_always_latest_version_question_ids($quizid) {
global $DB;
$questionids = [];
$sql = 'SELECT qr.questionbankentryid as entry
FROM {quiz_slots} qs
JOIN {question_references} qr ON qr.itemid = qs.id
WHERE qr.version IS NULL
AND qs.quizid = ?
AND qr.component = ?
AND qr.questionarea = ?';
$entryids = $DB->get_records_sql($sql, [$quizid, 'mod_quiz', 'slot']);
$questionentries = [];
foreach ($entryids as $entryid) {
$questionentries [] = $entryid->entry;
}
if (empty($questionentries)) {
return $questionids;
}
list($questionidcondition, $params) = $DB->get_in_or_equal($questionentries);
$extracondition = 'AND qv.questionbankentryid ' . $questionidcondition;
$questionsql = "SELECT q.id
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
WHERE qv.version = (SELECT MAX(v.version)
FROM {question_versions} v
JOIN {question_bank_entries} be
ON be.id = v.questionbankentryid
WHERE be.id = qv.questionbankentryid)
$extracondition";
$questions = $DB->get_records_sql($questionsql, $params);
foreach ($questions as $question) {
$questionids [] = $question->id;
}
return $questionids;
}
/**
* Get the question structure data for the given quiz or question ids.
*
* @param null $quizid
* @param array $questionids
* @param bool $attempt
* @return array
*/
public static function get_question_structure_data($quizid, $questionids = [], $attempt = false) {
global $DB;
$params = ['quizid' => $quizid];
$condition = '';
$joinon = 'AND qr.version = qv.version';
if (!empty($questionids)) {
list($condition, $param) = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid');
$condition = 'AND q.id ' . $condition;
$joinon = '';
$params = array_merge($params, $param);
}
if ($attempt) {
$selectstart = 'q.*, slot.id AS slotid, slot.slot,';
} else {
$selectstart = 'slot.slot, slot.id AS slotid, q.*,';
}
$sql = "SELECT $selectstart
q.id AS questionid,
q.name,
q.qtype,
q.length,
slot.page,
slot.maxmark,
slot.requireprevious,
qc.id as category,
qc.contextid,qv.status,
qv.id as versionid,
qv.version,
qv.questionbankentryid
FROM {quiz_slots} slot
LEFT JOIN {question_references} qr ON qr.itemid = slot.id AND qr.component = 'mod_quiz' AND qr.questionarea = 'slot'
LEFT JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid
LEFT JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id $joinon
LEFT JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
LEFT JOIN {question} q ON q.id = qv.questionid
WHERE slot.quizid = :quizid
$condition";
$questiondatas = $DB->get_records_sql($sql, $params);
foreach ($questiondatas as $questiondata) {
$questiondata->_partiallyloaded = true;
}
if (!empty($questiondatas)) {
return $questiondatas;
}
return [];
}
/**
* Get question structure.
*
* @param int $quizid
* @return array
*/
public static function get_question_structure($quizid) {
$firstslotsets = self::get_question_structure_data($quizid);
$latestquestionids = self::get_always_latest_version_question_ids($quizid);
$secondslotsets = self::get_question_structure_data($quizid, $latestquestionids);
foreach ($firstslotsets as $key => $firstslotset) {
foreach ($secondslotsets as $secondslotset) {
if ($firstslotset->slotid === $secondslotset->slotid) {
unset($firstslotsets[$key]);
}
}
}
return self::question_array_sort(array_merge($firstslotsets, $secondslotsets), 'slot');
}
/**
* Load random questions.
*
* @param int $quizid
* @param array $questiondata
* @return array
*/
public static function question_load_random_questions($quizid, $questiondata) {
global $DB, $USER;
$sql = 'SELECT slot.id AS slotid,
slot.maxmark,
slot.slot,
slot.page,
qsr.filtercondition
FROM {question_set_references} qsr
JOIN {quiz_slots} slot ON slot.id = qsr.itemid
WHERE slot.quizid = ?
AND qsr.component = ?
AND qsr.questionarea = ?';
$randomquestiondatas = $DB->get_records_sql($sql, [$quizid, 'mod_quiz', 'slot']);
$randomquestions = [];
// Questions already added.
$usedquestionids = [];
foreach ($questiondata as $question) {
if (isset($usedquestions[$question->id])) {
$usedquestionids[$question->id] += 1;
} else {
$usedquestionids[$question->id] = 1;
}
}
// Usages for this user's previous quiz attempts.
$qubaids = new \mod_quiz\question\qubaids_for_users_attempts($quizid, $USER->id);
$randomloader = new \core_question\local\bank\random_question_loader($qubaids, $usedquestionids);
foreach ($randomquestiondatas as $randomquestiondata) {
$filtercondition = json_decode($randomquestiondata->filtercondition);
$tagids = [];
if (isset($filtercondition->tags)) {
foreach ($filtercondition->tags as $tag) {
$tagstring = explode(',', $tag);
$tagids [] = $tagstring[0];
}
}
$randomquestiondata->randomfromcategory = $filtercondition->questioncategoryid;
$randomquestiondata->randomincludingsubcategories = $filtercondition->includingsubcategories;
$randomquestiondata->questionid = $randomloader->get_next_question_id($randomquestiondata->randomfromcategory,
$randomquestiondata->randomincludingsubcategories, $tagids);
$randomquestions [] = $randomquestiondata;
}
foreach ($randomquestions as $randomquestion) {
// Should not add if there is no question found from the ramdom question loader, maybe empty category.
if ($randomquestion->questionid === null) {
continue;
}
$question = new \stdClass();
$question->slotid = $randomquestion->slotid;
$question->maxmark = $randomquestion->maxmark;
$question->slot = $randomquestion->slot;
$question->page = $randomquestion->page;
$qdatas = question_preload_questions($randomquestion->questionid);
$qdatas = reset($qdatas);
foreach ($qdatas as $key => $qdata) {
$question->$key = $qdata;
}
$questiondata[$question->id] = $question;
}
return $questiondata;
}
/**
* Choose question for redo.
*
* @param int $slotid
* @param \qubaid_condition $qubaids
* @return int
*/
public static function choose_question_for_redo($slotid, $qubaids): int {
// Choose the replacement question.
if (!self::is_random($slotid)) {
$newqusetionid = self::get_question_for_redo($slotid);
} else {
$tagids = [];
$randomquestiondata = self::get_random_question_data_from_slot($slotid);
$filtercondition = json_decode($randomquestiondata->filtercondition);
if (isset($filtercondition->tags)) {
foreach ($filtercondition->tags as $tag) {
$tagstring = explode(',', $tag);
$tagids [] = $tagstring[0];
}
}
$randomloader = new \core_question\local\bank\random_question_loader($qubaids, []);
$newqusetionid = $randomloader->get_next_question_id($filtercondition->questioncategoryid,
(bool) $filtercondition->includingsubcategories, $tagids);
if ($newqusetionid === null) {
throw new \moodle_exception('notenoughrandomquestions', 'quiz');
}
}
return $newqusetionid;
}
/**
* Get the version information for a question to show in the version selection dropdown.
*
* @param int $questionid
* @param int $slotid
* @return array
*/
public static function get_question_version_info($questionid, $slotid): array {
global $DB;
$versiondata = [];
$versionsoptions = self::get_version_options($questionid);
$latestversion = reset($versionsoptions);
// Object for using the latest version.
$alwaysuselatest = new \stdClass();
$alwaysuselatest->versionid = 0;
$alwaysuselatest->version = 0;
$alwaysuselatest->versionvalue = get_string('alwayslatest', 'quiz');
array_unshift($versionsoptions, $alwaysuselatest);
$referencedata = $DB->get_record('question_references', ['itemid' => $slotid]);
if (!isset($referencedata->version) || ($referencedata->version === null)) {
$currentversion = 0;
} else {
$currentversion = $referencedata->version;
}
foreach ($versionsoptions as $versionsoption) {
$versionsoption->selected = false;
if ($versionsoption->version === $currentversion) {
$versionsoption->selected = true;
}
if (!isset($versionsoption->versionvalue)) {
if ($versionsoption->version === $latestversion->version) {
$versionsoption->versionvalue = get_string('questionversionlatest', 'quiz', $versionsoption->version);
} else {
$versionsoption->versionvalue = get_string('questionversion', 'quiz', $versionsoption->version);
}
}
$versiondata[] = $versionsoption;
}
return $versiondata;
}
}

View file

@ -48,7 +48,7 @@ class question_name_text_column extends question_name_column {
$fields = parent::get_required_fields();
$fields[] = 'q.questiontext';
$fields[] = 'q.questiontextformat';
$fields[] = 'q.idnumber';
$fields[] = 'qbe.idnumber';
return $fields;
}

View file

@ -24,8 +24,10 @@
*/
namespace mod_quiz\question;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot.'/mod/quiz/attemptlib.php');
/**
* A {@link qubaid_condition} representing all the attempts by one user at a given quiz.

View file

@ -23,6 +23,8 @@
*/
namespace mod_quiz;
use mod_quiz\question\bank\qbank_helper;
defined('MOODLE_INTERNAL') || die();
/**
@ -126,6 +128,15 @@ class structure {
return $this->questions[$this->slotsinorder[$slotnumber]->questionid];
}
/**
* Get the information about the question name in a given slot.
* @param int $slotnumber the index of the slot in question.
* @return \stdClass the data from the questions table, augmented with
*/
public function get_question_name_in_slot($slotnumber) {
return $this->questions[$this->slotsinorder[$slotnumber]->name];
}
/**
* Get the displayed question number (or 'i') for a given slot.
* @param int $slotnumber the index of the slot in question.
@ -606,30 +617,29 @@ class structure {
public function populate_structure($quiz) {
global $DB;
$slots = $DB->get_records_sql("
SELECT slot.id AS slotid, slot.slot, slot.questionid, slot.page, slot.maxmark,
slot.requireprevious, q.*, qc.contextid
FROM {quiz_slots} slot
LEFT JOIN {question} q ON q.id = slot.questionid
LEFT JOIN {question_categories} qc ON qc.id = q.category
WHERE slot.quizid = ?
ORDER BY slot.slot", array($quiz->id));
$slots = qbank_helper::get_question_structure($quiz->id);
$slots = $this->populate_missing_questions($slots);
$this->questions = array();
$this->slotsinorder = array();
$this->questions = [];
$this->slotsinorder = [];
foreach ($slots as $slotdata) {
$this->questions[$slotdata->questionid] = $slotdata;
$slot = new \stdClass();
$slot->id = $slotdata->slotid;
$slot->name = $slotdata->name;
$slot->slot = $slotdata->slot;
$slot->quizid = $quiz->id;
$slot->page = $slotdata->page;
$slot->questionid = $slotdata->questionid;
$slot->maxmark = $slotdata->maxmark;
$slot->requireprevious = $slotdata->requireprevious;
$slot->qtype = $slotdata->qtype;
$slot->length = $slotdata->length;
$slot->category = $slotdata->category;
$slot->questionbankentryid = $slotdata->questionbankentryid ?? null;
$slot->version = $slotdata->version ?? null;
$this->slotsinorder[$slot->slot] = $slot;
}
@ -646,21 +656,30 @@ class structure {
* @return \stdClass[] updated $slots array.
*/
protected function populate_missing_questions($slots) {
// Address missing question types.
global $DB;
// Address missing/random question types.
foreach ($slots as $slot) {
if ($slot->qtype === null) {
// If the questiontype is missing change the question type.
$slot->id = $slot->questionid;
$slot->category = 0;
$slot->qtype = 'missingtype';
$slot->name = get_string('missingquestion', 'quiz');
$slot->slot = $slot->slot;
$slot->maxmark = 0;
$slot->requireprevious = 0;
$slot->questiontext = ' ';
$slot->questiontextformat = FORMAT_HTML;
$slot->length = 1;
// Check if the question is random.
if ($setreference = $DB->get_record('question_set_references', ['itemid' => $slot->slotid])) {
$filtercondition = json_decode($setreference->filtercondition);
$slot->id = $slot->slotid;
$slot->category = $filtercondition->questioncategoryid;
$slot->qtype = 'random';
$slot->name = get_string('random', 'quiz');
$slot->length = 1;
} else {
// If the questiontype is missing change the question type.
$slot->id = $slot->questionid;
$slot->category = 0;
$slot->qtype = 'missingtype';
$slot->name = get_string('missingquestion', 'quiz');
$slot->maxmark = 0;
$slot->requireprevious = 0;
$slot->questiontext = ' ';
$slot->questiontextformat = FORMAT_HTML;
$slot->length = 1;
}
} else if (!\question_bank::qtype_exists($slot->qtype)) {
$slot->qtype = 'missingtype';
}
@ -936,7 +955,18 @@ class structure {
$maxslot = $DB->get_field_sql('SELECT MAX(slot) FROM {quiz_slots} WHERE quizid = ?', array($this->get_quizid()));
$trans = $DB->start_delegated_transaction();
$DB->delete_records('quiz_slot_tags', array('slotid' => $slot->id));
// Delete the reference if its a question.
$questionreference = $DB->get_record('question_references',
['component' => 'mod_quiz', 'questionarea' => 'slot', 'itemid' => $slot->id]);
if ($questionreference) {
$DB->delete_records('question_references', ['id' => $questionreference->id]);
}
// Delete the set reference if its a random question.
$questionsetreference = $DB->get_record('question_set_references',
['component' => 'mod_quiz', 'questionarea' => 'slot', 'itemid' => $slot->id]);
if ($questionsetreference) {
$DB->delete_records('question_set_references', ['id' => $questionsetreference->id]);
}
$DB->delete_records('quiz_slots', array('id' => $slot->id));
for ($i = $slot->slot + 1; $i <= $maxslot; $i++) {
$DB->set_field('quiz_slots', 'slot', $i - 1,
@ -946,12 +976,6 @@ class structure {
unset($this->slotsinorder[$i]);
}
$qtype = $DB->get_field('question', 'qtype', array('id' => $slot->questionid));
if ($qtype === 'random') {
// This function automatically checks if the question is in use, and won't delete if it is.
question_delete_question($slot->questionid);
}
quiz_update_section_firstslots($this->get_quizid(), -1, $slotnumber);
foreach ($this->sections as $key => $section) {
if ($section->firstslot > $slotnumber) {
@ -960,7 +984,7 @@ class structure {
}
$this->populate_slots_with_sections();
$this->populate_question_numbers();
unset($this->questions[$slot->questionid]);
$this->unset_question($slot->id);
$this->refresh_page_numbers_and_update_db();
@ -978,6 +1002,19 @@ class structure {
$event->trigger();
}
/**
* Unset the question object after deletion.
*
* @param int $slotid
*/
public function unset_question($slotid) {
foreach ($this->questions as $key => $question) {
if ($question->slotid === $slotid) {
unset($this->questions[$key]);
}
}
}
/**
* Change the max mark for a slot.
*
@ -1203,30 +1240,6 @@ class structure {
$event->trigger();
}
/**
* Set up this class with the slot tags for each of the slots.
*/
protected function populate_slot_tags() {
$slotids = array_column($this->slotsinorder, 'id');
$this->slottags = quiz_retrieve_tags_for_slot_ids($slotids);
}
/**
* Retrieve the list of slot tags for the given slot id.
*
* @param int $slotid The id for the slot
* @return \stdClass[] The list of slot tag records
*/
public function get_slot_tags_for_slot_id($slotid) {
if (!$this->hasloadedtags) {
// Lazy load the tags just in case they are never required.
$this->populate_slot_tags();
$this->hasloadedtags = true;
}
return isset($this->slottags[$slotid]) ? $this->slottags[$slotid] : [];
}
/**
* Whether the current user can add random questions to the quiz or not.
* It is only possible to add a random question if the user has the moodle/question:useall capability
@ -1237,7 +1250,7 @@ class structure {
public function can_add_random_questions() {
if ($this->canaddrandom === null) {
$quizcontext = $this->quizobj->get_context();
$relatedcontexts = new \question_edit_contexts($quizcontext);
$relatedcontexts = new \core_question\local\bank\question_edit_contexts($quizcontext);
$usablecontexts = $relatedcontexts->having_cap('moodle/question:useall');
$this->canaddrandom = !empty($usablecontexts);
@ -1245,4 +1258,21 @@ class structure {
return $this->canaddrandom;
}
/**
* Retrieve the list of slot tags for the given slot id.
*
* @param int $slotid The id for the slot
* @return \stdClass[] The list of slot tag records
* @deprecated since Moodle 4.0 MDL-71573
* @todo Final deprecation on Moodle 4.4 MDL-72438
*/
public function get_slot_tags_for_slot_id($slotid) {
debugging('Function get_slot_tags_for_slot_id() has been deprecated and the structure
for this method have been moved to filtercondition in question_set_reference table, please
use the new structure instead.', DEBUG_DEVELOPER);
// All the associated code for this method have been removed to get rid of accidental call or errors.
return [];
}
}

View file

@ -62,16 +62,11 @@
<FIELD NAME="quizid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Foreign key references quiz.id."/>
<FIELD NAME="page" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="The page number that this questions appears on. If the question in slot n appears on page p, then the question in slot n+1 must appear on page p or p+1. Well, except that when a quiz is being created, there may be empty pages, which would cause the page number to jump here."/>
<FIELD NAME="requireprevious" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Set to 1 when current question requires previous one to be answered first."/>
<FIELD NAME="questionid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Foreign key references question.id."/>
<FIELD NAME="questioncategoryid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="The question category that the random question will be picked from. Will be null if and only if the question is not a random question."/>
<FIELD NAME="includingsubcategories" TYPE="int" LENGTH="4" NOTNULL="false" SEQUENCE="false" COMMENT="Whether the random question can be picked from sub categories or not. Will be null if questioncategoryid is null."/>
<FIELD NAME="maxmark" TYPE="number" LENGTH="12" NOTNULL="true" DEFAULT="0" SEQUENCE="false" DECIMALS="7" COMMENT="How many marks this question contributes to quiz.sumgrades."/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
<KEY NAME="quizid" TYPE="foreign" FIELDS="quizid" REFTABLE="quiz" REFFIELDS="id"/>
<KEY NAME="questionid" TYPE="foreign" FIELDS="questionid" REFTABLE="question" REFFIELDS="id"/>
<KEY NAME="questioncategoryid" TYPE="foreign" FIELDS="questioncategoryid" REFTABLE="question_categories" REFFIELDS="id"/>
</KEYS>
<INDEXES>
<INDEX NAME="quizid-slot" UNIQUE="true" FIELDS="quizid, slot"/>
@ -186,18 +181,5 @@
<INDEX NAME="name" UNIQUE="true" FIELDS="name"/>
</INDEXES>
</TABLE>
<TABLE NAME="quiz_slot_tags" COMMENT="Stores data about the tags that a question must have so that it can be selected for a quiz slot (when having a random question by tags on that slot).">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="slotid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="The quiz slot that this tag belong to"/>
<FIELD NAME="tagid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="tagname" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false" COMMENT="The tag name is to be stored as well, so we won't lose data if the tag is removed from Moodle (A tag with the same name might be added in future)."/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
<KEY NAME="slotid" TYPE="foreign" FIELDS="slotid" REFTABLE="quiz_slots" REFFIELDS="id"/>
<KEY NAME="tagid" TYPE="foreign" FIELDS="tagid" REFTABLE="tag" REFFIELDS="id"/>
</KEYS>
</TABLE>
</TABLES>
</XMLDB>

View file

@ -191,4 +191,12 @@ $functions = array(
'capabilities' => 'mod/quiz:view',
'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE)
),
'mod_quiz_set_question_version' => [
'classname' => 'mod_quiz\external\submit_question_version',
'description' => 'Set the version of question that would be required for a given quiz.',
'type' => 'write',
'capabilities' => 'mod/quiz:view',
'ajax' => true,
],
);

View file

@ -66,10 +66,10 @@ function xmldb_quiz_upgrade($oldversion) {
if ($dbman->field_exists($table, $field)) {
$sql = "SELECT q.id, m.id as quizid " .
"FROM {quiz} q " .
"INNER JOIN {course_modules} cm ON cm.instance = q.id " .
"INNER JOIN {modules} m ON m.id = cm.module " .
"WHERE m.name = :name AND q.completionpass = :completionpass";
"FROM {quiz} q " .
"INNER JOIN {course_modules} cm ON cm.instance = q.id " .
"INNER JOIN {modules} m ON m.id = cm.module " .
"WHERE m.name = :name AND q.completionpass = :completionpass";
/** @var moodle_recordset $records */
$records = $DB->get_recordset_sql($sql, ['name' => 'quiz', 'completionpass' => 1], 0, 1000);
@ -120,5 +120,51 @@ function xmldb_quiz_upgrade($oldversion) {
upgrade_mod_savepoint(true, 2021101900, 'quiz');
}
if ($oldversion < 2022020300) {
// Define table quiz_slot_tags to be dropped.
$table = new xmldb_table('quiz_slot_tags');
// Conditionally launch drop table for quiz_slot_tags.
if ($dbman->table_exists($table)) {
$dbman->drop_table($table);
}
// Define fields to be dropped from quiz_slots.
$table = new xmldb_table('quiz_slots');
// Define key questionid (foreign) to be dropped form quiz_slots.
$key = new xmldb_key('questionid', XMLDB_KEY_FOREIGN, ['questionid'], 'question', ['id']);
// Launch drop key questionid.
$dbman->drop_key($table, $key);
// Define key questioncategoryid (foreign) to be dropped form quiz_slots.
$key = new xmldb_key('questioncategoryid', XMLDB_KEY_FOREIGN, ['questioncategoryid'], 'question_categories', ['id']);
// Launch drop key questioncategoryid.
$dbman->drop_key($table, $key);
$field = new xmldb_field('questionid');
// Conditionally launch drop field questionid.
if ($dbman->field_exists($table, $field)) {
$dbman->drop_field($table, $field);
}
$field = new xmldb_field('questioncategoryid');
// Conditionally launch drop field questioncategoryid.
if ($dbman->field_exists($table, $field)) {
$dbman->drop_field($table, $field);
}
$field = new xmldb_field('includingsubcategories');
// Conditionally launch drop field includingsubcategories.
if ($dbman->field_exists($table, $field)) {
$dbman->drop_field($table, $field);
}
// Quiz savepoint reached.
upgrade_mod_savepoint(true, 2022020300, 'quiz');
}
return true;
}

View file

@ -19,6 +19,7 @@
*
* @package mod_quiz
* @copyright 2018 Shamim Rezaie <shamim@moodle.com>
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
@ -29,13 +30,13 @@ $slotid = required_param('slotid', PARAM_INT);
$returnurl = optional_param('returnurl', '', PARAM_LOCALURL);
// Get the quiz slot.
$slot = $DB->get_record('quiz_slots', array('id' => $slotid));
if (!$slot || empty($slot->questioncategoryid)) {
print_error('invalidrandomslot', 'mod_quiz');
$slot = $DB->get_record('quiz_slots', ['id' => $slotid]);
if (!$slot) {
new moodle_exception('invalidrandomslot', 'mod_quiz');
}
if (!$quiz = $DB->get_record('quiz', array('id' => $slot->quizid))) {
print_error('invalidquizid', 'quiz');
if (!$quiz = $DB->get_record('quiz', ['id' => $slot->quizid])) {
new moodle_exception('invalidquizid', 'quiz');
}
$cm = get_coursemodule_from_instance('quiz', $slot->quizid, $quiz->course);
@ -45,46 +46,46 @@ require_login($cm->course, false, $cm);
if ($returnurl) {
$returnurl = new moodle_url($returnurl);
} else {
$returnurl = new moodle_url('/mod/quiz/edit.php', array('cmid' => $cm->id));
$returnurl = new moodle_url('/mod/quiz/edit.php', ['cmid' => $cm->id]);
}
$url = new moodle_url('/mod/quiz/editrandom.php', array('slotid' => $slotid));
$url = new moodle_url('/mod/quiz/editrandom.php', ['slotid' => $slotid]);
$PAGE->set_url($url);
$PAGE->set_pagelayout('admin');
$PAGE->add_body_class('limitedwidth');
if (!$question = $DB->get_record('question', array('id' => $slot->questionid))) {
print_error('questiondoesnotexist', 'question', $returnurl);
}
$qtypeobj = question_bank::get_qtype('random');
$setreference = $DB->get_record('question_set_references', ['itemid' => $slot->id]);
$filterconditions = json_decode($setreference->filtercondition);
// Validate the question category.
if (!$category = $DB->get_record('question_categories', array('id' => $question->category))) {
print_error('categorydoesnotexist', 'question', $returnurl);
if (!$category = $DB->get_record('question_categories', ['id' => $filterconditions->questioncategoryid])) {
new moodle_exception('categorydoesnotexist', 'question', $returnurl);
}
// Check permissions.
question_require_capability_on($question, 'edit');
$catcontext = context::instance_by_id($category->contextid);
require_capability('moodle/question:useall', $catcontext);
$thiscontext = context_module::instance($cm->id);
$contexts = new question_edit_contexts($thiscontext);
$contexts = new core_question\local\bank\question_edit_contexts($thiscontext);
// Create the question editing form.
$mform = new mod_quiz\form\randomquestion_form(new moodle_url('/mod/quiz/editrandom.php'),
array('contexts' => $contexts));
// Create the editing form.
$mform = new mod_quiz\form\randomquestion_form(new moodle_url('/mod/quiz/editrandom.php'), ['contexts' => $contexts]);
// Send the question object and a few more parameters to the form.
$toform = fullclone($question);
// Set the form data.
$toform = new stdClass();
$toform->category = "{$category->id},{$category->contextid}";
$toform->includesubcategories = $slot->includingsubcategories;
$toform->includesubcategories = $filterconditions->includingsubcategories;
$toform->fromtags = array();
$currentslottags = quiz_retrieve_slot_tags($slot->id);
foreach ($currentslottags as $slottag) {
$toform->fromtags[] = "{$slottag->tagid},{$slottag->tagname}";
if (isset($filterconditions->tags)) {
$currentslottags = $filterconditions->tags;
foreach ($currentslottags as $slottag) {
$toform->fromtags[] = $slottag;
}
}
$toform->returnurl = $returnurl;
$toform->returnurl = $returnurl;
$toform->slotid = $slot->id;
if ($cm !== null) {
$toform->cmid = $cm->id;
$toform->courseid = $cm->course;
@ -92,89 +93,46 @@ if ($cm !== null) {
$toform->courseid = $COURSE->id;
}
$toform->slotid = $slotid;
$mform->set_data($toform);
if ($mform->is_cancelled()) {
redirect($returnurl);
} else if ($fromform = $mform->get_data()) {
// If we are moving a question, check we have permission to move it from
// whence it came. Where we are moving to is validated by the form.
list($newcatid, $newcontextid) = explode(',', $fromform->category);
if (!empty($question->id) && $newcatid != $question->category) {
if ($newcatid != $category->id) {
$contextid = $newcontextid;
question_require_capability_on($question, 'move');
} else {
$contextid = $category->contextid;
}
$setreference->questionscontextid = $contextid;
$question = $qtypeobj->save_question($question, $fromform);
// Set the filter conditions.
$filtercondition = new stdClass();
$filtercondition->questioncategoryid = $newcatid;
$filtercondition->includingsubcategories = $fromform->includesubcategories;
// We need to save some data into the quiz_slots table.
$slot->questioncategoryid = $fromform->category;
$slot->includingsubcategories = $fromform->includesubcategories;
$DB->update_record('quiz_slots', $slot);
$tags = [];
foreach ($fromform->fromtags as $tagstring) {
list($tagid, $tagname) = explode(',', $tagstring);
$tags[] = (object) [
'id' => $tagid,
'name' => $tagname
];
}
$recordstokeep = [];
$recordstoinsert = [];
$searchableslottags = array_map(function($slottag) {
return ['tagid' => $slottag->tagid, 'tagname' => $slottag->tagname];
}, $currentslottags);
foreach ($tags as $tag) {
if ($key = array_search(['tagid' => $tag->id, 'tagname' => $tag->name], $searchableslottags)) {
// If found, $key would be the id field in the quiz_slot_tags table.
// Therefore, there was no need to check !== false here.
$recordstokeep[] = $key;
} else {
$recordstoinsert[] = (object)[
'slotid' => $slot->id,
'tagid' => $tag->id,
'tagname' => $tag->name
];
if (isset($fromform->fromtags)) {
$tags = [];
foreach ($fromform->fromtags as $tagstring) {
list($tagid, $tagname) = explode(',', $tagstring);
$tags[] = "{$tagid},{$tagname}";
}
if (!empty($tags)) {
$filtercondition->tags = $tags;
}
}
// Now, delete the remaining records.
if (!empty($recordstokeep)) {
list($select, $params) = $DB->get_in_or_equal($recordstokeep, SQL_PARAMS_QM, 'param', false);
array_unshift($params, $slot->id);
$DB->delete_records_select('quiz_slot_tags', "slotid = ? AND id $select", $params);
} else {
$DB->delete_records('quiz_slot_tags', array('slotid' => $slot->id));
}
$setreference->filtercondition = json_encode($filtercondition);
$DB->update_record('question_set_references', $setreference);
// And now, insert the extra records if there is any.
if (!empty($recordstoinsert)) {
$DB->insert_records('quiz_slot_tags', $recordstoinsert);
}
// Purge this question from the cache.
question_bank::notify_question_edited($question->id);
$returnurl->param('lastchanged', $question->id);
redirect($returnurl);
}
$streditingquestion = $qtypeobj->get_heading();
$PAGE->set_title($streditingquestion);
$PAGE->set_title('Random question');
$PAGE->set_heading($COURSE->fullname);
$PAGE->navbar->add($streditingquestion);
$PAGE->navbar->add('Random question');
// Display a heading, question editing form and possibly some extra content needed for
// for this question type.
// Display a heading, question editing form.
echo $OUTPUT->header();
$heading = get_string('randomediting', 'mod_quiz');
echo $OUTPUT->heading_with_help($heading, 'randomquestion', 'mod_quiz');

View file

@ -1051,6 +1051,10 @@ $string['wronguse'] = 'You can not use this page like that';
$string['xhtml'] = 'XHTML';
$string['youneedtoenrol'] = 'You need to enrol in this course before you can attempt this quiz';
$string['yourfinalgradeis'] = 'Your final grade for this quiz is {$a}.';
$string['questionversion'] = 'v{$a}';
$string['questionversionlatest'] = 'v{$a} (latest)';
$string['alwayslatest'] = 'Always latest';
$string['gobacktoquiz'] = 'Go back';
// Deprecated since Moodle 3.11.
$string['completionattemptsexhausteddesc'] = 'Complete if all available attempts are exhausted';

View file

@ -176,24 +176,12 @@ function quiz_delete_instance($id) {
quiz_delete_all_attempts($quiz);
quiz_delete_all_overrides($quiz);
// Look for random questions that may no longer be used when this quiz is gone.
$sql = "SELECT q.id
FROM {quiz_slots} slot
JOIN {question} q ON q.id = slot.questionid
WHERE slot.quizid = ? AND q.qtype = ?";
$questionids = $DB->get_fieldset_sql($sql, array($quiz->id, 'random'));
quiz_delete_references($quiz->id);
// We need to do the following deletes before we try and delete randoms, otherwise they would still be 'in use'.
$quizslots = $DB->get_fieldset_select('quiz_slots', 'id', 'quizid = ?', array($quiz->id));
$DB->delete_records_list('quiz_slot_tags', 'slotid', $quizslots);
$DB->delete_records('quiz_slots', array('quizid' => $quiz->id));
$DB->delete_records('quiz_sections', array('quizid' => $quiz->id));
foreach ($questionids as $questionid) {
question_delete_question($questionid);
}
$DB->delete_records('quiz_feedback', array('quizid' => $quiz->id));
quiz_access_manager::delete_settings($quiz);
@ -1445,11 +1433,15 @@ function quiz_get_post_actions() {
* @return bool whether any of these questions are used by any instance of this module.
*/
function quiz_questions_in_use($questionids) {
global $DB, $CFG;
require_once($CFG->libdir . '/questionlib.php');
global $DB;
list($test, $params) = $DB->get_in_or_equal($questionids);
return $DB->record_exists_select('quiz_slots',
'questionid ' . $test, $params) || question_engine::questions_in_use(
$sql = "SELECT qs.id
FROM {quiz_slots} qs
JOIN {question_references} qr ON qr.itemid = qs.id
JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid
JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id
WHERE qv.questionid $test";
return $DB->record_exists_sql($sql, $params) || question_engine::questions_in_use(
$questionids, new qubaid_join('{quiz_attempts} quiza',
'quiza.uniqueid', 'quiza.preview = 0'));
}
@ -2421,7 +2413,7 @@ function mod_quiz_output_fragment_add_random_question_form($args) {
global $CFG;
require_once($CFG->dirroot . '/mod/quiz/addrandomform.php');
$contexts = new \question_edit_contexts($args['context']);
$contexts = new \core_question\local\bank\question_edit_contexts($args['context']);
$formoptions = [
'contexts' => $contexts,
'cat' => $args['cat']
@ -2469,3 +2461,24 @@ function mod_quiz_core_calendar_get_event_action_string(string $eventtype): stri
return get_string($identifier, 'quiz', $modulename);
}
/**
* Delete question reference data.
*
* @param int $quizid The id of quiz.
*/
function quiz_delete_references($quizid): void {
global $DB;
$slots = $DB->get_records('quiz_slots', ['quizid' => $quizid]);
foreach ($slots as $slot) {
$params = [
'itemid' => $slot->id,
'component' => 'mod_quiz',
'questionarea' => 'slot'
];
// Delete any set references.
$DB->delete_records('question_set_references', $params);
// Delete any references.
$DB->delete_records('question_references', $params);
}
}

View file

@ -171,10 +171,12 @@ function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $time
// First load all the non-random questions.
$randomfound = false;
$randomtestfound = false;
$slot = 0;
$questions = array();
$maxmark = array();
$page = array();
$questiondatarandom = [];
foreach ($quizobj->get_questions() as $questiondata) {
$slot += 1;
$maxmark[$slot] = $questiondata->maxmark;
@ -183,55 +185,34 @@ function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $time
$randomfound = true;
continue;
}
// Intended for testing purposes only.
foreach ($questionids as $key => $questionid) {
if ($questionid !== (int)$questiondata->id && $slot === $key) {
$randomtestfound = true;
$questiondatarandom[$key] = $questiondata;
continue 2;
}
}
if (!$quizobj->get_quiz()->shuffleanswers) {
$questiondata->options->shuffleanswers = false;
}
$questions[$slot] = question_bank::make_question($questiondata);
}
// Then find a question to go in place of each random question.
// Then find a question throw an error as something horribly wrong might have happened.
if ($randomfound) {
$slot = 0;
$usedquestionids = array();
foreach ($questions as $question) {
if (isset($usedquestions[$question->id])) {
$usedquestionids[$question->id] += 1;
} else {
$usedquestionids[$question->id] = 1;
}
}
$randomloader = new \core_question\local\bank\random_question_loader($qubaids, $usedquestionids);
foreach ($quizobj->get_questions() as $questiondata) {
$slot += 1;
if ($questiondata->qtype != 'random') {
continue;
}
$tagids = quiz_retrieve_slot_tag_ids($questiondata->slotid);
throw new coding_exception(
'Using "random" questions directly in an attempt is deprecated. Please use question_set_references table instead.'
);
}
// Then find a question to go in place of each random question. Intended for testing purposes only.
if ($randomtestfound) {
foreach ($questiondatarandom as $slot => $questiondata) {
// 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)) {
$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->randomfromcategory,
$questiondata->randomincludingsubcategories, $tagids);
if ($questionid === null) {
throw new moodle_exception('notenoughrandomquestions', 'quiz',
$quizobj->view_url(), $questiondata);
}
$questions[$slot] = question_bank::load_question($questionid,
$quizobj->get_quiz()->shuffleanswers);
$questions[$slot] = question_bank::load_question($questionids[$slot], $quizobj->get_quiz()->shuffleanswers);
}
}
@ -1380,12 +1361,16 @@ function quiz_attempt_state_name($state) {
* @param object $question the question.
* @param string $returnurl url to return to after action is done.
* @param int $variant which question variant to preview (optional).
* @param bool $random if question is random, true.
* @return string html for a number of icons linked to action pages for a
* question - preview and edit / view icons depending on user capabilities.
*/
function quiz_question_action_icons($quiz, $cmid, $question, $returnurl, $variant = null) {
$html = quiz_question_preview_button($quiz, $question, false, $variant) . ' ' .
quiz_question_edit_button($cmid, $question, $returnurl);
$html = '';
if ($question->qtype !== 'random') {
$html = quiz_question_preview_button($quiz, $question, false, $variant);
}
$html .= quiz_question_edit_button($cmid, $question, $returnurl);
return $html;
}
@ -1464,15 +1449,15 @@ function quiz_question_preview_url($quiz, $question, $variant = null) {
* @param object $question the question
* @param bool $label if true, show the preview question label after the icon
* @param int $variant which question variant to preview (optional).
* @param bool $random if question is random, true.
* @return the HTML for a preview question icon.
*/
function quiz_question_preview_button($quiz, $question, $label = false, $variant = null) {
function quiz_question_preview_button($quiz, $question, $label = false, $variant = null, $random = null) {
global $PAGE;
if (!question_has_capability_on($question, 'use')) {
return '';
}
return $PAGE->get_renderer('mod_quiz', 'edit')->question_preview_icon($quiz, $question, $label, $variant);
return $PAGE->get_renderer('mod_quiz', 'edit')->question_preview_icon($quiz, $question, $label, $variant, null);
}
/**
@ -2240,7 +2225,7 @@ function quiz_question_tostring($question, $showicon = false, $showquestiontext
if ($showquestiontext) {
$questiontext = question_utils::to_plain_text($question->questiontext,
$question->questiontextformat, array('noclean' => true, 'para' => false));
$questiontext = shorten_text($questiontext, 200);
$questiontext = shorten_text($questiontext, 50);
if ($questiontext) {
$result .= ' ' . html_writer::span(s($questiontext), 'questiontext');
}
@ -2268,11 +2253,17 @@ function quiz_require_question_use($questionid) {
*/
function quiz_has_question_use($quiz, $slot) {
global $DB;
$question = $DB->get_record_sql("
SELECT q.*
$sql = 'SELECT q.*
FROM {quiz_slots} slot
JOIN {question} q ON q.id = slot.questionid
WHERE slot.quizid = ? AND slot.slot = ?", array($quiz->id, $slot));
JOIN {question_references} qre ON qre.itemid = slot.id
JOIN {question_bank_entries} qbe ON qbe.id = qre.questionbankentryid
JOIN {question_versions} qve ON qve.questionbankentryid = qbe.id
JOIN {question} q ON q.id = qve.questionid
WHERE slot.quizid = ? AND slot.slot = ?';
$question = $DB->get_record_sql($sql, [$quiz->id, $slot]);
if (!$question) {
return false;
}
@ -2296,6 +2287,11 @@ function quiz_has_question_use($quiz, $slot) {
function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null) {
global $DB;
if (!isset($quiz->cmid)) {
$cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course);
$quiz->cmid = $cm->id;
}
// Make sue the question is not of the "random" type.
$questiontype = $DB->get_field('question', 'qtype', array('id' => $questionid));
if ($questiontype == 'random') {
@ -2305,13 +2301,29 @@ function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null)
}
$trans = $DB->start_delegated_transaction();
$slots = $DB->get_records('quiz_slots', array('quizid' => $quiz->id),
'slot', 'questionid, slot, page, id');
if (array_key_exists($questionid, $slots)) {
$sql = "SELECT qbe.id
FROM {quiz_slots} slot
JOIN {question_references} qr ON qr.itemid = slot.id
JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid
WHERE slot.quizid = ?";
$questionslots = $DB->get_records_sql($sql, [$quiz->id]);
$currententry = get_question_bank_entry($questionid);
if (array_key_exists($currententry->id, $questionslots)) {
$trans->allow_commit();
return false;
}
$sql = "SELECT slot.slot, slot.page, slot.id
FROM {quiz_slots} slot
WHERE slot.quizid = ?
ORDER BY slot.slot";
$slots = $DB->get_records_sql($sql, [$quiz->id]);
$maxpage = 1;
$numonlastpage = 0;
foreach ($slots as $slot) {
@ -2323,10 +2335,9 @@ function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null)
}
}
// Add the new question instance.
// Add the new instance.
$slot = new stdClass();
$slot->quizid = $quiz->id;
$slot->questionid = $questionid;
if ($maxmark !== null) {
$slot->maxmark = $maxmark;
@ -2364,14 +2375,57 @@ function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null)
}
}
$newslotid = $DB->insert_record('quiz_slots', $slot);
$slotid = $DB->insert_record('quiz_slots', $slot);
// Update or insert record in question_reference table.
$sql = "SELECT DISTINCT qr.id, qr.itemid
FROM {question} q
JOIN {question_versions} qv ON q.id = qv.questionid
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_references} qr ON qbe.id = qr.questionbankentryid AND qr.version = qv.version
JOIN {quiz_slots} qs ON qs.id = qr.itemid
WHERE q.id = ?
AND qs.id =?";
$qreferenceitem = $DB->get_record_sql($sql, [$questionid, $slotid]);
if (!$qreferenceitem) {
// Create a new reference record for questions created already.
$questionreferences = new \StdClass();
$questionreferences->usingcontextid = context_module::instance($quiz->cmid)->id;
$questionreferences->component = 'mod_quiz';
$questionreferences->questionarea = 'slot';
$questionreferences->itemid = $slotid;
$questionreferences->questionbankentryid = get_question_bank_entry($questionid)->id;
$version = get_question_version($questionid);
$questionreferences->version = $version[array_key_first($version)]->version;
$DB->insert_record('question_references', $questionreferences);
} else if ($qreferenceitem->itemid === 0 || $qreferenceitem->itemid === null) {
$questionreferences = new \StdClass();
$questionreferences->id = $qreferenceitem->id;
$questionreferences->itemid = $slotid;
$DB->update_record('question_references', $questionreferences);
} else {
// If the reference record exits for another quiz.
$questionreferences = new \StdClass();
$questionreferences->usingcontextid = context_module::instance($quiz->cmid)->id;
$questionreferences->component = 'mod_quiz';
$questionreferences->questionarea = 'slot';
$questionreferences->itemid = $slotid;
$questionreferences->questionbankentryid = get_question_bank_entry($questionid)->id;
$version = get_question_version($questionid);
$questionreferences->version = $version[array_key_first($version)]->version;
$DB->insert_record('question_references', $questionreferences);
}
$trans->allow_commit();
// Log slot created event.
$cm = get_coursemodule_from_instance('quiz', $quiz->id);
$event = \mod_quiz\event\slot_created::create([
'context' => context_module::instance($cm->id),
'objectid' => $newslotid,
'objectid' => $slotid,
'other' => [
'quizid' => $quiz->id,
'slotnumber' => $slot->slot,
@ -2415,61 +2469,45 @@ function quiz_add_random_questions($quiz, $addonpage, $categoryid, $number,
$includesubcategories, $tagids = []) {
global $DB;
$category = $DB->get_record('question_categories', array('id' => $categoryid));
$category = $DB->get_record('question_categories', ['id' => $categoryid]);
if (!$category) {
print_error('invalidcategoryid', 'error');
new moodle_exception('invalidcategoryid');
}
$catcontext = context::instance_by_id($category->contextid);
require_capability('moodle/question:useall', $catcontext);
// Tags for filter condition.
$tags = \core_tag_tag::get_bulk($tagids, 'id, name');
$tagstrings = [];
foreach ($tags as $tag) {
$tagstrings[] = "{$tag->id},{$tag->name}";
}
// Find existing random questions in this category that are
// not used by any quiz.
$existingquestions = $DB->get_records_sql(
"SELECT q.id, q.qtype FROM {question} q
WHERE qtype = 'random'
AND category = ?
AND " . $DB->sql_compare_text('questiontext') . " = ?
AND NOT EXISTS (
SELECT *
FROM {quiz_slots}
WHERE questionid = q.id)
ORDER BY id", array($category->id, $includesubcategories ? '1' : '0'));
// Create the selected number of random questions.
for ($i = 0; $i < $number; $i++) {
// Take as many of orphaned "random" questions as needed.
if (!$question = array_shift($existingquestions)) {
$form = new stdClass();
$form->category = $category->id . ',' . $category->contextid;
$form->includesubcategories = $includesubcategories;
$form->fromtags = $tagstrings;
$form->defaultmark = 1;
$form->hidden = 1;
$form->stamp = make_unique_id_code(); // Set the unique code (not to be changed).
$question = new stdClass();
$question->qtype = 'random';
$question = question_bank::get_qtype('random')->save_question($question, $form);
if (!isset($question->id)) {
print_error('cannotinsertrandomquestion', 'quiz');
}
// Set the filter conditions.
$filtercondition = new stdClass();
$filtercondition->questioncategoryid = $categoryid;
$filtercondition->includingsubcategories = $includesubcategories ? 1 : 0;
if (!empty($tagstrings)) {
$filtercondition->tags = $tagstrings;
}
if (!isset($quiz->cmid)) {
$cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course);
$quiz->cmid = $cm->id;
}
// Slot data.
$randomslotdata = new stdClass();
$randomslotdata->quizid = $quiz->id;
$randomslotdata->questionid = $question->id;
$randomslotdata->questioncategoryid = $categoryid;
$randomslotdata->includingsubcategories = $includesubcategories ? 1 : 0;
$randomslotdata->usingcontextid = context_module::instance($quiz->cmid)->id;
$randomslotdata->questionscontextid = $category->contextid;
$randomslotdata->maxmark = 1;
$randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata);
$randomslot->set_quiz($quiz);
$randomslot->set_tags($tags);
$randomslot->set_filter_condition($filtercondition);
$randomslot->insert($addonpage);
}
}
@ -2698,10 +2736,13 @@ function quiz_is_overriden_calendar_event(\calendar_event $event) {
*
* @param int[] $slotids The list of id for the quiz slots.
* @return array[] List of quiz_slot_tags records indexed by slot id.
* @deprecated since Moodle 4.0
* @todo Final deprecation on Moodle 4.4 MDL-72438
*/
function quiz_retrieve_tags_for_slot_ids($slotids) {
debugging('Method quiz_retrieve_tags_for_slot_ids() is deprecated, ' .
'see filtercondition->tags from the question_set_reference table.', DEBUG_DEVELOPER);
global $DB;
if (empty($slotids)) {
return [];
}
@ -2757,11 +2798,17 @@ function quiz_retrieve_tags_for_slot_ids($slotids) {
* A quiz slot have some tags if and only if it is representing a random question by tags.
*
* @param int $slotid The id of the quiz slot.
* @return stdClass[] List of quiz_slot_tags records.
* @return array List of tags.
*/
function quiz_retrieve_slot_tags($slotid) {
$slottags = quiz_retrieve_tags_for_slot_ids([$slotid]);
return $slottags[$slotid];
$referencedata = \mod_quiz\question\bank\qbank_helper::get_random_question_data_from_slot($slotid);
if (isset($referencedata->filtercondition)) {
$filtercondition = json_decode($referencedata->filtercondition);
if (isset($filtercondition->tags)) {
return $filtercondition->tags;
}
}
return [];
}
/**
@ -2772,10 +2819,13 @@ function quiz_retrieve_slot_tags($slotid) {
* @return int[]
*/
function quiz_retrieve_slot_tag_ids($slotid) {
$tagids = [];
$tags = quiz_retrieve_slot_tags($slotid);
// Only work with tags that exist.
return array_filter(array_column($tags, 'tagid'));
foreach ($tags as $tag) {
$tagstring = explode(',', $tag);
$tagids [] = $tagstring[0];
}
return $tagids;
}
/**

View file

@ -256,7 +256,7 @@ abstract class quiz_attempts_report_table extends table_sql {
$feedbackimg = '';
$state = $this->slot_state($attempt, $slot);
if ($state->is_finished() && $state != question_state::$needsgrading) {
if ($state && $state->is_finished() && $state != question_state::$needsgrading) {
$feedbackimg = $this->icon_for_fraction($this->slot_fraction($attempt, $slot));
}

View file

@ -63,7 +63,6 @@ class quiz_overview_report extends quiz_attempts_report {
// Load the required questions.
$questions = quiz_report_get_significant_questions($quiz);
// Prepare for downloading, if applicable.
$courseshortname = format_string($course->shortname, true,
array('context' => context_course::instance($course->id)));

View file

@ -73,37 +73,38 @@ Feature: Regrading quiz attempts using the Grades report
And I should see "Quiz for testing regrading"
And I should see "Overall number of students achieving grade ranges"
Scenario: Dry-run a full regrade, then regrade the attempts that will need it.
Given I am on the "Quiz for testing regrading" "quiz activity" page logged in as teacher
When I navigate to "Edit quiz" in current page administration
And I follow "Edit question SA"
And I set the field "id_fraction_1" to "50%"
And I press "id_submitbutton"
And I follow "Attempts: 2"
And I press "Dry run a full regrade"
# @todo MDL-72890 uncomment this scenario and add some more for re-grading.
# Scenario: Dry-run a full regrade, then regrade the attempts that will need it.
# Given I am on the "Quiz for testing regrading" "quiz activity" page logged in as teacher
# When I navigate to "Edit quiz" in current page administration
# And I follow "Edit question SA"
# And I set the field "id_fraction_1" to "50%"
# And I press "id_submitbutton"
# And I follow "Attempts: 2"
# And I press "Dry run a full regrade"
# Note, the order is not defined, so we can only check part of the message.
Then I should see "Quiz for testing regrading"
And I should see "Successfully regraded (2/2)"
And I should see "Regrade completed successfully"
And I press "Continue"
# Then I should see "Quiz for testing regrading"
# And I should see "Successfully regraded (2/2)"
# And I should see "Regrade completed successfully"
# And I press "Continue"
And "Student One" row "Regrade" column of "attempts" table should not contain "Needed"
And "Student TwoReview attempt" row "Regrade" column of "attempts" table should contain "Needed"
# And "Student One" row "Regrade" column of "attempts" table should not contain "Needed"
# And "Student TwoReview attempt" row "Regrade" column of "attempts" table should contain "Needed"
# In the following, the first number is strike-through, and the second is not, but Behat can't see that.
# At this point, it is showing what would change.
And "Student TwoReview attempt" row "Q. 2/50.00Sort by Q. 2/50.00 Ascending" column of "attempts" table should contain "40.00/25.00"
And "Student TwoReview attempt" row "Grade/100.00Sort by Grade/100.00 Ascending" column of "attempts" table should contain "90.00/75.00"
And I press "Regrade attempts marked as needing regrading (1)"
And I should see "Quiz for testing regrading"
And I should see "Successfully regraded (1/1)"
And I should see "Regrade completed successfully"
And I press "Continue"
# And "Student TwoReview attempt" row "Q. 2/50.00Sort by Q. 2/50.00 Ascending" column of "attempts" table should contain "40.00/25.00"
# And "Student TwoReview attempt" row "Grade/100.00Sort by Grade/100.00 Ascending" column of "attempts" table should contain "90.00/75.00"
# And I press "Regrade attempts marked as needing regrading (1)"
# And I should see "Quiz for testing regrading"
# And I should see "Successfully regraded (1/1)"
# And I should see "Regrade completed successfully"
# And I press "Continue"
# These next tests just serve to check we got back to the report.
And I should see "Quiz for testing regrading"
And I should see "Overall number of students achieving grade ranges"
# And I should see "Quiz for testing regrading"
# And I should see "Overall number of students achieving grade ranges"
# Now, both old-score strike-through and new score plain, are still shown, but now it indicates what did change.
And "Student TwoReview attempt" row "Q. 2/50.00Sort by Q. 2/50.00 Ascending" column of "attempts" table should contain "40.00/25.00"
And "Student TwoReview attempt" row "Grade/100.00Sort by Grade/100.00 Ascending" column of "attempts" table should contain "90.00/75.00"
And "Regrade attempts marked as needing regrading" "button" should not exist
# And "Student TwoReview attempt" row "Q. 2/50.00Sort by Q. 2/50.00 Ascending" column of "attempts" table should contain "40.00/25.00"
# And "Student TwoReview attempt" row "Grade/100.00Sort by Grade/100.00 Ascending" column of "attempts" table should contain "90.00/75.00"
# And "Regrade attempts marked as needing regrading" "button" should not exist

View file

@ -27,6 +27,7 @@ defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/mod/quiz/lib.php');
require_once($CFG->libdir . '/filelib.php');
require_once($CFG->dirroot . '/mod/quiz/accessmanager.php');
/**
* Takes an array of objects and constructs a multidimensional array keyed by
@ -94,29 +95,31 @@ function quiz_has_questions($quizid) {
*/
function quiz_report_get_significant_questions($quiz) {
global $DB;
$qsbyslot = $DB->get_records_sql("
SELECT slot.slot,
q.id,
q.qtype,
q.length,
slot.maxmark
FROM {question} q
JOIN {quiz_slots} slot ON slot.questionid = q.id
WHERE slot.quizid = ?
AND q.length > 0
ORDER BY slot.slot", array($quiz->id));
$qsbyslot = [];
$quizobj = \quiz::create($quiz->id);
$structure = \mod_quiz\structure::create_for_quiz($quizobj);
$slots = $structure->get_slots();
foreach ($slots as $slot) {
$slotreport = new \stdClass();
$slotreport->slot = $slot->slot;
$slotreport->id = $slot->questionid;
$slotreport->qtype = $slot->qtype;
$slotreport->length = $slot->length;
$slotreport->maxmark = $slot->maxmark;
$slotreport->type = $slot->qtype;
if ($slot->qtype === 'random') {
$categoryobject = $DB->get_record('question_categories', ['id' => $slot->category]);
$slotreport->categoryobject = $categoryobject;
$slotreport->category = $slot->category;
}
$qsbyslot[$slotreport->slot] = $slotreport;
}
$qsbyslot = \mod_quiz\question\bank\qbank_helper::question_array_sort($qsbyslot, 'slot');
$number = 1;
foreach ($qsbyslot as $question) {
$question->number = $number;
$number += $question->length;
$question->type = $question->qtype;
$number ++;
}
return $qsbyslot;
}

View file

@ -830,19 +830,33 @@ class quiz_statistics_report extends quiz_default_report {
public function load_and_initialise_questions_for_calculations($quiz) {
// Load the questions.
$questions = quiz_report_get_significant_questions($quiz);
$questionids = array();
foreach ($questions as $question) {
$questionids = [];
$randomquestions = [];
foreach ($questions as $qs => $question) {
if ($question->qtype === 'random') {
$question->id = 0;
$question->name = get_string('random', 'quiz');
$question->questiontext = get_string('random', 'quiz');
$question->parenttype = 'random';
$randomquestions [] = $question;
unset($questions[$qs]);
continue;
}
$questionids[] = $question->id;
}
$fullquestions = question_load_questions($questionids);
foreach ($questions as $qno => $question) {
$q = $fullquestions[$question->id];
$q->maxmark = $question->maxmark;
$q->slot = $qno;
$q->slot = $question->slot;
$q->number = $question->number;
$questions[$qno] = $q;
$q->parenttype = null;
$questiondata[$question->slot] = $q;
}
return $questions;
foreach ($randomquestions as $randomquestion) {
$questiondata[$randomquestion->slot] = $randomquestion;
}
return \mod_quiz\question\bank\qbank_helper::question_array_sort($questiondata, 'slot');
}
/**

View file

@ -188,7 +188,8 @@ class quiz_statistics_table extends flexible_table {
if ($this->is_calculated_question_summary($questionstat)) {
return '';
} else {
return print_question_icon($questionstat->question, true);
$questionobject = $questionstat->question;
return print_question_icon($questionobject);
}
}
@ -201,8 +202,12 @@ class quiz_statistics_table extends flexible_table {
if ($this->is_calculated_question_summary($questionstat)) {
return '';
} else {
$random = null;
if ($questionstat->question->qtype === 'random') {
$random = true;
}
return quiz_question_action_icons($this->quiz, $this->cmid,
$questionstat->question, $this->baseurl, $questionstat->variant);
$questionstat->question, $this->baseurl, $questionstat->variant, $random);
}
}

View file

@ -59,8 +59,10 @@ Feature: Basic use of the Statistics report
| 1 | False |
| 2 | False |
| 3 | False |
And I am on the "Quiz 1" "quiz activity" page logged in as teacher1
And I navigate to "Results > Statistics" in current page administration
And I press "Show report"
Then I should not see "No attempts have been made at this quiz, or all attempts have questions that need manual grading."
Then I should not see "No questions have been attempted yet"
And "Show chart data" "link" should exist
# Question A statistics breakdown.

View file

@ -358,6 +358,11 @@ body.path-mod-quiz table tbody tr.gradedattempt > td {
margin-top: 1.5em;
}
#page-mod-quiz-edit .section .activity .actions .version-selection {
width: 8em;
padding: 0;
}
@media print {
.quiz-secure-window * {
display: none;
@ -789,6 +794,7 @@ table.quizreviewsummary td.cell {
display: flex;
flex: 1 1 auto;
min-height: 1.7em;
padding-right: 2px;
}
#page-mod-quiz-edit ul.slots li.section li.activity .mod-indent-outer {

View file

@ -0,0 +1,52 @@
{{!
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/>.
}}
{{!
@template mod_quiz/question_slot
This template renders the question slot content.
Example context (json):
{
"checkbox" : "<input id='selectquestion-1' name='selectquestion[]' type='checkbox' class='select-multiple-checkbox'>",
"questionnumber" : "<span class='slotnumber'><span class='accesshide'>Question</span> 1</span>",
"questionname" : "This is a test question",
"questionicons" : "<i class='icon fa fa-search-plus fa-fw' title='Preview question' aria-label='Preview question'></i>",
"canbeedited" : false,
"questiondependencyicon" : "<span class='question_dependency_wrapper question_dependency_cannot_depend'></span>"
}
}}
<div class="mod-indent-outer" id="mod-indent-outer-slot-{{slotid}}">
{{{checkbox}}}
{{{questionnumber}}}
<div class="mod-indent"></div>
<div class="activityinstance">
{{{questionname}}}
</div>
<span class="actions">
{{#versionselection}}
<label for="version-{{slotid}}"> </label>
<select id="version-{{slotid}}" name="version" class="form-control mr-2 h-auto version-selection"
data-action="mod_quiz-select_slot" data-slot-id="{{slotid}}">
{{#versionoption}}
<option value="{{version}}" {{#selected}}selected="selected"{{/selected}}>{{versionvalue}}</option>
{{/versionoption}}
</select>
{{/versionselection}}
{{{questionicons}}}
</span>
{{#canbeedited}}
{{{questiondependencyicon}}}
{{/canbeedited}}
</div>

View file

@ -149,7 +149,6 @@ class mod_quiz_attempt_walkthrough_from_csv_testcase extends advanced_testcase {
*/
protected function create_quiz_simulate_attempts_and_check_results($quizsettings, $csvdata) {
$this->resetAfterTest(true);
question_bank::get_qtype('random')->clear_caches_before_testing();
$this->create_quiz($quizsettings, $csvdata['questions']);

View file

@ -106,15 +106,13 @@ Feature: Allow students to redo questions in a practice quiz, without starting a
@javascript
Scenario: Redoing questions should work with random questions as well
Given the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | random | Random (Test questions) | 0 |
And the following "activities" exist:
Given the following "activities" exist:
| activity | name | intro | course | idnumber | preferredbehaviour | canredoquestions |
| quiz | Quiz 2 | Quiz 2 description | C1 | quiz2 | immediatefeedback | 1 |
And quiz "Quiz 2" contains the following questions:
| question | page |
| Random (Test questions) | 1 |
And I am on the "Quiz 2" "mod_quiz > Edit" page logged in as "admin"
And I open the "last" add to quiz menu
And I follow "a random question"
And I press "Add random question"
And user "student" has started an attempt at quiz "Quiz 2" randomised as follows:
| slot | actualquestion |
| 1 | TF1 |
@ -124,7 +122,7 @@ Feature: Allow students to redo questions in a practice quiz, without starting a
And I click on "False" "radio"
And I click on "Check" "button"
And I press "Try another question like this one"
Then I should see "Second question"
And I should see "Second question"
And "Check" "button" should exist
Scenario: Teachers reviewing can see author of action in review attempt

View file

@ -212,7 +212,12 @@ class behat_mod_quiz extends behat_question_base {
}
// Question id, category and type.
$question = $DB->get_record('question', array('name' => $questiondata['question']), 'id, category, qtype', MUST_EXIST);
$sql = 'SELECT q.id AS id, qbe.questioncategoryid AS category, q.qtype AS qtype
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE q.name = :name';
$question = $DB->get_record_sql($sql, ['name' => $questiondata['question']], MUST_EXIST);
// Page number.
$page = clean_param($questiondata['page'], PARAM_INT);
@ -937,4 +942,16 @@ class behat_mod_quiz extends behat_question_base {
$this->set_user();
}
/**
* Return a list of the exact named selectors for the component.
*
* @return behat_component_named_selector[]
*/
public static function get_exact_named_selectors(): array {
return [
new behat_component_named_selector('Edit slot',
["//li[contains(@class,'qtype')]//span[@class='slotnumber' and contains(., %locator%)]/.."])
];
}
}

View file

@ -44,7 +44,10 @@ Feature: Adding random questions to a quiz based on category and tags
And I follow "a random question"
And I set the field "Tags" to "foo"
And I press "Add random question"
Then I should see "Random (Questions Category 1, tags: foo)" on quiz page "1"
And I should see "Random question"
And I click on "(See questions)" "link"
Then I should see "Questions Category 1"
And I should see "foo"
Scenario: Teacher without moodle/question:useall should not see the add a random question menu item
Given the following "permission overrides" exist:
@ -64,4 +67,6 @@ Feature: Adding random questions to a quiz based on category and tags
| Name | New Random category |
| Parent category | Top for Quiz 1 |
And I press "Create category and add random question"
Then I should see "Random (New Random category)" on quiz page "1"
And I should see "Random question"
And I click on "(See questions)" "link"
Then I should see "Top for Quiz 1"

View file

@ -45,11 +45,11 @@ Feature: Editing random questions already in a quiz based on category and tags
And I set the field "Tags" to "hard"
And I press "Add random question"
And I follow "Add page break"
When I click on "Configure question" "link" in the "Random (Questions Category 1, tags: easy)" "list_item"
When I click on "Configure question" "link" in the "Random question" "list_item"
And I click on "easy" "autocomplete_selection"
And I set the field "Tags" to "essay"
And I press "Save changes"
Then I should see "Random (Questions Category 1, tags: essay)" on quiz page "1"
And I should see "Random (Questions Category 1, tags: hard)" on quiz page "2"
And I click on "Configure question" "link" in the "Questions Category 1, tags: hard" "list_item"
Then I should see "Random question" on quiz page "1"
And I should see "Random question" on quiz page "2"
And I click on "Configure question" "link" in the "2" "mod_quiz > Edit slot"
And "hard" "autocomplete_selection" should be visible

View file

@ -0,0 +1,78 @@
@mod @mod_quiz
Feature: Quiz question versioning
In order to manage question versions
As a teacher
I need to be able to choose which versions can be displayed in a quiz
Background:
Given the following "courses" exist:
| fullname | shortname | category | groupmode |
| Course 1 | C1 | 0 | 1 |
And the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "activities" exist:
| activity | name | course | idnumber |
| quiz | Quiz 1 | C1 | quiz1 |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext | answer 1 |
| Test questions | truefalse | First question | Answer the first question | True |
And quiz "Quiz 1" contains the following questions:
| question | page |
| First question | 1 |
And I log in as "teacher1"
And I am on "Course 1" course homepage
@javascript
Scenario: Approriate question version should be displayed when not edited
When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1"
And I should see "First question"
And I should see "Answer the first question"
And I should see "v1 (latest)"
# We check that the corresponding version is the appropriate one in preview
And I click on "Preview question" "link"
And I switch to "questionpreview" window
And I should see "Version 1 (latest)"
And I should see "Answer the first question"
And I press "Display options"
And I set the following fields to these values:
| id_feedback | Not shown |
| id_generalfeedback | Not shown |
| id_rightanswer | Shown |
And I press "id_saveupdate"
And I click on "finish" "button"
And I should see "The correct answer is 'True'."
@javascript
Scenario: Approriate question version should be displayed when edited
When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1"
And I click on "Edit question First question" "link"
# We edit the question with new informations to generate a second version
And I set the following fields to these values:
| id_name | Second question |
| id_questiontext | This is the second question text |
| id_correctanswer | False |
And I press "id_submitbutton"
And I set the field "version" to "v2"
And I should see "Second question"
And I should see "This is the second question text"
And I click on "Preview question" "link"
And I switch to "questionpreview" window
# We check that the corresponding version is the appropriate one in preview
# We also check that the new informations are properly displayed
And I should see "Version 2 (latest)"
And I should see "This is the second question text"
And I press "Display options"
And I set the following fields to these values:
| id_feedback | Not shown |
| id_generalfeedback | Not shown |
| id_rightanswer | Shown |
And I press "id_saveupdate"
And I click on "finish" "button"
Then I should see "The correct answer is 'False'."

View file

@ -1941,11 +1941,11 @@ class external_test extends externallib_advanced_testcase {
$question = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
quiz_add_quiz_question($question->id, $quiz);
// Add new question types in the category (for the random one).
$question = $questiongenerator->create_question('truefalse', null, array('category' => $cat->id));
$question = $questiongenerator->create_question('essay', null, array('category' => $cat->id));
quiz_add_quiz_question($question->id, $quiz);
quiz_add_random_questions($quiz, 0, $cat->id, 1, false);
$question = $questiongenerator->create_question('essay', null, array('category' => $cat->id));
quiz_add_quiz_question($question->id, $quiz);
$this->setUser($this->student);
@ -1953,7 +1953,7 @@ class external_test extends externallib_advanced_testcase {
$result = \external_api::clean_returnvalue(mod_quiz_external::get_quiz_required_qtypes_returns(), $result);
$expected = array(
'questiontypes' => ['essay', 'numerical', 'random', 'shortanswer', 'truefalse'],
'questiontypes' => ['essay', 'numerical', 'shortanswer', 'truefalse'],
'warnings' => []
);

View file

@ -117,8 +117,10 @@ class lib_test extends \advanced_testcase {
quiz_delete_instance($quiz->id);
// Check that the random question was deleted.
$count = $DB->count_records('question', array('id' => $randomq->id));
$this->assertEquals(0, $count);
if ($randomq) {
$count = $DB->count_records('question', array('id' => $randomq->id));
$this->assertEquals(0, $count);
}
// Check that the standard question was not deleted.
$count = $DB->count_records('question', array('id' => $standardq->id));
$this->assertEquals(1, $count);

View file

@ -57,30 +57,37 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase {
$form->includesubcategories = true;
$form->fromtags = [];
$form->defaultmark = 1;
$form->hidden = 1;
$form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN;
$form->stamp = make_unique_id_code();
$question = new stdClass();
$question->qtype = 'random';
$question = question_bank::get_qtype('random')->save_question($question, $form);
// Set the filter conditions.
$filtercondition = new stdClass();
$filtercondition->questioncategoryid = $category->id;
$filtercondition->includingsubcategories = 1;
// Slot data.
$randomslotdata = new stdClass();
$randomslotdata->quizid = $quiz->id;
$randomslotdata->questionid = $question->id;
$randomslotdata->questioncategoryid = $category->id;
$randomslotdata->includingsubcategories = 1;
$randomslotdata->maxmark = 1;
$randomslotdata->usingcontextid = context_module::instance($quiz->cmid)->id;
$randomslotdata->questionscontextid = $category->contextid;
// Insert the random question to the quiz.
$randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata);
$randomslot->set_filter_condition($filtercondition);
$rc = new ReflectionClass('\mod_quiz\local\structure\slot_random');
$rcp = $rc->getProperty('filtercondition');
$rcp->setAccessible(true);
$record = json_decode($rcp->getValue($randomslot));
$this->assertEquals($quiz->id, $randomslot->get_quiz()->id);
$this->assertEquals($category->id, $record->questioncategoryid);
$this->assertEquals(1, $record->includingsubcategories);
$rcp = $rc->getProperty('record');
$rcp->setAccessible(true);
$record = $rcp->getValue($randomslot);
$this->assertEquals($quiz->id, $record->quizid);
$this->assertEquals($question->id, $record->questionid);
$this->assertEquals($category->id, $record->questioncategoryid);
$this->assertEquals(1, $record->includingsubcategories);
$this->assertEquals(1, $record->maxmark);
}
@ -100,17 +107,20 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase {
quiz_add_random_questions($quiz, 0, $category->id, 1, false);
// Get the random question's id. It is at the first slot.
$questionid = $DB->get_field('quiz_slots', 'questionid', array('quizid' => $quiz->id, 'slot' => 1));
// Set the filter conditions.
$filtercondition = new stdClass();
$filtercondition->questioncategoryid = $category->id;
$filtercondition->includingsubcategories = 1;
// Slot data.
$randomslotdata = new stdClass();
$randomslotdata->quizid = $quiz->id;
$randomslotdata->questionid = $questionid;
$randomslotdata->questioncategoryid = $category->id;
$randomslotdata->includingsubcategories = 1;
$randomslotdata->maxmark = 1;
$randomslotdata->usingcontextid = context_module::instance($quiz->cmid)->id;
$randomslotdata->questionscontextid = $category->contextid;
$randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata);
$randomslot->set_filter_condition($filtercondition);
// The create_instance had injected an additional cmid propery to the quiz. Let's remove that.
unset($quiz->cmid);
@ -134,17 +144,20 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase {
quiz_add_random_questions($quiz, 0, $category->id, 1, false);
// Get the random question's id. It is at the first slot.
$questionid = $DB->get_field('quiz_slots', 'questionid', array('quizid' => $quiz->id, 'slot' => 1));
// Set the filter conditions.
$filtercondition = new stdClass();
$filtercondition->questioncategoryid = $category->id;
$filtercondition->includingsubcategories = 1;
// Slot data.
$randomslotdata = new stdClass();
$randomslotdata->quizid = $quiz->id;
$randomslotdata->questionid = $questionid;
$randomslotdata->questioncategoryid = $category->id;
$randomslotdata->includingsubcategories = 1;
$randomslotdata->maxmark = 1;
$randomslotdata->usingcontextid = context_module::instance($quiz->cmid)->id;
$randomslotdata->questionscontextid = $category->contextid;
$randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata);
$randomslot->set_filter_condition($filtercondition);
// The create_instance had injected an additional cmid propery to the quiz. Let's remove that.
unset($quiz->cmid);
@ -172,15 +185,12 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase {
quiz_add_random_questions($quiz, 0, $category->id, 1, false);
// Get the random question's id. It is at the first slot.
$questionid = $DB->get_field('quiz_slots', 'questionid', array('quizid' => $quiz->id, 'slot' => 1));
// Slot data.
$randomslotdata = new stdClass();
$randomslotdata->quizid = $quiz->id;
$randomslotdata->questionid = $questionid;
$randomslotdata->questioncategoryid = $category->id;
$randomslotdata->includingsubcategories = 1;
$randomslotdata->maxmark = 1;
$randomslotdata->usingcontextid = context_module::instance($quiz->cmid)->id;
$randomslotdata->questionscontextid = $category->contextid;
$randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata);
@ -203,17 +213,19 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase {
$this->setAdminUser();
list($randomslot, $tags) = $this->setup_for_test_tags(['foo', 'bar']);
$filtercondition = new stdClass();
$randomslot->set_tags([$tags['foo'], $tags['bar']]);
$randomslot->set_filter_condition($filtercondition);
$rc = new ReflectionClass('\mod_quiz\local\structure\slot_random');
$rcp = $rc->getProperty('tags');
$rcp = $rc->getProperty('filtercondition');
$rcp->setAccessible(true);
$tagspropery = $rcp->getValue($randomslot);
$this->assertEquals([
$tags['foo']->id => $tags['foo'],
$tags['bar']->id => $tags['bar'],
], $tagspropery);
], (array)json_decode($tagspropery)->tags);
}
public function test_set_tags_twice() {
@ -223,18 +235,20 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase {
list($randomslot, $tags) = $this->setup_for_test_tags(['foo', 'bar', 'baz']);
// Set tags for the first time.
$filtercondition = new stdClass();
$randomslot->set_tags([$tags['foo'], $tags['bar']]);
// Now set the tags again.
$randomslot->set_tags([$tags['baz']]);
$randomslot->set_filter_condition($filtercondition);
$rc = new ReflectionClass('\mod_quiz\local\structure\slot_random');
$rcp = $rc->getProperty('tags');
$rcp = $rc->getProperty('filtercondition');
$rcp->setAccessible(true);
$tagspropery = $rcp->getValue($randomslot);
$this->assertEquals([
$tags['baz']->id => $tags['baz'],
], $tagspropery);
], (array)json_decode($tagspropery)->tags);
}
public function test_set_tags_duplicates() {
@ -242,18 +256,19 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase {
$this->setAdminUser();
list($randomslot, $tags) = $this->setup_for_test_tags(['foo', 'bar', 'baz']);
$filtercondition = new stdClass();
$randomslot->set_tags([$tags['foo'], $tags['bar'], $tags['foo']]);
$randomslot->set_filter_condition($filtercondition);
$rc = new ReflectionClass('\mod_quiz\local\structure\slot_random');
$rcp = $rc->getProperty('tags');
$rcp = $rc->getProperty('filtercondition');
$rcp->setAccessible(true);
$tagspropery = $rcp->getValue($randomslot);
$this->assertEquals([
$tags['foo']->id => $tags['foo'],
$tags['bar']->id => $tags['bar'],
], $tagspropery);
], (array)json_decode($tagspropery)->tags);
}
public function test_set_tags_by_id() {
@ -261,8 +276,9 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase {
$this->setAdminUser();
list($randomslot, $tags) = $this->setup_for_test_tags(['foo', 'bar', 'baz']);
$filtercondition = new stdClass();
$randomslot->set_tags_by_id([$tags['foo']->id, $tags['bar']->id]);
$randomslot->set_filter_condition($filtercondition);
$rc = new ReflectionClass('\mod_quiz\local\structure\slot_random');
$rcp = $rc->getProperty('tags');
@ -355,11 +371,8 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase {
$form->includesubcategories = true;
$form->fromtags = [];
$form->defaultmark = 1;
$form->hidden = 1;
$form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN;
$form->stamp = make_unique_id_code();
$question = new stdClass();
$question->qtype = 'random';
$question = question_bank::get_qtype('random')->save_question($question, $form);
// Prepare 2 tags.
$tagrecord = array(
@ -377,36 +390,43 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase {
);
$bartag = $this->getDataGenerator()->create_tag($tagrecord);
// Set the filter conditions.
$filtercondition = new stdClass();
$filtercondition->questioncategoryid = $category->id;
$filtercondition->includingsubcategories = 1;
// Slot data.
$randomslotdata = new stdClass();
$randomslotdata->quizid = $quiz->id;
$randomslotdata->questionid = $question->id;
$randomslotdata->questioncategoryid = $category->id;
$randomslotdata->includingsubcategories = 1;
$randomslotdata->maxmark = 1;
$randomslotdata->usingcontextid = context_module::instance($quiz->cmid)->id;
$randomslotdata->questionscontextid = $category->contextid;
// Insert the random question to the quiz.
$randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata);
$randomslot->set_tags([$footag, $bartag]);
$randomslot->set_filter_condition($filtercondition);
$randomslot->insert(1); // Put the question on the first page of the quiz.
// Get the random question's quiz_slot. It is at the first slot.
$quizslot = $DB->get_record('quiz_slots', array('quizid' => $quiz->id, 'slot' => 1));
// Get the random question's tags from quiz_slot_tags. It is at the first slot.
$quizslottags = $DB->get_records('quiz_slot_tags', array('slotid' => $quizslot->id));
$setreference = \mod_quiz\question\bank\qbank_helper::get_random_question_data_from_slot($quizslot->id);
$this->assertEquals($question->id, $quizslot->questionid);
$this->assertEquals($category->id, $quizslot->questioncategoryid);
$this->assertEquals(1, $quizslot->includingsubcategories);
$this->assertEquals($category->id, json_decode($setreference->filtercondition)->questioncategoryid);
$this->assertEquals(1, json_decode($setreference->filtercondition)->includingsubcategories);
$this->assertEquals(1, $quizslot->maxmark);
$tagspropery = (array)json_decode($setreference->filtercondition)->tags;
$this->assertCount(2, $quizslottags);
$this->assertCount(2, $tagspropery);
$this->assertEqualsCanonicalizing(
[
['tagid' => $footag->id, 'tagname' => $footag->name],
['tagid' => $bartag->id, 'tagname' => $bartag->name]
],
array_map(function($slottag) {
return ['tagid' => $slottag->tagid, 'tagname' => $slottag->tagname];
}, $quizslottags));
return ['tagid' => $slottag->id, 'tagname' => $slottag->name];
}, $tagspropery));
}
}

View file

@ -521,15 +521,13 @@ class locallib_test extends \advanced_testcase {
// Get the random question's slotid. It is at the second slot.
$slotid = $DB->get_field('quiz_slots', 'id', array('quizid' => $quiz->id, 'slot' => 2));
$slottags = quiz_retrieve_slot_tags($slotid);
sort($slottags);
$this->assertEqualsCanonicalizing(
$this->assertEquals(
[
['tagid' => $tags['foo']->id, 'tagname' => $tags['foo']->name],
['tagid' => $tags['bar']->id, 'tagname' => $tags['bar']->name]
],
array_map(function($slottag) {
return ['tagid' => $slottag->tagid, 'tagname' => $slottag->tagname];
}, $slottags));
"{$tags['foo']->id},{$tags['foo']->name}",
"{$tags['bar']->id},{$tags['bar']->name}",
], $slottags);
}
public function test_quiz_retrieve_slot_tags_with_removed_tag() {
@ -547,15 +545,15 @@ class locallib_test extends \advanced_testcase {
// Now remove the foo tag and check again.
\core_tag_tag::delete_tags([$tags['foo']->id]);
$slottags = quiz_retrieve_slot_tags($slotid);
sort($slottags);
$this->assertEqualsCanonicalizing(
[
['tagid' => null, 'tagname' => $tags['foo']->name],
['tagid' => $tags['bar']->id, 'tagname' => $tags['bar']->name]
],
array_map(function($slottag) {
return ['tagid' => $slottag->tagid, 'tagname' => $slottag->tagname];
}, $slottags));
$this->assertEquals(
[
"{$tags['foo']->id},{$tags['foo']->name}",
"{$tags['bar']->id},{$tags['bar']->name}",
],
$slottags);
}
public function test_quiz_retrieve_slot_tags_for_standard_question() {
@ -603,246 +601,6 @@ class locallib_test extends \advanced_testcase {
$this->assertEqualsCanonicalizing([], $tagids);
}
/**
* Data provider for the get_random_question_summaries test.
*/
public function get_quiz_retrieve_tags_for_slot_ids_test_cases() {
return [
'no questions' => [
'questioncount' => 0,
'randomquestioncount' => 0,
'randomquestiontags' => [],
'unusedtags' => [],
'removeslottagids' => [],
'expected' => []
],
'only regular questions' => [
'questioncount' => 2,
'randomquestioncount' => 0,
'randomquestiontags' => [],
'unusedtags' => ['unused1', 'unused2'],
'removeslottagids' => [],
'expected' => [
1 => [],
2 => []
]
],
'only random questions 1' => [
'questioncount' => 0,
'randomquestioncount' => 2,
'randomquestiontags' => [
0 => ['foo'],
1 => []
],
'unusedtags' => ['unused1', 'unused2'],
'removeslottagids' => [],
'expected' => [
1 => ['foo'],
2 => []
]
],
'only random questions 2' => [
'questioncount' => 0,
'randomquestioncount' => 2,
'randomquestiontags' => [
0 => ['foo', 'bop'],
1 => ['bar']
],
'unusedtags' => ['unused1', 'unused2'],
'removeslottagids' => [],
'expected' => [
1 => ['foo', 'bop'],
2 => ['bar']
]
],
'only random questions 3' => [
'questioncount' => 0,
'randomquestioncount' => 2,
'randomquestiontags' => [
0 => ['foo', 'bop'],
1 => ['bar', 'foo']
],
'unusedtags' => ['unused1', 'unused2'],
'removeslottagids' => [],
'expected' => [
1 => ['foo', 'bop'],
2 => ['bar', 'foo']
]
],
'combination of questions 1' => [
'questioncount' => 2,
'randomquestioncount' => 2,
'randomquestiontags' => [
0 => ['foo'],
1 => []
],
'unusedtags' => ['unused1', 'unused2'],
'removeslottagids' => [],
'expected' => [
1 => [],
2 => [],
3 => ['foo'],
4 => []
]
],
'combination of questions 2' => [
'questioncount' => 2,
'randomquestioncount' => 2,
'randomquestiontags' => [
0 => ['foo', 'bop'],
1 => ['bar']
],
'unusedtags' => ['unused1', 'unused2'],
'removeslottagids' => [],
'expected' => [
1 => [],
2 => [],
3 => ['foo', 'bop'],
4 => ['bar']
]
],
'combination of questions 3' => [
'questioncount' => 2,
'randomquestioncount' => 2,
'randomquestiontags' => [
0 => ['foo', 'bop'],
1 => ['bar', 'foo']
],
'unusedtags' => ['unused1', 'unused2'],
'removeslottagids' => [],
'expected' => [
1 => [],
2 => [],
3 => ['foo', 'bop'],
4 => ['bar', 'foo']
]
],
'load from name 1' => [
'questioncount' => 2,
'randomquestioncount' => 2,
'randomquestiontags' => [
0 => ['foo'],
1 => []
],
'unusedtags' => ['unused1', 'unused2'],
'removeslottagids' => [3],
'expected' => [
1 => [],
2 => [],
3 => ['foo'],
4 => []
]
],
'load from name 2' => [
'questioncount' => 2,
'randomquestioncount' => 2,
'randomquestiontags' => [
0 => ['foo', 'bop'],
1 => ['bar']
],
'unusedtags' => ['unused1', 'unused2'],
'removeslottagids' => [3],
'expected' => [
1 => [],
2 => [],
3 => ['foo', 'bop'],
4 => ['bar']
]
],
'load from name 3' => [
'questioncount' => 2,
'randomquestioncount' => 2,
'randomquestiontags' => [
0 => ['foo', 'bop'],
1 => ['bar', 'foo']
],
'unusedtags' => ['unused1', 'unused2'],
'removeslottagids' => [3],
'expected' => [
1 => [],
2 => [],
3 => ['foo', 'bop'],
4 => ['bar', 'foo']
]
]
];
}
/**
* Test the quiz_retrieve_tags_for_slot_ids function with various parameter
* combinations.
*
* @dataProvider get_quiz_retrieve_tags_for_slot_ids_test_cases()
* @param int $questioncount The number of regular questions to create
* @param int $randomquestioncount The number of random questions to create
* @param array $randomquestiontags The tags for the random questions
* @param string[] $unusedtags Additional tags to create to populate the DB with data
* @param int[] $removeslottagids Slot numbers to remove tag ids for
* @param array $expected The expected output of tag names indexed by slot number
*/
public function test_quiz_retrieve_tags_for_slot_ids_combinations(
$questioncount,
$randomquestioncount,
$randomquestiontags,
$unusedtags,
$removeslottagids,
$expected
) {
global $DB;
$this->resetAfterTest();
$this->setAdminUser();
list($quiz, $tags) = $this->setup_quiz_and_tags(
$questioncount,
$randomquestioncount,
$randomquestiontags,
$unusedtags
);
$slots = $DB->get_records('quiz_slots', ['quizid' => $quiz->id]);
$slotids = [];
$slotsbynumber = [];
foreach ($slots as $slot) {
$slotids[] = $slot->id;
$slotsbynumber[$slot->slot] = $slot;
}
if (!empty($removeslottagids)) {
// The slots to remove are the slot numbers not the slot id so we need
// to get the ids for the DB call.
$idstonull = array_map(function($slot) use ($slotsbynumber) {
return $slotsbynumber[$slot]->id;
}, $removeslottagids);
list($sql, $params) = $DB->get_in_or_equal($idstonull);
// Null out the tagid column to force the code to look up the tag by name.
$DB->set_field_select('quiz_slot_tags', 'tagid', null, "slotid {$sql}", $params);
}
$slottagsbyslotids = quiz_retrieve_tags_for_slot_ids($slotids);
// Convert the result into an associative array of slotid => [... tag names..]
// to make it easier to compare.
$actual = array_map(function($slottags) {
$names = array_map(function($slottag) {
return $slottag->tagname;
}, $slottags);
// Make sure the names are sorted for comparison.
sort($names);
return $names;
}, $slottagsbyslotids);
$formattedexptected = [];
// The expected values are indexed by slot number rather than id so let
// convert it to use the id so that we can compare the results.
foreach ($expected as $slot => $tagnames) {
sort($tagnames);
$slotid = $slotsbynumber[$slot]->id;
$formattedexptected[$slotid] = $tagnames;
}
$this->assertEquals($formattedexptected, $actual);
}
public function test_quiz_override_summary() {
global $DB, $PAGE;
$this->resetAfterTest();

View file

@ -0,0 +1,225 @@
<?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/>.
namespace mod_quiz;
use mod_quiz\external\submit_question_version;
use mod_quiz\question\bank\qbank_helper;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/quiz_question_helper_test_trait.php');
/**
* Qbank helper test for quiz.
*
* @package mod_quiz
* @category test
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @coversDefaultClass \mod_quiz\question\bank\qbank_helper
*/
class qbank_helper_test extends \advanced_testcase {
use \quiz_question_helper_test_trait;
/**
* Called before every test.
*/
public function setUp(): void {
global $USER;
parent::setUp();
$this->setAdminUser();
$this->course = $this->getDataGenerator()->create_course();
$this->student = $this->getDataGenerator()->create_user();
$this->user = $USER;
}
/**
* Test is random.
*
* @covers ::is_random
* @covers ::get_random_question_data_from_slot
*/
public function test_is_random() {
$this->resetAfterTest();
$quiz = $this->create_test_quiz($this->course);
// Test for questions from a different context.
$context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id);
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$this->add_random_questions($questiongenerator, $quiz, ['contextid' => $context->id]);
// Create the quiz object.
$quizobj = \quiz::create($quiz->id);
$structure = \mod_quiz\structure::create_for_quiz($quizobj);
$slots = $structure->get_slots();
foreach ($slots as $slot) {
$this->assertEquals(true, qbank_helper::is_random($slot->id));
// Test random data for slot.
$this->assertEquals($slot->id, qbank_helper::get_random_question_data_from_slot($slot->id)->itemid);
}
}
/**
* Test reference records.
*
* @covers ::get_version_options
* @covers ::get_question_for_redo
* @covers ::get_always_latest_version_question_ids
* @covers ::question_load_random_questions
* @covers ::question_array_sort
*/
public function test_reference_records() {
$this->resetAfterTest();
$quiz = $this->create_test_quiz($this->course);
// Test for questions from a different context.
$context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id);
// Create a couple of questions.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category(['contextid' => $context->id]);
$numq = $questiongenerator->create_question('essay', null,
['category' => $cat->id, 'name' => 'This is the first version']);
// Create two version.
$questiongenerator->update_question($numq, null, ['name' => 'This is the second version']);
$questiongenerator->update_question($numq, null, ['name' => 'This is the third version']);
quiz_add_quiz_question($numq->id, $quiz);
// Create the quiz object.
$quizobj = \quiz::create($quiz->id);
$quizobj->preload_questions();
$quizobj->load_questions();
$questions = $quizobj->get_questions();
$question = reset($questions);
$structure = \mod_quiz\structure::create_for_quiz($quizobj);
$slots = $structure->get_slots();
$slot = reset($slots);
$this->assertEquals(3, count(qbank_helper::get_version_options($question->id)));
$quizobj->preload_questions();
$quizobj->load_questions();
$questions = $quizobj->get_questions();
$question = reset($questions);
$this->assertEquals($question->id, qbank_helper::get_question_for_redo($slot->id));
// Create another version.
$questiongenerator->update_question($numq, null, ['name' => 'This is the latest version']);
// Change to always latest.
submit_question_version::execute($slot->id, 0);
$quizobj->preload_questions();
$quizobj->load_questions();
$questions = $quizobj->get_questions();
$question = reset($questions);
$this->assertEquals($question->id, qbank_helper::get_question_for_redo($slot->id));
// Test always latest version question ids.
$latestquestionids = qbank_helper::get_always_latest_version_question_ids($quiz->id);
$this->assertEquals($question->id, reset($latestquestionids));
}
/**
* Test question structure data.
*
* @covers ::get_question_structure
* @covers ::get_question_structure_data
* @covers ::question_array_sort
* @covers ::get_always_latest_version_question_ids
* @covers ::question_load_random_questions
*/
public function test_get_question_structure() {
$this->resetAfterTest();
$quiz = $this->create_test_quiz($this->course);
// Test for questions from a different context.
$context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id);
// Create a couple of questions.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category(['contextid' => $context->id]);
$numq = $questiongenerator->create_question('essay', null,
['category' => $cat->id, 'name' => 'This is the first version']);
// Create two version.
$questiongenerator->update_question($numq, null, ['name' => 'This is the second version']);
$questiongenerator->update_question($numq, null, ['name' => 'This is the third version']);
quiz_add_quiz_question($numq->id, $quiz);
// Create the quiz object.
$quizobj = \quiz::create($quiz->id);
$quizobj->preload_questions();
$quizobj->load_questions();
$questions = $quizobj->get_questions();
$question = reset($questions);
$structure = \mod_quiz\structure::create_for_quiz($quizobj);
$slots = $structure->get_slots();
$slot = reset($slots);
$structuredatas = qbank_helper::get_question_structure($quiz->id);
$structuredata = reset($structuredatas);
$this->assertEquals($structuredata->slotid, $slot->id);
$this->assertEquals($structuredata->id, $question->id);
}
/**
* Test to get the version information for a question to show in the version selection dropdown.
*
* @covers ::get_question_version_info
*/
public function test_get_question_version_info() {
$this->resetAfterTest();
$quiz = $this->create_test_quiz($this->course);
// Test for questions from a different context.
$context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id);
// Create a couple of questions.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category(['contextid' => $context->id]);
$numq = $questiongenerator->create_question('essay', null,
['category' => $cat->id, 'name' => 'This is the first version']);
// Create two version.
$questiongenerator->update_question($numq, null, ['name' => 'This is the second version']);
$questiongenerator->update_question($numq, null, ['name' => 'This is the third version']);
quiz_add_quiz_question($numq->id, $quiz);
// Create the quiz object.
$quizobj = \quiz::create($quiz->id);
$quizobj->preload_questions();
$quizobj->load_questions();
$questions = $quizobj->get_questions();
$question = reset($questions);
$structure = \mod_quiz\structure::create_for_quiz($quizobj);
$slots = $structure->get_slots();
$slot = reset($slots);
$versiondata = qbank_helper::get_question_version_info($question->id, $slot->id);
$this->assertEquals(4, count($versiondata));
$this->assertEquals('Always latest', $versiondata[0]->versionvalue);
$this->assertEquals('v3 (latest)', $versiondata[1]->versionvalue);
$this->assertEquals('v1', $versiondata[3]->versionvalue);
}
/**
* Test get the question ids for specific question version.
*
* @covers ::get_specific_version_question_ids
*/
public function test_get_specific_version_question_ids() {
$this->resetAfterTest();
$quiz = $this->create_test_quiz($this->course);
// Test for questions from a different context.
$context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id);
// Create a couple of questions.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category(['contextid' => $context->id]);
$numq = $questiongenerator->create_question('essay', null,
['category' => $cat->id, 'name' => 'This is the first version']);
// Create two version.
$questiongenerator->update_question($numq, null, ['name' => 'This is the second version']);
$questiongenerator->update_question($numq, null, ['name' => 'This is the third version']);
quiz_add_quiz_question($numq->id, $quiz);
$specificversionquestionid = qbank_helper::get_specific_version_question_ids($quiz->id);
$specificversionquestionid = reset($specificversionquestionid);
$this->assertEquals($numq->id, $specificversionquestionid);
}
}

View file

@ -51,7 +51,7 @@ class quiz_question_bank_view_testcase extends advanced_testcase {
$cm = get_coursemodule_from_instance('quiz', $quiz->id);
// Create a question in the default category.
$contexts = new question_edit_contexts($context);
$contexts = new core_question\local\bank\question_edit_contexts($context);
$cat = question_make_default_categories($contexts->all());
$questiondata = $questiongenerator->create_question('numerical', null,
['name' => 'Example question', 'category' => $cat->id]);

View file

@ -0,0 +1,182 @@
<?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/>.
/**
* Helper trait for quiz question unit tests.
*
* This trait helps to execute different tests for quiz, for example if it needs to create a quiz, add question
* to the question, add random quetion to the quiz, do a backup or restore.
*
* @package mod_quiz
* @category test
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
trait quiz_question_helper_test_trait {
/** @var \stdClass $course Test course to contain quiz. */
protected $course;
/** @var \stdClass $quiz A test quiz. */
protected $quiz;
/** @var \stdClass $user A test logged-in user. */
protected $user;
/**
* Create a test quiz for the specified course.
*
* @param \stdClass $course
* @return \stdClass
*/
protected function create_test_quiz(\stdClass $course): \stdClass {
$quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
$quiz = $quizgenerator->create_instance([
'course' => $course->id,
'questionsperpage' => 0,
'grade' => 100.0,
'sumgrades' => 2,
]);
$quiz->coursemodule = $quiz->cmid;
return $quiz;
}
/**
* Helper method to add regular questions in quiz.
*
* @param component_generator_base $questiongenerator
* @param \stdClass $quiz
* @param array $override
*/
protected function add_regular_questions($questiongenerator, \stdClass $quiz, $override = null): void {
// Create a couple of questions.
$cat = $questiongenerator->create_question_category($override);
$saq = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
// Create another version.
$questiongenerator->update_question($saq);
quiz_add_quiz_question($saq->id, $quiz);
$numq = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
// Create two version.
$questiongenerator->update_question($numq);
$questiongenerator->update_question($numq);
quiz_add_quiz_question($numq->id, $quiz);
}
/**
* Helper method to add random question to quiz.
*
* @param component_generator_base $questiongenerator
* @param \stdClass $quiz
* @param array $override
*/
protected function add_random_questions($questiongenerator, \stdClass $quiz, $override = []): void {
// Create a random question.
$cat = $questiongenerator->create_question_category($override);
$questiongenerator->create_question('truefalse', null, array('category' => $cat->id));
$questiongenerator->create_question('essay', null, array('category' => $cat->id));
quiz_add_random_questions($quiz, 0, $cat->id, 1, false);
}
/**
* Attempt questions for a quiz and user.
*
* @param \stdClass $quiz Quiz to attempt.
* @param \stdClass $user A user to attempt the quiz.
* @param int $attemptnumber
* @return array
*/
protected function attempt_quiz(\stdClass $quiz, \stdClass $user, $attemptnumber = 1): array {
$this->setUser($user);
$starttime = time();
$quizobj = \quiz::create($quiz->id, $user->id);
$quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
$quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
// Start the attempt.
$attempt = quiz_create_attempt($quizobj, $attemptnumber, false, $starttime, false, $user->id);
quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $starttime);
quiz_attempt_save_started($quizobj, $quba, $attempt);
// Finish the attempt.
$attemptobj = \quiz_attempt::create($attempt->id);
$attemptobj->process_finish($starttime, false);
$this->setUser();
return [$quizobj, $quba, $attemptobj];
}
/**
* A helper method to backup test quiz.
*
* @param \stdClass $quiz Quiz to attempt.
* @param \stdClass $user A user to attempt the quiz.
* @return string A backup ID ready to be restored.
*/
protected function backup_quiz(\stdClass $quiz, \stdClass $user): string {
global $CFG;
// Get the necessary files to perform backup and restore.
require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
$backupid = 'test-question-backup-restore';
$bc = new backup_controller(backup::TYPE_1ACTIVITY, $quiz->coursemodule, backup::FORMAT_MOODLE,
backup::INTERACTIVE_NO, backup::MODE_GENERAL, $user->id);
$bc->execute_plan();
$results = $bc->get_results();
$file = $results['backup_destination'];
$fp = get_file_packer('application/vnd.moodle.backup');
$filepath = $CFG->dataroot . '/temp/backup/' . $backupid;
$file->extract_to_pathname($fp, $filepath);
$bc->destroy();
return $backupid;
}
/**
* A helper method to restore provided backup.
*
* @param string $backupid Backup ID to restore.
* @param stdClass $course
* @param stdClass $user
*/
protected function restore_quiz(string $backupid, stdClass $course, stdClass $user): void {
$rc = new restore_controller($backupid, $course->id,
backup::INTERACTIVE_NO, backup::MODE_GENERAL, $user->id, backup::TARGET_CURRENT_ADDING);
$this->assertTrue($rc->execute_precheck());
$rc->execute_plan();
$rc->destroy();
}
/**
* A helper method to emulate duplication of the quiz.
*
* @param stdClass $course
* @param stdClass $quiz
* @return \cm_info|null
*/
protected function duplicate_quiz($course, $quiz): ?\cm_info {
return duplicate_module($course, get_fast_modinfo($course)->get_cm($quiz->cmid));
}
}

View file

@ -0,0 +1,286 @@
<?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/>.
namespace mod_quiz;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/quiz_question_helper_test_trait.php');
require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
/**
* Quiz backup and restore tests.
*
* @package mod_quiz
* @category test
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @coversDefaultClass \mod_quiz\question\bank\qbank_helper
* @coversDefaultClass \backup_quiz_activity_structure_step
* @coversDefaultClass \restore_quiz_activity_structure_step
*/
class quiz_question_restore_test extends \advanced_testcase {
use \quiz_question_helper_test_trait;
/**
* Called before every test.
*/
public function setUp(): void {
global $USER;
parent::setUp();
$this->setAdminUser();
$this->course = $this->getDataGenerator()->create_course();
$this->student = $this->getDataGenerator()->create_user();
$this->user = $USER;
}
/**
* Test a quiz backup and restore in a different course without attempts for course question bank.
*
* @covers ::get_question_structure
*/
public function test_quiz_restore_in_a_different_course_using_course_question_bank() {
global $DB;
$this->resetAfterTest();
$quiz = $this->create_test_quiz($this->course);
// Test for questions from a different context.
$context = \context_course::instance($this->course->id);
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$this->add_regular_questions($questiongenerator, $quiz, ['contextid' => $context->id]);
$this->add_random_questions($questiongenerator, $quiz, ['contextid' => $context->id]);
$backupid = $this->backup_quiz($quiz, $this->user);
// Delete the current course to make sure there is no data.
delete_course($this->course, false);
// Check if the questions and associated datas are deleted properly.
$this->assertEquals(0, count(\mod_quiz\question\bank\qbank_helper::get_question_structure($quiz->id)));
$newcourse = $this->getDataGenerator()->create_course();
$this->restore_quiz($backupid, $newcourse, $this->user);
$module = $DB->get_record('quiz', ['course' => $newcourse->id]);
$this->assertEquals(3, count(\mod_quiz\question\bank\qbank_helper::get_question_structure($module->id)));
}
/**
* Test a quiz backup and restore in a different course without attempts for quiz question bank.
*
* @covers ::get_question_structure
*/
public function test_quiz_restore_in_a_different_course_using_quiz_question_bank() {
global $DB;
$this->resetAfterTest();
$quiz = $this->create_test_quiz($this->course);
// Test for questions from a different context.
$context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id);
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$this->add_regular_questions($questiongenerator, $quiz, ['contextid' => $context->id]);
$this->add_random_questions($questiongenerator, $quiz, ['contextid' => $context->id]);
$backupid = $this->backup_quiz($quiz, $this->user);
// Delete the current course to make sure there is no data.
delete_course($this->course, false);
// Check if the questions and associated datas are deleted properly.
$this->assertEquals(0, count(\mod_quiz\question\bank\qbank_helper::get_question_structure($quiz->id)));
$newcourse = $this->getDataGenerator()->create_course();
$this->restore_quiz($backupid, $newcourse, $this->user);
$module = $DB->get_record('quiz', ['course' => $newcourse->id]);
$this->assertEquals(3, count(\mod_quiz\question\bank\qbank_helper::get_question_structure($module->id)));
}
/**
* Count the questions for the context.
*
* @param int $context
* @param string $extracondition
* @return int
*/
protected function question_count($context, $extracondition = ''): int {
global $DB;
return $DB->count_records_sql(
"SELECT COUNT(q.id)
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc on qc.id = qbe.questioncategoryid
WHERE qc.contextid = ?
$extracondition", [$context]);
}
/**
* Test if a duplicate does not duplicate questions in course question bank.
*
* @covers ::duplicate_module
*/
public function test_quiz_duplicate_does_not_duplicate_course_question_bank_questions() {
$this->resetAfterTest();
$quiz = $this->create_test_quiz($this->course);
// Test for questions from a different context.
$context = \context_course::instance($this->course->id);
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$this->add_regular_questions($questiongenerator, $quiz, ['contextid' => $context->id]);
$this->add_random_questions($questiongenerator, $quiz, ['contextid' => $context->id]);
// Count the questions in course context.
$this->assertEquals(7, $this->question_count($context->id));
$newquiz = $this->duplicate_quiz($this->course, $quiz);
$this->assertEquals(7, $this->question_count($context->id));
$context = \context_module::instance($newquiz->id);
// Count the questions in the quiz context.
$this->assertEquals(0, $this->question_count($context->id));
}
/**
* Test quiz duplicate for quiz question bank.
*
* @covers ::duplicate_module
*/
public function test_quiz_duplicate_for_quiz_question_bank_questions() {
$this->resetAfterTest();
$quiz = $this->create_test_quiz($this->course);
// Test for questions from a different context.
$context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id);
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$this->add_regular_questions($questiongenerator, $quiz, ['contextid' => $context->id]);
$this->add_random_questions($questiongenerator, $quiz, ['contextid' => $context->id]);
// Count the questions in course context.
$this->assertEquals(7, $this->question_count($context->id));
$newquiz = $this->duplicate_quiz($this->course, $quiz);
$this->assertEquals(7, $this->question_count($context->id));
$context = \context_module::instance($newquiz->id);
// Count the questions in the quiz context.
$this->assertEquals(7, $this->question_count($context->id));
}
/**
* Test quiz restore with attempts.
*
* @covers ::get_question_structure
*/
public function test_quiz_restore_with_attempts() {
global $DB;
$this->resetAfterTest();
$quiz = $this->create_test_quiz($this->course);
// Test for questions from a different context.
$context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id);
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$this->add_regular_questions($questiongenerator, $quiz, ['contextid' => $context->id]);
$this->add_random_questions($questiongenerator, $quiz, ['contextid' => $context->id]);
list($quizobj, $quba, $attemptobj) = $this->attempt_quiz($quiz, $this->student);
$userattempts = quiz_get_user_attempts($quiz->id, $this->student->id);
// Count the attempts for this quiz.
$this->assertEquals(3, $quba->question_count());
$this->assertEquals(1, count($userattempts));
$backupid = $this->backup_quiz($quiz, $this->user);
// Delete the current course to make sure there is no data.
delete_course($this->course, false);
$newcourse = $this->getDataGenerator()->create_course();
$this->restore_quiz($backupid, $newcourse, $this->user);
$module = $DB->get_record('quiz', ['course' => $newcourse->id]);
$userattempts = quiz_get_user_attempts($module->id, $this->student->id);
$this->assertEquals(1, count($userattempts));
$this->assertEquals(3, count(\mod_quiz\question\bank\qbank_helper::get_question_structure($module->id)));
}
/**
* Test pre 4.0 quiz restore for regular questions.
*
* @covers ::process_quiz_question_legacy_instance
*/
public function test_pre_4_quiz_restore_for_regular_questions() {
global $USER, $DB;
$this->resetAfterTest();
$backupid = 'abc';
$backuppath = make_backup_temp_directory($backupid);
get_file_packer('application/vnd.moodle.backup')->extract_to_pathname(
__DIR__ . "/fixtures/moodle_28_quiz.mbz", $backuppath);
// Do the restore to new course with default settings.
$categoryid = $DB->get_field_sql("SELECT MIN(id) FROM {course_categories}");
$newcourseid = \restore_dbops::create_new_course('Test fullname', 'Test shortname', $categoryid);
$rc = new \restore_controller($backupid, $newcourseid, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id,
\backup::TARGET_NEW_COURSE);
$this->assertTrue($rc->execute_precheck());
$rc->execute_plan();
$rc->destroy();
// Get the information about the resulting course and check that it is set up correctly.
$modinfo = get_fast_modinfo($newcourseid);
$quiz = array_values($modinfo->get_instances_of('quiz'))[0];
$quizobj = \quiz::create($quiz->instance);
$structure = \mod_quiz\structure::create_for_quiz($quizobj);
// Are the correct slots returned?
$slots = $structure->get_slots();
$this->assertCount(2, $slots);
$quizobj->preload_questions();
$quizobj->load_questions();
$questions = $quizobj->get_questions();
$this->assertCount(2, $questions);
// Count the questions in quiz qbank.
$context = \context_module::instance(get_coursemodule_from_instance("quiz", $quizobj->get_quizid(), $newcourseid)->id);
$this->assertEquals(2, $this->question_count($context->id));
}
/**
* Test pre 4.0 quiz restore for random questions.
*
* @covers ::process_quiz_question_legacy_instance
*/
public function test_pre_4_quiz_restore_for_random_questions() {
global $USER, $DB;
$this->resetAfterTest();
$backupid = 'abc';
$backuppath = make_backup_temp_directory($backupid);
get_file_packer('application/vnd.moodle.backup')->extract_to_pathname(
__DIR__ . "/fixtures/random_by_tag_quiz.mbz", $backuppath);
// Do the restore to new course with default settings.
$categoryid = $DB->get_field_sql("SELECT MIN(id) FROM {course_categories}");
$newcourseid = \restore_dbops::create_new_course('Test fullname', 'Test shortname', $categoryid);
$rc = new \restore_controller($backupid, $newcourseid, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id,
\backup::TARGET_NEW_COURSE);
$this->assertTrue($rc->execute_precheck());
$rc->execute_plan();
$rc->destroy();
// Get the information about the resulting course and check that it is set up correctly.
$modinfo = get_fast_modinfo($newcourseid);
$quiz = array_values($modinfo->get_instances_of('quiz'))[0];
$quizobj = \quiz::create($quiz->instance);
$structure = \mod_quiz\structure::create_for_quiz($quizobj);
// Are the correct slots returned?
$slots = $structure->get_slots();
$this->assertCount(1, $slots);
$quizobj->preload_questions();
$quizobj->load_questions();
$questions = $quizobj->get_questions();
$this->assertCount(1, $questions);
// Count the questions for course question bank.
$this->assertEquals(6, $this->question_count(\context_course::instance($newcourseid)->id));
$this->assertEquals(6, $this->question_count(\context_course::instance($newcourseid)->id,
"AND q.qtype <> 'random'"));
// Count the questions in quiz qbank.
$context = \context_module::instance(get_coursemodule_from_instance("quiz", $quizobj->get_quizid(), $newcourseid)->id);
$this->assertEquals(0, $this->question_count($context->id));
}
}

View file

@ -0,0 +1,185 @@
<?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/>.
namespace mod_quiz;
use mod_quiz\external\submit_question_version;
use mod_quiz\question\bank\qbank_helper;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/quiz_question_helper_test_trait.php');
/**
* Question versions test for quiz.
*
* @package mod_quiz
* @category test
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @coversDefaultClass \mod_quiz\question\bank\qbank_helper
*/
class quiz_question_version_test extends \advanced_testcase {
use \quiz_question_helper_test_trait;
/**
* Called before every test.
*/
public function setUp(): void {
global $USER;
parent::setUp();
$this->setAdminUser();
$this->course = $this->getDataGenerator()->create_course();
$this->student = $this->getDataGenerator()->create_user();
$this->user = $USER;
}
/**
* Test the quiz question data for changed version in the slots.
*
* @covers ::get_version_options
*/
public function test_quiz_questions_for_changed_versions() {
$this->resetAfterTest();
$quiz = $this->create_test_quiz($this->course);
// Test for questions from a different context.
$context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id);
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
// Create a couple of questions.
$cat = $questiongenerator->create_question_category(['contextid' => $context->id]);
$numq = $questiongenerator->create_question('essay', null,
['category' => $cat->id, 'name' => 'This is the first version']);
// Create two version.
$questiongenerator->update_question($numq, null, ['name' => 'This is the second version']);
$questiongenerator->update_question($numq, null, ['name' => 'This is the third version']);
quiz_add_quiz_question($numq->id, $quiz);
// Create the quiz object.
$quizobj = \quiz::create($quiz->id);
$structure = \mod_quiz\structure::create_for_quiz($quizobj);
$slots = $structure->get_slots();
$slot = reset($slots);
// Test that the version added is the latest version, as there are three created.
$this->assertEquals(3, $slot->version);
$quizobj->preload_questions();
$quizobj->load_questions();
$questions = $quizobj->get_questions();
$question = reset($questions);
$this->assertEquals(3, $question->version);
$this->assertEquals('This is the third version', $question->name);
// Now change the version using the external service.
$versions = qbank_helper::get_version_options($slot->questionid);
// We dont want the current version.
$selectversions = [];
foreach ($versions as $version) {
if ($version->version === $slot->version) {
continue;
}
$selectversions [$version->version] = $version;
}
// Change to version 1.
submit_question_version::execute($slot->id, (int)$selectversions[1]->version);
$quizobj->preload_questions();
$quizobj->load_questions();
$questions = $quizobj->get_questions();
$question = reset($questions);
$this->assertEquals(1, $question->version);
$this->assertEquals('This is the first version', $question->name);
$structure = \mod_quiz\structure::create_for_quiz($quizobj);
$slots = $structure->get_slots();
$slot = reset($slots);
$this->assertEquals(1, $slot->version);
// Change to version 2.
submit_question_version::execute($slot->id, $selectversions[2]->version);
$quizobj->preload_questions();
$quizobj->load_questions();
$questions = $quizobj->get_questions();
$question = reset($questions);
$this->assertEquals(2, $question->version);
$this->assertEquals('This is the second version', $question->name);
$structure = \mod_quiz\structure::create_for_quiz($quizobj);
$slots = $structure->get_slots();
$slot = reset($slots);
$this->assertEquals(2, $slot->version);
// Create another version.
$questiongenerator->update_question($numq, null, ['name' => 'This is the latest version']);
// Change to always latest.
submit_question_version::execute($slot->id, 0);
$quizobj->preload_questions();
$quizobj->load_questions();
$questions = $quizobj->get_questions();
$question = reset($questions);
$this->assertEquals(4, $question->version);
$this->assertEquals('This is the latest version', $question->name);
$structure = \mod_quiz\structure::create_for_quiz($quizobj);
$slots = $structure->get_slots();
$slot = reset($slots);
$this->assertEquals(4, $slot->version);
}
/**
* Test if changing the version of the slot changes the attempts.
*
* @covers ::get_version_options
*/
public function test_quiz_question_attempts_with_changed_version() {
$this->resetAfterTest();
$quiz = $this->create_test_quiz($this->course);
// Test for questions from a different context.
$context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id);
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
// Create a couple of questions.
$cat = $questiongenerator->create_question_category(['contextid' => $context->id]);
$numq = $questiongenerator->create_question('numerical', null,
['category' => $cat->id, 'name' => 'This is the first version']);
// Create two version.
$questiongenerator->update_question($numq, null, ['name' => 'This is the second version']);
$questiongenerator->update_question($numq, null, ['name' => 'This is the third version']);
quiz_add_quiz_question($numq->id, $quiz);
list($quizobj, $quba, $attemptobj) = $this->attempt_quiz($quiz, $this->student);
$this->assertEquals('This is the third version', $attemptobj->get_question_attempt(1)->get_question()->name);
// Create the quiz object.
$quizobj = \quiz::create($quiz->id);
$structure = \mod_quiz\structure::create_for_quiz($quizobj);
$slots = $structure->get_slots();
$slot = reset($slots);
// Now change the version using the external service.
$versions = qbank_helper::get_version_options($slot->questionid);
// We dont want the current version.
$selectversions = [];
foreach ($versions as $version) {
if ($version->version === $slot->version) {
continue;
}
$selectversions [$version->version] = $version;
}
// Change to version 1.
$this->expectException('moodle_exception');
submit_question_version::execute($slot->id, (int)$selectversions[1]->version);
list($quizobj, $quba, $attemptobj) = $this->attempt_quiz($quiz, $this->student, 2);
$this->assertEquals('This is the first version', $attemptobj->get_question_attempt(1)->get_question()->name);
// Change to version 2.
submit_question_version::execute($slot->id, (int)$selectversions[2]->version);
list($quizobj, $quba, $attemptobj) = $this->attempt_quiz($quiz, $this->student, 3);
$this->assertEquals('This is the second version', $attemptobj->get_question_attempt(1)->get_question()->name);
// Create another version.
$questiongenerator->update_question($numq, null, ['name' => 'This is the latest version']);
// Change to always latest.
submit_question_version::execute($slot->id, 0);
list($quizobj, $quba, $attemptobj) = $this->attempt_quiz($quiz, $this->student, 4);
$this->assertEquals('This is the latest version', $attemptobj->get_question_attempt(1)->get_question()->name);
}
}

View file

@ -703,7 +703,11 @@ class mod_quiz_structure_testcase extends advanced_testcase {
$cat = $questiongenerator->create_question_category();
quiz_add_random_questions($quizobj->get_quiz(), 1, $cat->id, 1, false);
$structure = \mod_quiz\structure::create_for_quiz($quizobj);
$randomq = $DB->get_record('question', array('qtype' => 'random'));
$sql = 'SELECT qsr.*
FROM {question_set_references} qsr
JOIN {quiz_slots} qs ON qs.id = qsr.itemid
WHERE qs.quizid = ?';
$randomq = $DB->get_record_sql($sql, [$quizobj->get_quizid()]);
$structure->remove_slot(2);
@ -711,7 +715,7 @@ class mod_quiz_structure_testcase extends advanced_testcase {
$this->assert_quiz_layout(array(
array('TF1', 1, 'truefalse'),
), $structure);
$this->assertFalse($DB->record_exists('question', array('id' => $randomq->id)));
$this->assertFalse($DB->record_exists('question_set_references', array('id' => $randomq->id)));
}
/**
@ -899,150 +903,6 @@ class mod_quiz_structure_testcase extends advanced_testcase {
$this->assertEquals(0, $structure->is_question_dependent_on_previous_slot(2));
}
/**
* Data provider for the get_slot_tags_for_slot test.
*/
public function get_slot_tags_for_slot_test_cases() {
return [
'incorrect slot id' => [
'layout' => [
['TF1', 1, 'truefalse'],
['TF2', 1, 'truefalse'],
['TF3', 1, 'truefalse']
],
'tagnames' => [
['foo'],
['bar'],
['baz']
],
'slotnumber' => null,
'expected' => []
],
'no tags' => [
'layout' => [
['TF1', 1, 'truefalse'],
['TF2', 1, 'truefalse'],
['TF3', 1, 'truefalse']
],
'tagnames' => [
['foo'],
[],
['baz']
],
'slotnumber' => 2,
'expected' => []
],
'one tag 1' => [
'layout' => [
['TF1', 1, 'truefalse'],
['TF2', 1, 'truefalse'],
['TF3', 1, 'truefalse']
],
'tagnames' => [
['foo'],
['bar'],
['baz']
],
'slotnumber' => 1,
'expected' => ['foo']
],
'one tag 2' => [
'layout' => [
['TF1', 1, 'truefalse'],
['TF2', 1, 'truefalse'],
['TF3', 1, 'truefalse']
],
'tagnames' => [
['foo'],
['bar'],
['baz']
],
'slotnumber' => 2,
'expected' => ['bar']
],
'multiple tags 1' => [
'layout' => [
['TF1', 1, 'truefalse'],
['TF2', 1, 'truefalse'],
['TF3', 1, 'truefalse']
],
'tagnames' => [
['foo', 'bar'],
['bar'],
['baz']
],
'slotnumber' => 1,
'expected' => ['foo', 'bar']
],
'multiple tags 2' => [
'layout' => [
['TF1', 1, 'truefalse'],
['TF2', 1, 'truefalse'],
['TF3', 1, 'truefalse']
],
'tagnames' => [
['foo', 'bar'],
['bar', 'baz'],
['baz']
],
'slotnumber' => 2,
'expected' => ['bar', 'baz']
]
];
}
/**
* @dataProvider get_slot_tags_for_slot_test_cases()
* @param array $layout Quiz layout for create_test_quiz function
* @param array $tagnames Tags to create for each question slot
* @param int $slotnumber The slot number to select tags from
* @param string[] $expected The tags expected for the given $slotnumber
*/
public function test_get_slot_tags_for_slot($layout, $tagnames, $slotnumber, $expected) {
global $DB;
$this->resetAfterTest();
$quiz = $this->create_test_quiz($layout);
$structure = \mod_quiz\structure::create_for_quiz($quiz);
$collid = core_tag_area::get_collection('core', 'question');
$slottagrecords = [];
if (is_null($slotnumber)) {
// Null slot number means to create a non-existent slot id.
$slot = $structure->get_last_slot();
$slotid = $slot->id + 100;
} else {
$slot = $structure->get_slot_by_number($slotnumber);
$slotid = $slot->id;
}
foreach ($tagnames as $index => $slottagnames) {
$tagslotnumber = $index + 1;
$tagslotid = $structure->get_slot_id_for_slot($tagslotnumber);
$tags = core_tag_tag::create_if_missing($collid, $slottagnames);
$records = array_map(function($tag) use ($tagslotid) {
return (object) [
'slotid' => $tagslotid,
'tagid' => $tag->id,
'tagname' => $tag->name
];
}, array_values($tags));
$slottagrecords = array_merge($slottagrecords, $records);
}
$DB->insert_records('quiz_slot_tags', $slottagrecords);
$actualslottags = $structure->get_slot_tags_for_slot_id($slotid);
$actual = array_map(function($slottag) {
return $slottag->tagname;
}, $actualslottags);
sort($expected);
sort($actual);
$this->assertEquals($expected, $actual);
}
/**
* Test for can_add_random_questions.
*/

View file

@ -15,20 +15,10 @@
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Unit tests for usage of tags in quizzes.
*
* @package mod_quiz
* @copyright 2018 Shamim Rezaie <shamim@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Class mod_quiz_tags_testcase
* Class for tests related to usage of question tags in quizzes.
* Test the restore of random question tags.
*
* @copyright 2018 Shamim Rezaie <shamim@moodle.com>
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class mod_quiz_tags_testcase extends advanced_testcase {
@ -84,17 +74,14 @@ class mod_quiz_tags_testcase extends advanced_testcase {
$this->assertNotFalse($tag3);
$slottags = quiz_retrieve_slot_tags($question->slotid);
$this->assertEqualsCanonicalizing(
[
['tagid' => $tag2->id, 'tagname' => $tag2->name]
],
array_map(function($tag) {
return ['tagid' => $tag->tagid, 'tagname' => $tag->tagname];
}, $slottags)
);
$slottags = reset($slottags);
$slottags = explode(',', $slottags);
$this->assertEquals("{$tag2->id},{$tag2->name}", "{$slottags[0]},{$slottags[1]}");
$defaultcategory = question_get_default_category(context_course::instance($newcourseid)->id);
$this->assertEquals($defaultcategory->id, $question->randomfromcategory);
$this->assertEquals(0, $question->randomincludingsubcategories);
$this->assertEquals($defaultcategory->id, $question->categoryobject->id);
$randomincludingsubcategories = $DB->get_record('question_set_references', ['itemid' => reset($slots)->id]);
$filtercondition = json_decode($randomincludingsubcategories->filtercondition);
$this->assertEquals(0, $filtercondition->includingsubcategories);
}
}

View file

@ -12,7 +12,13 @@ This files describes API changes in the quiz code.
in mod/quiz/renderer.php.
* The function no_questions_message() in class mod_quiz_renderer is deprecated. Please use generate_no_questions_message()
in the same class.
* quiz_slots has been updated as a part of https://docs.moodle.org/dev/Question_bank_improvements_for_Moodle_4.0
The fields removed will be now manage by a new table in core_question:
- question_set_reference -> Records where a specific question is used.
- question_set_reference -> Records where groups of questions are used (e.g.: Random questions).
The quiz_slots_tags table will be removed entirely.
* The method get_slot_tags_for_slot_id() from mod/quiz/classes/structure.php has been deprecated and the associated
code for this method have been removed to make sure any unnecessary error doesn't happen as a part of any call.
=== 3.11 ===

View file

@ -24,6 +24,6 @@
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2021101901;
$plugin->version = 2022020300;
$plugin->requires = 2021052500;
$plugin->component = 'mod_quiz';