Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
// This file is part of Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
 
17
namespace mod_quiz\question\bank;
18
 
19
use context_module;
1441 ariadna 20
use core_question\local\bank\version_options;
1 efrain 21
use core_question\local\bank\question_version_status;
22
use core_question\local\bank\random_question_loader;
23
use core_question\question_reference_manager;
24
use qbank_tagquestion\tag_condition;
25
use qubaid_condition;
26
use stdClass;
27
 
28
defined('MOODLE_INTERNAL') || die();
29
 
30
require_once($CFG->dirroot . '/question/engine/bank.php');
31
 
32
/**
33
 * Helper class for question bank and its associated data.
34
 *
35
 * @package    mod_quiz
36
 * @category   question
37
 * @copyright  2021 Catalyst IT Australia Pty Ltd
38
 * @author     Safat Shahin <safatshahin@catalyst-au.net>
39
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
40
 */
41
class qbank_helper {
42
 
43
    /**
44
     * Get the available versions of a question where one of the version has the given question id.
45
     *
46
     * @param int $questionid id of a question.
47
     * @return stdClass[] other versions of this question. Each object has fields versionid,
48
     *       version and questionid. Array is returned most recent version first.
49
     */
1441 ariadna 50
    #[\core\attribute\deprecated(
51
        'core_question\local\bank::get_version_options',
52
        since: 5.0,
53
        mdl: 'MDL-77713')
54
    ]
1 efrain 55
    public static function get_version_options(int $questionid): array {
1441 ariadna 56
        \core\deprecation::emit_deprecation([self::class, __FUNCTION__]);
57
        return version_options::get_version_options($questionid);
1 efrain 58
    }
59
 
60
    /**
61
     * Get the information about which questions should be used to create a quiz attempt.
62
     *
63
     * Each element in the returned array is indexed by slot.slot (slot number) an each object hass:
64
     * - All the field of the slot table.
65
     * - contextid for where the question(s) come from.
66
     * - category id for where the questions come from.
67
     * - For non-random questions, All the fields of the question table (but id is in questionid).
68
     *   Also question version and question bankentryid.
69
     * - For random questions, filtercondition, which is also unpacked into category, randomrecurse,
70
     *   randomtags, and note that these also have a ->name set and ->qtype set to 'random'.
71
     *
72
     * @param int $quizid the id of the quiz to load the data for.
73
     * @param context_module $quizcontext the context of this quiz.
74
     * @param int|null $slotid optional, if passed only load the data for this one slot (if it is in this quiz).
75
     * @return array indexed by slot, with information about the content of each slot.
76
     */
