AutorÃa | Ultima modificación | Ver Log |
<?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;
use context_module;
use core_question\local\bank\question_version_status;
use core_question\local\bank\random_question_loader;
use core_question\question_reference_manager;
use qbank_tagquestion\tag_condition;
use qubaid_condition;
use stdClass;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/question/engine/bank.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 {
/**
* Get the available versions of a question where one of the version has the given question id.
*
* @param int $questionid id of a question.
* @return stdClass[] other versions of this question. Each object has fields versionid,
* version and questionid. Array is returned most recent version first.
*/
public static function get_version_options(int $questionid): array {
global $DB;
return $DB->get_records_sql("
SELECT allversions.id AS versionid,
allversions.version,
allversions.questionid
FROM {question_versions} allversions
WHERE allversions.questionbankentryid = (
SELECT givenversion.questionbankentryid
FROM {question_versions} givenversion
WHERE givenversion.questionid = ?
)
AND allversions.status <> ?
ORDER BY allversions.version DESC
", [$questionid, question_version_status::QUESTION_STATUS_DRAFT]);
}
/**
* Get the information about which questions should be used to create a quiz attempt.
*
* Each element in the returned array is indexed by slot.slot (slot number) an each object hass:
* - All the field of the slot table.
* - contextid for where the question(s) come from.
* - category id for where the questions come from.
* - For non-random questions, All the fields of the question table (but id is in questionid).
* Also question version and question bankentryid.
* - For random questions, filtercondition, which is also unpacked into category, randomrecurse,
* randomtags, and note that these also have a ->name set and ->qtype set to 'random'.
*
* @param int $quizid the id of the quiz to load the data for.
* @param context_module $quizcontext the context of this quiz.
* @param int|null $slotid optional, if passed only load the data for this one slot (if it is in this quiz).
* @return array indexed by slot, with information about the content of each slot.
*/
public static function get_question_structure(int $quizid, context_module $quizcontext,
int $slotid = null): array {
global $DB;
$params = [
'draft' => question_version_status::QUESTION_STATUS_DRAFT,
'quizcontextid' => $quizcontext->id,
'quizcontextid2' => $quizcontext->id,
'quizcontextid3' => $quizcontext->id,
'quizid' => $quizid,
'quizid2' => $quizid,
];
$slotidtest = '';
$slotidtest2 = '';
if ($slotid !== null) {
$params['slotid'] = $slotid;
$params['slotid2'] = $slotid;
$slotidtest = ' AND slot.id = :slotid';
$slotidtest2 = ' AND lslot.id = :slotid2';
}
// Load all the data about each slot.
$slotdata = $DB->get_records_sql("
SELECT slot.slot,
slot.id AS slotid,
slot.page,
slot.displaynumber,
slot.requireprevious,
slot.maxmark,
slot.quizgradeitemid,
qsr.filtercondition,
qsr.usingcontextid,
qv.status,
qv.id AS versionid,
qv.version,
qr.version AS requestedversion,
qv.questionbankentryid,
q.id AS questionid,
q.*,
qc.id AS category,
COALESCE(qc.contextid, qsr.questionscontextid) AS contextid
FROM {quiz_slots} slot
-- case where a particular question has been added to the quiz.
LEFT JOIN {question_references} qr ON qr.usingcontextid = :quizcontextid AND qr.component = 'mod_quiz'
AND qr.questionarea = 'slot' AND qr.itemid = slot.id
LEFT JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid
-- This way of getting the latest version for each slot is a bit more complicated
-- than we would like, but the simpler SQL did not work in Oracle 11.2.
-- (It did work fine in Oracle 19.x, so once we have updated our min supported
-- version we could consider digging the old code out of git history from
-- just before the commit that added this comment.
-- For relevant question_bank_entries, this gets the latest non-draft slot number.
LEFT JOIN (
SELECT lv.questionbankentryid,
MAX(CASE WHEN lv.status <> :draft THEN lv.version END) AS usableversion,
MAX(lv.version) AS anyversion
FROM {quiz_slots} lslot
JOIN {question_references} lqr ON lqr.usingcontextid = :quizcontextid2 AND lqr.component = 'mod_quiz'
AND lqr.questionarea = 'slot' AND lqr.itemid = lslot.id
JOIN {question_versions} lv ON lv.questionbankentryid = lqr.questionbankentryid
WHERE lslot.quizid = :quizid2
$slotidtest2
AND lqr.version IS NULL
GROUP BY lv.questionbankentryid
) latestversions ON latestversions.questionbankentryid = qr.questionbankentryid
LEFT JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id
-- Either specified version, or latest usable version, or a draft version.
AND qv.version = COALESCE(qr.version,
latestversions.usableversion,
latestversions.anyversion)
LEFT JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
LEFT JOIN {question} q ON q.id = qv.questionid
-- Case where a random question has been added.
LEFT JOIN {question_set_references} qsr ON qsr.usingcontextid = :quizcontextid3 AND qsr.component = 'mod_quiz'
AND qsr.questionarea = 'slot' AND qsr.itemid = slot.id
WHERE slot.quizid = :quizid
$slotidtest
ORDER BY slot.slot
", $params);
// Unpack the random info from question_set_reference.
foreach ($slotdata as $slot) {
// Ensure the right id is the id.
$slot->id = $slot->slotid;
if ($slot->filtercondition) {
// Unpack the information about a random question.
$slot->questionid = 's' . $slot->id; // Sometimes this is used as an array key, so needs to be unique.
$filter = json_decode($slot->filtercondition, true);
$slot->filtercondition = question_reference_manager::convert_legacy_set_reference_filter_condition($filter);
$slot->category = $slot->filtercondition['filter']['category']['values'][0] ?? 0;
$slot->qtype = 'random';
$slot->name = get_string('random', 'quiz');
$slot->length = 1;
} else if ($slot->qtype === null) {
// This question must have gone missing. Put in a placeholder.
$slot->questionid = 's' . $slot->id; // Sometimes this is used as an array key, so needs to be unique.
$slot->category = 0;
$slot->qtype = 'missingtype';
$slot->name = get_string('missingquestion', 'quiz');
$slot->questiontext = ' ';
$slot->questiontextformat = FORMAT_HTML;
$slot->length = 1;
} else if (!\question_bank::qtype_exists($slot->qtype)) {
// Question of unknown type found in the database. Set to placeholder question types instead.
$slot->qtype = 'missingtype';
} else {
$slot->_partiallyloaded = 1;
}
}
return $slotdata;
}
/**
* Get this list of random selection tag ids from one of the slots returned by get_question_structure.
*
* @param stdClass $slotdata one of the array elements returned by get_question_structure.
* @return array list of tag ids.
*/
public static function get_tag_ids_for_slot(stdClass $slotdata): array {
$tagids = [];
if (!isset($slotdata->filtercondition['filter'])) {
return $tagids;
}
$filter = $slotdata->filtercondition['filter'];
if (isset($filter['qtagids'])) {
$tagids = $filter['qtagids']['values'];
}
return $tagids;
}
/**
* Given a slot from the array returned by get_question_structure, describe the random question it represents.
*
* @param stdClass $slotdata one of the array elements returned by get_question_structure.
* @return string that can be used to display the random slot.
*/
public static function describe_random_question(stdClass $slotdata): string {
$qtagids = self::get_tag_ids_for_slot($slotdata);
if ($qtagids) {
$tagnames = [];
$tags = \core_tag_tag::get_bulk($qtagids, 'id, name');
foreach ($tags as $tag) {
$tagnames[] = $tag->name;
}
$description = get_string('randomqnametags', 'mod_quiz', implode(",", $tagnames));
} else {
$description = get_string('randomqname', 'mod_quiz');
}
return shorten_text($description, 255);
}
/**
* Choose question for redo in a particular slot.
*
* @param int $quizid the id of the quiz to load the data for.
* @param context_module $quizcontext the context of this quiz.
* @param int $slotid optional, if passed only load the data for this one slot (if it is in this quiz).
* @param qubaid_condition $qubaids attempts to consider when avoiding picking repeats of random questions.
* @return int the id of the question to use.
*/
public static function choose_question_for_redo(int $quizid, context_module $quizcontext,
int $slotid, qubaid_condition $qubaids): int {
$slotdata = self::get_question_structure($quizid, $quizcontext, $slotid);
$slotdata = reset($slotdata);
// Non-random question.
if ($slotdata->qtype != 'random') {
return $slotdata->questionid;
}
// Random question.
$randomloader = new random_question_loader($qubaids, []);
$fitlercondition = $slotdata->filtercondition;
$filter = $fitlercondition['filter'] ?? [];
$newqusetionid = $randomloader->get_next_filtered_question_id($filter);
if ($newqusetionid === null) {
throw new \moodle_exception('notenoughrandomquestions', 'quiz');
}
return $newqusetionid;
}
/**
* Check all the questions in an attempt and return information about their versions.
*
* Once a quiz attempt has been started, it continues to use the version of each question
* it was started with. This checks the version used for each question, against the
* quiz settings for that slot, and returns which version would be used if the quiz
* attempt was being started now.
*
* There are several cases for each slot:
* - If this slot is currently set to use version 'Always latest' (which includes
* random slots) and if there is now a newer version than the one in the attempt,
* use that.
* - If the slot is currently set to use a fixed version of the question, and that
* is different from the version currently in the attempt, use that.
* - Otherwise, use the same version.
*
* This is used in places like the re-grade code.
*
* The returned data probably contains a bit more information than is strictly needed,
* (see the SQL for details) but returning a few extra ints is fast, and this could
* prove invaluable when debugging. The key information is probably:
* - questionattemptslot <-- array key
* - questionattemptid
* - currentversion
* - currentquestionid
* - newversion
* - newquestionid
*
* @param stdClass $attempt a quiz_attempt database row.
* @param context_module $quizcontext the quiz context for the quiz the attempt belongs to.
* @return array for each question_attempt in the quiz attempt, information about whether it is using
* the latest version of the question. Array indexed by questionattemptslot.
*/
public static function get_version_information_for_questions_in_attempt(
stdClass $attempt,
context_module $quizcontext,
): array {
global $DB;
return $DB->get_records_sql("
SELECT qa.slot AS questionattemptslot,
qa.id AS questionattemptid,
slot.slot AS quizslot,
slot.id AS quizslotid,
qr.id AS questionreferenceid,
currentqv.version AS currentversion,
currentqv.questionid AS currentquestionid,
newqv.version AS newversion,
newqv.questionid AS newquestionid
-- Start with the question currently used in the attempt.
FROM {question_attempts} qa
JOIN {question_versions} currentqv ON currentqv.questionid = qa.questionid
-- Join in the question metadata which says if this is a qa from a 'Try another question like this one'.
JOIN {question_attempt_steps} firststep ON firststep.questionattemptid = qa.id
AND firststep.sequencenumber = 0
LEFT JOIN {question_attempt_step_data} otherslotinfo ON otherslotinfo.attemptstepid = firststep.id
AND otherslotinfo.name = :otherslotmetadataname
-- Join in the quiz slot information, and hence for non-random slots, the questino_reference.
JOIN {quiz_slots} slot ON slot.quizid = :quizid
AND slot.slot = COALESCE({$DB->sql_cast_char2int('otherslotinfo.value', true)}, qa.slot)
LEFT JOIN {question_references} qr ON qr.usingcontextid = :quizcontextid
AND qr.component = 'mod_quiz'
AND qr.questionarea = 'slot'
AND qr.itemid = slot.id
-- Finally, get the new version for this slot.
JOIN {question_versions} newqv ON newqv.questionbankentryid = currentqv.questionbankentryid
AND newqv.version = COALESCE(
-- If the quiz setting say use a particular version, use that.
qr.version,
-- Otherwise, we need the latest non-draft version of the current questions.
(SELECT MAX(version)
FROM {question_versions}
WHERE questionbankentryid = currentqv.questionbankentryid AND status <> :draft),
-- Otherwise, there is not a suitable other version, so stick with the current one.
currentqv.version
)
-- We want this for questions in the current attempt.
WHERE qa.questionusageid = :questionusageid
-- Order not essential, but fast and good for debugging.
ORDER BY qa.slot
", [
'otherslotmetadataname' => ':_originalslot',
'quizid' => $attempt->quiz,
'quizcontextid' => $quizcontext->id,
'draft' => question_version_status::QUESTION_STATUS_DRAFT,
'questionusageid' => $attempt->uniqueid,
]);
}
}