Proyectos de Subversion Moodle

Rev

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