77
    public static function get_question_structure(int $quizid, context_module $quizcontext,
1441 ariadna 78
            ?int $slotid = null): array {
1 efrain 79
        global $DB;
80
 
81
        $params = [
82
            'draft' => question_version_status::QUESTION_STATUS_DRAFT,
83
            'quizcontextid' => $quizcontext->id,
84
            'quizcontextid2' => $quizcontext->id,
85
            'quizcontextid3' => $quizcontext->id,
86
            'quizid' => $quizid,
87
            'quizid2' => $quizid,
88
        ];
89
        $slotidtest = '';
90
        $slotidtest2 = '';
91
        if ($slotid !== null) {
92
            $params['slotid'] = $slotid;
93
            $params['slotid2'] = $slotid;
94
            $slotidtest = ' AND slot.id = :slotid';
95
            $slotidtest2 = ' AND lslot.id = :slotid2';
96
        }
97
 
98
        // Load all the data about each slot.
99
        $slotdata = $DB->get_records_sql("
100
                SELECT slot.slot,
101
                       slot.id AS slotid,
102
                       slot.page,
103
                       slot.displaynumber,
104
                       slot.requireprevious,
105
                       slot.maxmark,
106
                       slot.quizgradeitemid,
107
                       qsr.filtercondition,
108
                       qsr.usingcontextid,
109
                       qv.status,
110
                       qv.id AS versionid,
111
                       qv.version,
112
                       qr.version AS requestedversion,
113
                       qv.questionbankentryid,
114
                       q.id AS questionid,
115
                       q.*,
116
                       qc.id AS category,
117
                       COALESCE(qc.contextid, qsr.questionscontextid) AS contextid
118
 
119
                  FROM {quiz_slots} slot
120
 
121
             -- case where a particular question has been added to the quiz.
122
             LEFT JOIN {question_references} qr ON qr.usingcontextid = :quizcontextid AND qr.component = 'mod_quiz'
123
                                        AND qr.questionarea = 'slot' AND qr.itemid = slot.id
124
             LEFT JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid
125
 
126
             -- This way of getting the latest version for each slot is a bit more complicated
127
             -- than we would like, but the simpler SQL did not work in Oracle 11.2.
128
             -- (It did work fine in Oracle 19.x, so once we have updated our min supported
129
             -- version we could consider digging the old code out of git history from
130
             -- just before the commit that added this comment.
131
             -- For relevant question_bank_entries, this gets the latest non-draft slot number.
1441 ariadna 132
             -- TODO: Optimise the query, as Oracle-specific constraints no longer apply.
1 efrain 133
             LEFT JOIN (
134
                   SELECT lv.questionbankentryid,
135
                          MAX(CASE WHEN lv.status <> :draft THEN lv.version END) AS usableversion,
136
                          MAX(lv.version) AS anyversion
137
                     FROM {quiz_slots} lslot
138
                     JOIN {question_references} lqr ON lqr.usingcontextid = :quizcontextid2 AND lqr.component = 'mod_quiz'
139
                                        AND lqr.questionarea = 'slot' AND lqr.itemid = lslot.id
140
                     JOIN {question_versions} lv ON lv.questionbankentryid = lqr.questionbankentryid
141
                    WHERE lslot.quizid = :quizid2
142
                          $slotidtest2
143
                      AND lqr.version IS NULL
144
                 GROUP BY lv.questionbankentryid
145
             ) latestversions ON latestversions.questionbankentryid = qr.questionbankentryid
146
 
147
             LEFT JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id
148
                                       -- Either specified version, or latest usable version, or a draft version.
149
                                       AND qv.version = COALESCE(qr.version,
150
                                           latestversions.usableversion,
151
                                           latestversions.anyversion)
152
             LEFT JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
153
             LEFT JOIN {question} q ON q.id = qv.questionid
154
 
155
             -- Case where a random question has been added.
156
             LEFT JOIN {question_set_references} qsr ON qsr.usingcontextid = :quizcontextid3 AND qsr.component = 'mod_quiz'
157
                                        AND qsr.questionarea = 'slot' AND qsr.itemid = slot.id
158
 
159
                 WHERE slot.quizid = :quizid
160
                       $slotidtest
161
 
162
              ORDER BY slot.slot
163
              ", $params);
164
 
165
        // Unpack the random info from question_set_reference.
166
        foreach ($slotdata as $slot) {
167
            // Ensure the right id is the id.
168
            $slot->id = $slot->slotid;
169
 
170
            if ($slot->filtercondition) {
171
                // Unpack the information about a random question.
172
                $slot->questionid = 's' . $slot->id; // Sometimes this is used as an array key, so needs to be unique.
173
                $filter = json_decode($slot->filtercondition, true);
174
                $slot->filtercondition = question_reference_manager::convert_legacy_set_reference_filter_condition($filter);
175
 
176
                $slot->category = $slot->filtercondition['filter']['category']['values'][0] ?? 0;
177
 
178
                $slot->qtype = 'random';
179
                $slot->name = get_string('random', 'quiz');
180
                $slot->length = 1;
181
            } else if ($slot->qtype === null) {
182
                // This question must have gone missing. Put in a placeholder.
183
                $slot->questionid = 's' . $slot->id; // Sometimes this is used as an array key, so needs to be unique.
184
                $slot->category = 0;
185
                $slot->qtype = 'missingtype';
186
                $slot->name = get_string('missingquestion', 'quiz');
187
                $slot->questiontext = ' ';
188
                $slot->questiontextformat = FORMAT_HTML;
189
                $slot->length = 1;
190
            } else if (!\question_bank::qtype_exists($slot->qtype)) {
191
                // Question of unknown type found in the database. Set to placeholder question types instead.
1441 ariadna 192
                $slot->originalqtype = $slot->qtype;
1 efrain 193
                $slot->qtype = 'missingtype';
194
            } else {
195
                $slot->_partiallyloaded = 1;
196
            }
197
        }
198
 
199
        return $slotdata;
200
    }
201
 
202
    /**
203
     * Get this list of random selection tag ids from one of the slots returned by get_question_structure.
204
     *
205
     * @param stdClass $slotdata one of the array elements returned by get_question_structure.
206
     * @return array list of tag ids.
207
     */
208
    public static function get_tag_ids_for_slot(stdClass $slotdata): array {
209
        $tagids = [];
210
        if (!isset($slotdata->filtercondition['filter'])) {
211
            return $tagids;
212
        }
213
        $filter = $slotdata->filtercondition['filter'];
214
        if (isset($filter['qtagids'])) {
215
            $tagids = $filter['qtagids']['values'];
216
        }
217
        return $tagids;
218
    }
219
 
220
    /**
221
     * Given a slot from the array returned by get_question_structure, describe the random question it represents.
222
     *
223
     * @param stdClass $slotdata one of the array elements returned by get_question_structure.
224
     * @return string that can be used to display the random slot.
225
     */
226
    public static function describe_random_question(stdClass $slotdata): string {
227
        $qtagids = self::get_tag_ids_for_slot($slotdata);
228
 
229
        if ($qtagids) {
230
            $tagnames = [];
231
            $tags = \core_tag_tag::get_bulk($qtagids, 'id, name');
232
            foreach ($tags as $tag) {
233
                $tagnames[] = $tag->name;
234
            }
235
            $description = get_string('randomqnametags', 'mod_quiz', implode(",", $tagnames));
236
        } else {
237
            $description = get_string('randomqname', 'mod_quiz');
238
        }
239
        return shorten_text($description, 255);
240
    }
241
 
242
    /**
243
     * Choose question for redo in a particular slot.
244
     *
245
     * @param int $quizid the id of the quiz to load the data for.
246
     * @param context_module $quizcontext the context of this quiz.
247
     * @param int $slotid optional, if passed only load the data for this one slot (if it is in this quiz).
248
     * @param qubaid_condition $qubaids attempts to consider when avoiding picking repeats of random questions.
249
     * @return int the id of the question to use.
250
     */
251
    public static function choose_question_for_redo(int $quizid, context_module $quizcontext,
252
            int $slotid, qubaid_condition $qubaids): int {
253
        $slotdata = self::get_question_structure($quizid, $quizcontext, $slotid);
254
        $slotdata = reset($slotdata);
255
 
256
        // Non-random question.
257
        if ($slotdata->qtype != 'random') {
258
            return $slotdata->questionid;
259
        }
260
 
261
        // Random question.
262
        $randomloader = new random_question_loader($qubaids, []);
263
        $fitlercondition = $slotdata->filtercondition;
264
        $filter = $fitlercondition['filter'] ?? [];
265
        $newqusetionid = $randomloader->get_next_filtered_question_id($filter);
266
 
267
        if ($newqusetionid === null) {
268
            throw new \moodle_exception('notenoughrandomquestions', 'quiz');
269
        }
270
        return $newqusetionid;
271
    }
272
 
273
    /**
274
     * Check all the questions in an attempt and return information about their versions.
275
     *
276
     * Once a quiz attempt has been started, it continues to use the version of each question
277
     * it was started with. This checks the version used for each question, against the
278
     * quiz settings for that slot, and returns which version would be used if the quiz
279
     * attempt was being started now.
280
     *
281
     * There are several cases for each slot:
282
     * - If this slot is currently set to use version 'Always latest' (which includes
283
     *   random slots) and if there is now a newer version than the one in the attempt,
284
     *   use that.
285
     * - If the slot is currently set to use a fixed version of the question, and that
286
     *   is different from the version currently in the attempt, use that.
287
     * - Otherwise, use the same version.
288
     *
289
     * This is used in places like the re-grade code.
290
     *
291
     * The returned data probably contains a bit more information than is strictly needed,
292
     * (see the SQL for details) but returning a few extra ints is fast, and this could
293
     * prove invaluable when debugging. The key information is probably:
294
     * - questionattemptslot <-- array key
295
     * - questionattemptid
296
     * - currentversion
297
     * - currentquestionid
298
     * - newversion
299
     * - newquestionid
300
     *
301
     * @param stdClass $attempt a quiz_attempt database row.
302
     * @param context_module $quizcontext the quiz context for the quiz the attempt belongs to.
303
     * @return array for each question_attempt in the quiz attempt, information about whether it is using
304
     *      the latest version of the question. Array indexed by questionattemptslot.
305
     */
306
    public static function get_version_information_for_questions_in_attempt(
307
        stdClass $attempt,
308
        context_module $quizcontext,
309
    ): array {
310
        global $DB;
311
 
312
        return $DB->get_records_sql("
313
            SELECT qa.slot AS questionattemptslot,
314
                   qa.id AS questionattemptid,
315
                   slot.slot AS quizslot,
316
                   slot.id AS quizslotid,
317
                   qr.id AS questionreferenceid,
318
                   currentqv.version AS currentversion,
319
                   currentqv.questionid AS currentquestionid,
320
                   newqv.version AS newversion,
321
                   newqv.questionid AS newquestionid
322
 
323
              -- Start with the question currently used in the attempt.
324
              FROM {question_attempts} qa
325
              JOIN {question_versions} currentqv ON currentqv.questionid = qa.questionid
326
 
327
              -- Join in the question metadata which says if this is a qa from a 'Try another question like this one'.
328
              JOIN {question_attempt_steps} firststep ON firststep.questionattemptid = qa.id
329
                           AND firststep.sequencenumber = 0
330
         LEFT JOIN {question_attempt_step_data} otherslotinfo ON otherslotinfo.attemptstepid = firststep.id
331
                           AND otherslotinfo.name = :otherslotmetadataname
332
 
333
              -- Join in the quiz slot information, and hence for non-random slots, the questino_reference.
334
              JOIN {quiz_slots} slot ON slot.quizid = :quizid
335
                           AND slot.slot = COALESCE({$DB->sql_cast_char2int('otherslotinfo.value', true)}, qa.slot)
336
         LEFT JOIN {question_references} qr ON qr.usingcontextid = :quizcontextid
337
                           AND qr.component = 'mod_quiz'
338
                           AND qr.questionarea = 'slot'
339
                           AND qr.itemid = slot.id
340
 
341
              -- Finally, get the new version for this slot.
342
              JOIN {question_versions} newqv ON newqv.questionbankentryid = currentqv.questionbankentryid
343
                           AND newqv.version = COALESCE(
344
                               -- If the quiz setting say use a particular version, use that.
345
                               qr.version,
346
                               -- Otherwise, we need the latest non-draft version of the current questions.
347
                               (SELECT MAX(version)
348
                                  FROM {question_versions}
349
                                 WHERE questionbankentryid = currentqv.questionbankentryid AND status <> :draft),
350
                                -- Otherwise, there is not a suitable other version, so stick with the current one.
351
                                currentqv.version
352
                            )
353
 
354
             -- We want this for questions in the current attempt.
355
             WHERE qa.questionusageid = :questionusageid
356
 
357
          -- Order not essential, but fast and good for debugging.
358
          ORDER BY qa.slot
359
        ", [
360
            'otherslotmetadataname' => ':_originalslot',
361
            'quizid' => $attempt->quiz,
362
            'quizcontextid' => $quizcontext->id,
363
            'draft' => question_version_status::QUESTION_STATUS_DRAFT,
364
            'questionusageid' => $attempt->uniqueid,
365
        ]);
366
    }
367
}