mirrored from git://git.moodle.org/moodle.git
-
Notifications
You must be signed in to change notification settings - Fork 6.4k
/
qbank_helper.php
357 lines (320 loc) · 16.7 KB
/
qbank_helper.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
<?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 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.maxmark,
slot.displaynumber,
slot.requireprevious,
qsr.filtercondition,
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.
$filtercondition = json_decode($slot->filtercondition);
$slot->questionid = 's' . $slot->id; // Sometimes this is used as an array key, so needs to be unique.
$slot->category = $filtercondition->questioncategoryid;
$slot->randomrecurse = (bool) $filtercondition->includingsubcategories;
$slot->randomtags = isset($filtercondition->tags) ? (array) $filtercondition->tags : [];
$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 = [];
foreach ($slotdata->randomtags as $taginfo) {
[$id] = explode(',', $taginfo, 2);
$tagids[] = $id;
}
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 {
global $DB;
$category = $DB->get_record('question_categories', ['id' => $slotdata->category]);
return \question_bank::get_qtype('random')->question_name(
$category, $slotdata->randomrecurse, $slotdata->randomtags);
}
/**
* 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, []);
$newqusetionid = $randomloader->get_next_question_id($slotdata->category,
$slotdata->randomrecurse, self::get_tag_ids_for_slot($slotdata));
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,
]);
}
}