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
/**
18
 * Library of functions used by the quiz module.
19
 *
20
 * This contains functions that are called from within the quiz module only
21
 * Functions that are also called by core Moodle are in {@link lib.php}
22
 * This script also loads the code in {@link questionlib.php} which holds
23
 * the module-indpendent code for handling questions and which in turn
24
 * initialises all the questiontype classes.
25
 *
26
 * @package    mod_quiz
27
 * @copyright  1999 onwards Martin Dougiamas and others {@link http://moodle.com}
28
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
29
 */
30
 
31
defined('MOODLE_INTERNAL') || die();
32
 
33
require_once($CFG->dirroot . '/mod/quiz/lib.php');
34
require_once($CFG->libdir . '/completionlib.php');
35
require_once($CFG->libdir . '/filelib.php');
36
require_once($CFG->libdir . '/questionlib.php');
37
 
38
use core\di;
39
use core\hook;
40
use core_question\local\bank\condition;
41
use mod_quiz\access_manager;
42
use mod_quiz\event\attempt_submitted;
43
use mod_quiz\grade_calculator;
44
use mod_quiz\hook\attempt_state_changed;
45
use mod_quiz\local\override_manager;
46
use mod_quiz\question\bank\qbank_helper;
47
use mod_quiz\question\display_options;
48
use mod_quiz\quiz_attempt;
49
use mod_quiz\quiz_settings;
50
use mod_quiz\structure;
51
use qbank_previewquestion\question_preview_options;
52
 
53
/**
54
 * @var int We show the countdown timer if there is less than this amount of time left before the
55
 * the quiz close date. (1 hour)
56
 */
57
define('QUIZ_SHOW_TIME_BEFORE_DEADLINE', '3600');
58
 
59
/**
60
 * @var int If there are fewer than this many seconds left when the student submits
61
 * a page of the quiz, then do not take them to the next page of the quiz. Instead
62
 * close the quiz immediately.
63
 */
64
define('QUIZ_MIN_TIME_TO_CONTINUE', '2');
65
 
66
/**
67
 * @var int We show no image when user selects No image from dropdown menu in quiz settings.
68
 */
69
define('QUIZ_SHOWIMAGE_NONE', 0);
70
 
71
/**
72
 * @var int We show small image when user selects small image from dropdown menu in quiz settings.
73
 */
74
define('QUIZ_SHOWIMAGE_SMALL', 1);
75
 
76
/**
77
 * @var int We show Large image when user selects Large image from dropdown menu in quiz settings.
78
 */
79
define('QUIZ_SHOWIMAGE_LARGE', 2);
80
 
81
 
82
// Functions related to attempts ///////////////////////////////////////////////
83
 
84
/**
85
 * Creates an object to represent a new attempt at a quiz
86
 *
87
 * Creates an attempt object to represent an attempt at the quiz by the current
88
 * user starting at the current time. The ->id field is not set. The object is
89
 * NOT written to the database.
90
 *
91
 * @param quiz_settings $quizobj the quiz object to create an attempt for.
92
 * @param int $attemptnumber the sequence number for the attempt.
93
 * @param stdClass|false $lastattempt the previous attempt by this user, if any. Only needed
94
 *         if $attemptnumber > 1 and $quiz->attemptonlast is true.
95
 * @param int $timenow the time the attempt was started at.
96
 * @param bool $ispreview whether this new attempt is a preview.
97
 * @param int|null $userid  the id of the user attempting this quiz.
98
 *
99
 * @return stdClass the newly created attempt object.
100
 */
101
function quiz_create_attempt(quiz_settings $quizobj, $attemptnumber, $lastattempt, $timenow, $ispreview = false, $userid = null) {
102
    global $USER;
103
 
104
    if ($userid === null) {
105
        $userid = $USER->id;
106
    }
107
 
108
    $quiz = $quizobj->get_quiz();
109
    if ($quiz->sumgrades < grade_calculator::ALMOST_ZERO && $quiz->grade > grade_calculator::ALMOST_ZERO) {
110
        throw new moodle_exception('cannotstartgradesmismatch', 'quiz',
111
                new moodle_url('/mod/quiz/view.php', ['q' => $quiz->id]),
112
                    ['grade' => quiz_format_grade($quiz, $quiz->grade)]);
113
    }
114
 
115
    if ($attemptnumber == 1 || !$quiz->attemptonlast) {
116
        // We are not building on last attempt so create a new attempt.
117
        $attempt = new stdClass();
118
        $attempt->quiz = $quiz->id;
119
        $attempt->userid = $userid;
120
        $attempt->preview = 0;
121
        $attempt->layout = '';
122
    } else {
123
        // Build on last attempt.
124
        if (empty($lastattempt)) {
125
            throw new \moodle_exception('cannotfindprevattempt', 'quiz');
126
        }
127
        $attempt = $lastattempt;
128
    }
129
 
130
    $attempt->attempt = $attemptnumber;
131
    $attempt->timefinish = 0;
132
    $attempt->timemodified = $timenow;
133
    $attempt->timemodifiedoffline = 0;
134
    $attempt->currentpage = 0;
135
    $attempt->sumgrades = null;
136
    $attempt->gradednotificationsenttime = null;
1441 ariadna 137
    $attempt->timecheckstate = null;
1 efrain 138
 
139
    // If this is a preview, mark it as such.
140
    if ($ispreview) {
141
        $attempt->preview = 1;
142
    }
143
 
144
    return $attempt;
145
}
146
/**
147
 * Start a normal, new, quiz attempt.
148
 *
149
 * @param quiz_settings $quizobj        the quiz object to start an attempt for.
150
 * @param question_usage_by_activity $quba
151
 * @param stdClass    $attempt
152
 * @param integer   $attemptnumber      starting from 1
153
 * @param integer   $timenow            the attempt start time
154
 * @param array     $questionids        slot number => question id. Used for random questions, to force the choice
155
 *                                      of a particular actual question. Intended for testing purposes only.
156
 * @param array     $forcedvariantsbyslot slot number => variant. Used for questions with variants,
157
 *                                        to force the choice of a particular variant. Intended for testing
158
 *                                        purposes only.
159
 * @return stdClass   modified attempt object
160
 */
161
function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow,
162
                                $questionids = [], $forcedvariantsbyslot = []) {
163
 
164
    // Usages for this user's previous quiz attempts.
165
    $qubaids = new \mod_quiz\question\qubaids_for_users_attempts(
166
            $quizobj->get_quizid(), $attempt->userid);
167
 
168
    // Partially load all the questions in this quiz.
169
    $quizobj->preload_questions();
170
 
171
    // First load all the non-random questions.
172
    $randomfound = false;
173
    $slot = 0;
174
    $questions = [];
175
    $maxmark = [];
176
    $page = [];
177
    foreach ($quizobj->get_questions(null, false) as $questiondata) {
178
        $slot += 1;
179
        $maxmark[$slot] = $questiondata->maxmark;
180
        $page[$slot] = $questiondata->page;
181
        if ($questiondata->status == \core_question\local\bank\question_version_status::QUESTION_STATUS_DRAFT) {
182
            throw new moodle_exception('questiondraftonly', 'mod_quiz', '', $questiondata->name);
183
        }
184
        if ($questiondata->qtype == 'random') {
185
            $randomfound = true;
186
            continue;
187
        }
188
        $questions[$slot] = question_bank::load_question($questiondata->questionid, $quizobj->get_quiz()->shuffleanswers);
189
    }
190
 
191
    // Then find a question to go in place of each random question.
192
    if ($randomfound) {
193
        $slot = 0;
194
        $usedquestionids = [];
195
        foreach ($questions as $question) {
196
            if ($question->id && isset($usedquestions[$question->id])) {
197
                $usedquestionids[$question->id] += 1;
198
            } else {
199
                $usedquestionids[$question->id] = 1;
200
            }
201
        }
202
        $randomloader = new \core_question\local\bank\random_question_loader($qubaids, $usedquestionids);
203
 
204
        foreach ($quizobj->get_questions(null, false) as $questiondata) {
205
            $slot += 1;
206
            if ($questiondata->qtype != 'random') {
207
                continue;
208
            }
209
 
210
            $tagids = qbank_helper::get_tag_ids_for_slot($questiondata);
211
 
212
            // Deal with fixed random choices for testing.
213
            if (isset($questionids[$quba->next_slot_number()])) {
214
                $filtercondition = $questiondata->filtercondition;
215
                $filters = $filtercondition['filter'] ?? [];
216
                if ($randomloader->is_filtered_question_available($filters, $questionids[$quba->next_slot_number()])) {
217
                    $questions[$slot] = question_bank::load_question(
218
                            $questionids[$quba->next_slot_number()], $quizobj->get_quiz()->shuffleanswers);
219
                    continue;
220
                } else {
221
                    throw new coding_exception('Forced question id not available.');
222
                }
223
            }
224
 
225
            // Normal case, pick one at random.
226
            $filtercondition = $questiondata->filtercondition;
227
            $filters = $filtercondition['filter'] ?? [];
228
            $questionid = $randomloader->get_next_filtered_question_id($filters);
229
 
230
            if ($questionid === null) {
231
                throw new moodle_exception('notenoughrandomquestions', 'quiz',
232
                                           $quizobj->view_url(), $questiondata);
233
            }
234
 
235
            $questions[$slot] = question_bank::load_question($questionid,
236
                    $quizobj->get_quiz()->shuffleanswers);
237
        }
238
    }
239
 
240
    // Finally add them all to the usage.
241
    ksort($questions);
242
    foreach ($questions as $slot => $question) {
243
        $newslot = $quba->add_question($question, $maxmark[$slot]);
244
        if ($newslot != $slot) {
245
            throw new coding_exception('Slot numbers have got confused.');
246
        }
247
    }
248
 
249
    // Start all the questions.
250
    $variantstrategy = new core_question\engine\variants\least_used_strategy($quba, $qubaids);
251
 
252
    if (!empty($forcedvariantsbyslot)) {
253
        $forcedvariantsbyseed = question_variant_forced_choices_selection_strategy::prepare_forced_choices_array(
254
            $forcedvariantsbyslot, $quba);
255
        $variantstrategy = new question_variant_forced_choices_selection_strategy(
256
            $forcedvariantsbyseed, $variantstrategy);
257
    }
258
 
1441 ariadna 259
    $quba->start_all_questions($variantstrategy, question_attempt_step::TIMECREATED_ON_FIRST_RENDER, $attempt->userid);
1 efrain 260
 
261
    // Work out the attempt layout.
262
    $sections = $quizobj->get_sections();
263
    foreach ($sections as $i => $section) {
264
        if (isset($sections[$i + 1])) {
265
            $sections[$i]->lastslot = $sections[$i + 1]->firstslot - 1;
266
        } else {
267
            $sections[$i]->lastslot = count($questions);
268
        }
269
    }
270
 
271
    $layout = [];
272
    foreach ($sections as $section) {
273
        if ($section->shufflequestions) {
274
            $questionsinthissection = [];
275
            for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) {
276
                $questionsinthissection[] = $slot;
277
            }
278
            shuffle($questionsinthissection);
279
            $questionsonthispage = 0;
280
            foreach ($questionsinthissection as $slot) {
281
                if ($questionsonthispage && $questionsonthispage == $quizobj->get_quiz()->questionsperpage) {
282
                    $layout[] = 0;
283
                    $questionsonthispage = 0;
284
                }
285
                $layout[] = $slot;
286
                $questionsonthispage += 1;
287
            }
288
 
289
        } else {
290
            $currentpage = $page[$section->firstslot];
291
            for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) {
292
                if ($currentpage !== null && $page[$slot] != $currentpage) {
293
                    $layout[] = 0;
294
                }
295
                $layout[] = $slot;
296
                $currentpage = $page[$slot];
297
            }
298
        }
299
 
300
        // Each section ends with a page break.
301
        $layout[] = 0;
302
    }
303
    $attempt->layout = implode(',', $layout);
304
 
305
    return $attempt;
306
}
307
 
308
/**
309
 * Start a subsequent new attempt, in each attempt builds on last mode.
310
 *
311
 * @param question_usage_by_activity    $quba         this question usage
312
 * @param stdClass                        $attempt      this attempt
313
 * @param stdClass                        $lastattempt  last attempt
314
 * @return stdClass                       modified attempt object
315
 *
316
 */
317
function quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt) {
318
    $oldquba = question_engine::load_questions_usage_by_activity($lastattempt->uniqueid);
319
 
320
    $oldnumberstonew = [];
321
    foreach ($oldquba->get_attempt_iterator() as $oldslot => $oldqa) {
322
        $question = $oldqa->get_question(false);
323
        if ($question->status == \core_question\local\bank\question_version_status::QUESTION_STATUS_DRAFT) {
324
            throw new moodle_exception('questiondraftonly', 'mod_quiz', '', $question->name);
325
        }
326
        $newslot = $quba->add_question($question, $oldqa->get_max_mark());
327
 
328
        $quba->start_question_based_on($newslot, $oldqa);
329
 
330
        $oldnumberstonew[$oldslot] = $newslot;
331
    }
332
 
333
    // Update attempt layout.
334
    $newlayout = [];
335
    foreach (explode(',', $lastattempt->layout) as $oldslot) {
336
        if ($oldslot != 0) {
337
            $newlayout[] = $oldnumberstonew[$oldslot];
338
        } else {
339
            $newlayout[] = 0;
340
        }
341
    }
342
    $attempt->layout = implode(',', $newlayout);
343
    return $attempt;
344
}
345
 
346
/**
1441 ariadna 347
 * Create or update the quiz attempt record, and the question usage.
1 efrain 348
 *
1441 ariadna 349
 * If the attempt already exists in the database with the NOT_STARTED state, it will be transitioned
350
 * to IN_PROGRESS and the timestart updated. If it does not already exist, a new record will be created
351
 * already in the IN_PROGRESS state.
352
 *
1 efrain 353
 * @param quiz_settings $quizobj
354
 * @param question_usage_by_activity $quba
355
 * @param stdClass                     $attempt
1441 ariadna 356
 * @param ?int $timenow The time to use for the attempt's timestart property. Defaults to time().
1 efrain 357
 * @return stdClass                    attempt object with uniqueid and id set.
358
 */
1441 ariadna 359
function quiz_attempt_save_started(
360
    quiz_settings $quizobj,
361
    question_usage_by_activity $quba,
362
    \stdClass $attempt,
363
    ?int $timenow = null,
364
): stdClass {
1 efrain 365
    global $DB;
366
 
1441 ariadna 367
    $attempt->timestart = $timenow ?? time();
368
 
369
    $timeclose = $quizobj->get_access_manager($attempt->timestart)->get_end_time($attempt);
370
    if ($timeclose === false || $attempt->preview) {
371
        $attempt->timecheckstate = null;
372
    } else {
373
        $attempt->timecheckstate = $timeclose;
374
    }
375
 
376
    $originalattempt = null;
377
    if (isset($attempt->id) && $attempt->state === quiz_attempt::NOT_STARTED) {
378
        $originalattempt = clone $attempt;
379
        // In case questions have been edited since attempts were pre-created, update questions now.
380
        quiz_attempt::create($attempt->id)->update_questions_to_new_version_if_changed();
381
        // Update the attempt's state.
382
        $attempt->state = quiz_attempt::IN_PROGRESS;
383
        $DB->update_record('quiz_attempts', $attempt);
384
    } else {
385
        // Save the attempt in the database.
386
        question_engine::save_questions_usage_by_activity($quba);
387
        $attempt->uniqueid = $quba->get_id();
388
        $attempt->state = quiz_attempt::IN_PROGRESS;
389
        $attempt->id = $DB->insert_record('quiz_attempts', $attempt);
390
    }
391
 
1 efrain 392
    // Params used by the events below.
393
    $params = [
394
        'objectid' => $attempt->id,
395
        'relateduserid' => $attempt->userid,
396
        'courseid' => $quizobj->get_courseid(),
397
        'context' => $quizobj->get_context()
398
    ];
399
    // Decide which event we are using.
400
    if ($attempt->preview) {
401
        $params['other'] = [
402
            'quizid' => $quizobj->get_quizid()
403
        ];
404
        $event = \mod_quiz\event\attempt_preview_started::create($params);
405
    } else {
406
        $event = \mod_quiz\event\attempt_started::create($params);
407
 
408
    }
409
 
410
    // Trigger the event.
411
    $event->add_record_snapshot('quiz', $quizobj->get_quiz());
412
    $event->add_record_snapshot('quiz_attempts', $attempt);
413
    $event->trigger();
414
 
1441 ariadna 415
    di::get(hook\manager::class)->dispatch(new attempt_state_changed($originalattempt, $attempt));
416
 
1 efrain 417
    return $attempt;
418
}
419
 
420
/**
1441 ariadna 421
 * Create the quiz attempt record, and the question usage.
422
 *
423
 * This saves an attempt in the NOT_STARTED state, and is designed for use when pre-creating attempts
424
 * ahead of the quiz start time to spread out the processing load.
425
 *
426
 * @param question_usage_by_activity $quba
427
 * @param stdClass $attempt
428
 * @return stdClass attempt object with uniqueid and id set.
429
 */
430
function quiz_attempt_save_not_started(question_usage_by_activity $quba, stdClass $attempt): stdClass {
431
    global $DB;
432
    // Save the attempt in the database.
433
    question_engine::save_questions_usage_by_activity($quba);
434
    $attempt->uniqueid = $quba->get_id();
435
    $attempt->state = quiz_attempt::NOT_STARTED;
436
    $attempt->id = $DB->insert_record('quiz_attempts', $attempt);
437
    di::get(hook\manager::class)->dispatch(new attempt_state_changed(null, $attempt));
438
    return $attempt;
439
}
440
 
441
/**
1 efrain 442
 * Returns an unfinished attempt (if there is one) for the given
443
 * user on the given quiz. This function does not return preview attempts.
444
 *
445
 * @param int $quizid the id of the quiz.
446
 * @param int $userid the id of the user.
447
 *
448
 * @return mixed the unfinished attempt if there is one, false if not.
449
 */
450
function quiz_get_user_attempt_unfinished($quizid, $userid) {
451
    $attempts = quiz_get_user_attempts($quizid, $userid, 'unfinished', true);
452
    if ($attempts) {
453
        return array_shift($attempts);
454
    } else {
455
        return false;
456
    }
457
}
458
 
459
/**
460
 * Delete a quiz attempt.
461
 * @param mixed $attempt an integer attempt id or an attempt object
462
 *      (row of the quiz_attempts table).
463
 * @param stdClass $quiz the quiz object.
464
 */
465
function quiz_delete_attempt($attempt, $quiz) {
466
    global $DB;
467
    if (is_numeric($attempt)) {
468
        if (!$attempt = $DB->get_record('quiz_attempts', ['id' => $attempt])) {
469
            return;
470
        }
471
    }
472
 
473
    if ($attempt->quiz != $quiz->id) {
474
        debugging("Trying to delete attempt $attempt->id which belongs to quiz $attempt->quiz " .
475
                "but was passed quiz $quiz->id.");
476
        return;
477
    }
478
 
479
    if (!isset($quiz->cmid)) {
480
        $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course);
481
        $quiz->cmid = $cm->id;
482
    }
483
 
484
    question_engine::delete_questions_usage_by_activity($attempt->uniqueid);
485
    $DB->delete_records('quiz_attempts', ['id' => $attempt->id]);
486
 
487
    // Log the deletion of the attempt if not a preview.
488
    if (!$attempt->preview) {
489
        $params = [
490
            'objectid' => $attempt->id,
491
            'relateduserid' => $attempt->userid,
492
            'context' => context_module::instance($quiz->cmid),
493
            'other' => [
494
                'quizid' => $quiz->id
495
            ]
496
        ];
497
        $event = \mod_quiz\event\attempt_deleted::create($params);
498
        $event->add_record_snapshot('quiz_attempts', $attempt);
499
        $event->trigger();
500
 
501
        // This class callback is deprecated, and will be removed in Moodle 4.8 (MDL-80327).
502
        // Use the attempt_state_changed hook instead.
503
        $callbackclasses = \core_component::get_plugin_list_with_class('quiz', 'quiz_attempt_deleted');
504
        foreach ($callbackclasses as $callbackclass) {
505
            component_class_callback($callbackclass, 'callback', [$quiz->id], null, true);
506
        }
507
 
508
        di::get(hook\manager::class)->dispatch(new attempt_state_changed($attempt, null));
509
    }
510
 
511
    // Search quiz_attempts for other instances by this user.
512
    // If none, then delete record for this quiz, this user from quiz_grades
513
    // else recalculate best grade.
514
    $userid = $attempt->userid;
515
    $gradecalculator = quiz_settings::create($quiz->id)->get_grade_calculator();
516
    if (!$DB->record_exists('quiz_attempts', ['userid' => $userid, 'quiz' => $quiz->id])) {
517
        $DB->delete_records('quiz_grades', ['userid' => $userid, 'quiz' => $quiz->id]);
518
    } else {
519
        $gradecalculator->recompute_final_grade($userid);
520
    }
521
 
522
    quiz_update_grades($quiz, $userid);
523
}
524
 
525
/**
526
 * Delete all the preview attempts at a quiz, or possibly all the attempts belonging
527
 * to one user.
528
 * @param stdClass $quiz the quiz object.
529
 * @param int $userid (optional) if given, only delete the previews belonging to this user.
530
 */
531
function quiz_delete_previews($quiz, $userid = null) {
532
    global $DB;
533
    $conditions = ['quiz' => $quiz->id, 'preview' => 1];
534
    if (!empty($userid)) {
535
        $conditions['userid'] = $userid;
536
    }
537
    $previewattempts = $DB->get_records('quiz_attempts', $conditions);
538
    foreach ($previewattempts as $attempt) {
539
        quiz_delete_attempt($attempt, $quiz);
540
    }
541
}
542
 
543
/**
544
 * @param int $quizid The quiz id.
545
 * @return bool whether this quiz has any (non-preview) attempts.
546
 */
547
function quiz_has_attempts($quizid) {
548
    global $DB;
549
    return $DB->record_exists('quiz_attempts', ['quiz' => $quizid, 'preview' => 0]);
550
}
551
 
552
// Functions to do with quiz layout and pages //////////////////////////////////
553
 
554
/**
555
 * Repaginate the questions in a quiz
556
 * @param int $quizid the id of the quiz to repaginate.
557
 * @param int $slotsperpage number of items to put on each page. 0 means unlimited.
558
 */
559
function quiz_repaginate_questions($quizid, $slotsperpage) {
560
    global $DB;
561
    $trans = $DB->start_delegated_transaction();
562
 
563
    $sections = $DB->get_records('quiz_sections', ['quizid' => $quizid], 'firstslot ASC');
564
    $firstslots = [];
565
    foreach ($sections as $section) {
566
        if ((int)$section->firstslot === 1) {
567
            continue;
568
        }
569
        $firstslots[] = $section->firstslot;
570
    }
571
 
572
    $slots = $DB->get_records('quiz_slots', ['quizid' => $quizid],
573
            'slot');
574
    $currentpage = 1;
575
    $slotsonthispage = 0;
576
    foreach ($slots as $slot) {
577
        if (($firstslots && in_array($slot->slot, $firstslots)) ||
578
            ($slotsonthispage && $slotsonthispage == $slotsperpage)) {
579
            $currentpage += 1;
580
            $slotsonthispage = 0;
581
        }
582
        if ($slot->page != $currentpage) {
583
            $DB->set_field('quiz_slots', 'page', $currentpage, ['id' => $slot->id]);
584
        }
585
        $slotsonthispage += 1;
586
    }
587
 
588
    $trans->allow_commit();
589
 
590
    // Log quiz re-paginated event.
591
    $cm = get_coursemodule_from_instance('quiz', $quizid);
592
    $event = \mod_quiz\event\quiz_repaginated::create([
593
        'context' => \context_module::instance($cm->id),
594
        'objectid' => $quizid,
595
        'other' => [
596
            'slotsperpage' => $slotsperpage
597
        ]
598
    ]);
599
    $event->trigger();
600
 
601
}
602
 
603
// Functions to do with quiz grades ////////////////////////////////////////////
604
// Note a lot of logic related to this is now in the grade_calculator class.
605
 
606
/**
607
 * Convert the raw grade stored in $attempt into a grade out of the maximum
608
 * grade for this quiz.
609
 *
610
 * @param float $rawgrade the unadjusted grade, fof example $attempt->sumgrades
611
 * @param stdClass $quiz the quiz object. Only the fields grade, sumgrades and decimalpoints are used.
612
 * @param bool|string $format whether to format the results for display
613
 *      or 'question' to format a question grade (different number of decimal places.
614
 * @return float|string the rescaled grade, or null/the lang string 'notyetgraded'
615
 *      if the $grade is null.
616
 */
617
function quiz_rescale_grade($rawgrade, $quiz, $format = true) {
618
    if (is_null($rawgrade)) {
619
        $grade = null;
620
    } else if ($quiz->sumgrades >= grade_calculator::ALMOST_ZERO) {
621
        $grade = $rawgrade * $quiz->grade / $quiz->sumgrades;
622
    } else {
623
        $grade = 0;
624
    }
625
    if ($format === 'question') {
626
        $grade = quiz_format_question_grade($quiz, $grade);
627
    } else if ($format) {
628
        $grade = quiz_format_grade($quiz, $grade);
629
    }
630
    return $grade;
631
}
632
 
633
/**
634
 * Get the feedback object for this grade on this quiz.
635
 *
636
 * @param float $grade a grade on this quiz.
637
 * @param stdClass $quiz the quiz settings.
638
 * @return false|stdClass the record object or false if there is not feedback for the given grade
639
 * @since  Moodle 3.1
640
 */
641
function quiz_feedback_record_for_grade($grade, $quiz) {
642
    global $DB;
643
 
644
    // With CBM etc, it is possible to get -ve grades, which would then not match
645
    // any feedback. Therefore, we replace -ve grades with 0.
646
    $grade = max($grade, 0);
647
 
648
    $feedback = $DB->get_record_select('quiz_feedback',
649
            'quizid = ? AND mingrade <= ? AND ? < maxgrade', [$quiz->id, $grade, $grade]);
650
 
651
    return $feedback;
652
}
653
 
654
/**
655
 * Get the feedback text that should be show to a student who
656
 * got this grade on this quiz. The feedback is processed ready for diplay.
657
 *
658
 * @param float $grade a grade on this quiz.
659
 * @param stdClass $quiz the quiz settings.
660
 * @param context_module $context the quiz context.
661
 * @return string the comment that corresponds to this grade (empty string if there is not one.
662
 */
663
function quiz_feedback_for_grade($grade, $quiz, $context) {
664
 
665
    if (is_null($grade)) {
666
        return '';
667
    }
668
 
669
    $feedback = quiz_feedback_record_for_grade($grade, $quiz);
670
 
671
    if (empty($feedback->feedbacktext)) {
672
        return '';
673
    }
674
 
675
    // Clean the text, ready for display.
676
    $formatoptions = new stdClass();
677
    $formatoptions->noclean = true;
678
    $feedbacktext = file_rewrite_pluginfile_urls($feedback->feedbacktext, 'pluginfile.php',
679
            $context->id, 'mod_quiz', 'feedback', $feedback->id);
680
    $feedbacktext = format_text($feedbacktext, $feedback->feedbacktextformat, $formatoptions);
681
 
682
    return $feedbacktext;
683
}
684
 
685
/**
686
 * @param stdClass $quiz the quiz database row.
687
 * @return bool Whether this quiz has any non-blank feedback text.
688
 */
689
function quiz_has_feedback($quiz) {
690
    global $DB;
691
    static $cache = [];
692
    if (!array_key_exists($quiz->id, $cache)) {
693
        $cache[$quiz->id] = quiz_has_grades($quiz) &&
694
                $DB->record_exists_select('quiz_feedback', "quizid = ? AND " .
695
                    $DB->sql_isnotempty('quiz_feedback', 'feedbacktext', false, true),
696
                [$quiz->id]);
697
    }
698
    return $cache[$quiz->id];
699
}
700
 
701
/**
702
 * Return summary of the number of settings override that exist.
703
 *
704
 * To get a nice display of this, see the quiz_override_summary_links()
705
 * quiz renderer method.
706
 *
707
 * @param stdClass $quiz the quiz settings. Only $quiz->id is used at the moment.
708
 * @param cm_info|stdClass $cm the cm object. Only $cm->course, $cm->groupmode and
709
 *      $cm->groupingid fields are used at the moment.
710
 * @param int $currentgroup if there is a concept of current group where this method is being called
711
 *      (e.g. a report) pass it in here. Default 0 which means no current group.
712
 * @return array like 'group' => 3, 'user' => 12] where 3 is the number of group overrides,
713
 *      and 12 is the number of user ones.
714
 */
715
function quiz_override_summary(stdClass $quiz, cm_info|stdClass $cm, int $currentgroup = 0): array {
716
    global $DB;
717
 
718
    if ($currentgroup) {
719
        // Currently only interested in one group.
720
        $groupcount = $DB->count_records('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $currentgroup]);
721
        $usercount = $DB->count_records_sql("
722
                SELECT COUNT(1)
723
                  FROM {quiz_overrides} o
724
                  JOIN {groups_members} gm ON o.userid = gm.userid
725
                 WHERE o.quiz = ?
726
                   AND gm.groupid = ?
727
                    ", [$quiz->id, $currentgroup]);
728
        return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'onegroup'];
729
    }
730
 
731
    $quizgroupmode = groups_get_activity_groupmode($cm);
732
    $accessallgroups = ($quizgroupmode == NOGROUPS) ||
733
            has_capability('moodle/site:accessallgroups', context_module::instance($cm->id));
734
 
735
    if ($accessallgroups) {
736
        // User can see all groups.
737
        $groupcount = $DB->count_records_select('quiz_overrides',
738
                'quiz = ? AND groupid IS NOT NULL', [$quiz->id]);
739
        $usercount = $DB->count_records_select('quiz_overrides',
740
                'quiz = ? AND userid IS NOT NULL', [$quiz->id]);
741
        return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'allgroups'];
742
 
743
    } else {
744
        // User can only see groups they are in.
745
        $groups = groups_get_activity_allowed_groups($cm);
746
        if (!$groups) {
747
            return ['group' => 0, 'user' => 0, 'mode' => 'somegroups'];
748
        }
749
 
750
        list($groupidtest, $params) = $DB->get_in_or_equal(array_keys($groups));
751
        $params[] = $quiz->id;
752
 
753
        $groupcount = $DB->count_records_select('quiz_overrides',
754
                "groupid $groupidtest AND quiz = ?", $params);
755
        $usercount = $DB->count_records_sql("
756
                SELECT COUNT(1)
757
                  FROM {quiz_overrides} o
758
                  JOIN {groups_members} gm ON o.userid = gm.userid
759
                 WHERE gm.groupid $groupidtest
760
                   AND o.quiz = ?
761
               ", $params);
762
 
763
        return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'somegroups'];
764
    }
765
}
766
 
767
/**
768
 * Efficiently update check state time on all open attempts
769
 *
770
 * @param array $conditions optional restrictions on which attempts to update
771
 *                    Allowed conditions:
772
 *                      courseid => (array|int) attempts in given course(s)
773
 *                      userid   => (array|int) attempts for given user(s)
774
 *                      quizid   => (array|int) attempts in given quiz(s)
775
 *                      groupid  => (array|int) quizzes with some override for given group(s)
776
 *
777
 */
778
function quiz_update_open_attempts(array $conditions) {
779
    global $DB;
780
 
781
    foreach ($conditions as &$value) {
782
        if (!is_array($value)) {
783
            $value = [$value];
784
        }
785
    }
786
 
787
    $params = [];
788
    $wheres = ["quiza.state IN ('inprogress', 'overdue')"];
789
    $iwheres = ["iquiza.state IN ('inprogress', 'overdue')"];
790
 
791
    if (isset($conditions['courseid'])) {
792
        list ($incond, $inparams) = $DB->get_in_or_equal($conditions['courseid'], SQL_PARAMS_NAMED, 'cid');
793
        $params = array_merge($params, $inparams);
794
        $wheres[] = "quiza.quiz IN (SELECT q.id FROM {quiz} q WHERE q.course $incond)";
795
        list ($incond, $inparams) = $DB->get_in_or_equal($conditions['courseid'], SQL_PARAMS_NAMED, 'icid');
796
        $params = array_merge($params, $inparams);
797
        $iwheres[] = "iquiza.quiz IN (SELECT q.id FROM {quiz} q WHERE q.course $incond)";
798
    }
799
 
800
    if (isset($conditions['userid'])) {
801
        list ($incond, $inparams) = $DB->get_in_or_equal($conditions['userid'], SQL_PARAMS_NAMED, 'uid');
802
        $params = array_merge($params, $inparams);
803
        $wheres[] = "quiza.userid $incond";
804
        list ($incond, $inparams) = $DB->get_in_or_equal($conditions['userid'], SQL_PARAMS_NAMED, 'iuid');
805
        $params = array_merge($params, $inparams);
806
        $iwheres[] = "iquiza.userid $incond";
807
    }
808
 
809
    if (isset($conditions['quizid'])) {
810
        list ($incond, $inparams) = $DB->get_in_or_equal($conditions['quizid'], SQL_PARAMS_NAMED, 'qid');
811
        $params = array_merge($params, $inparams);
812
        $wheres[] = "quiza.quiz $incond";
813
        list ($incond, $inparams) = $DB->get_in_or_equal($conditions['quizid'], SQL_PARAMS_NAMED, 'iqid');
814
        $params = array_merge($params, $inparams);
815
        $iwheres[] = "iquiza.quiz $incond";
816
    }
817
 
818
    if (isset($conditions['groupid'])) {
819
        list ($incond, $inparams) = $DB->get_in_or_equal($conditions['groupid'], SQL_PARAMS_NAMED, 'gid');
820
        $params = array_merge($params, $inparams);
821
        $wheres[] = "quiza.quiz IN (SELECT qo.quiz FROM {quiz_overrides} qo WHERE qo.groupid $incond)";
822
        list ($incond, $inparams) = $DB->get_in_or_equal($conditions['groupid'], SQL_PARAMS_NAMED, 'igid');
823
        $params = array_merge($params, $inparams);
824
        $iwheres[] = "iquiza.quiz IN (SELECT qo.quiz FROM {quiz_overrides} qo WHERE qo.groupid $incond)";
825
    }
826
 
827
    // SQL to compute timeclose and timelimit for each attempt:
828
    $quizausersql = quiz_get_attempt_usertime_sql(
829
            implode("\n                AND ", $iwheres));
830
 
831
    // SQL to compute the new timecheckstate
832
    $timecheckstatesql = "
833
          CASE WHEN quizauser.usertimelimit = 0 AND quizauser.usertimeclose = 0 THEN NULL
834
               WHEN quizauser.usertimelimit = 0 THEN quizauser.usertimeclose
835
               WHEN quizauser.usertimeclose = 0 THEN quiza.timestart + quizauser.usertimelimit
836
               WHEN quiza.timestart + quizauser.usertimelimit < quizauser.usertimeclose THEN quiza.timestart + quizauser.usertimelimit
837
               ELSE quizauser.usertimeclose END +
838
          CASE WHEN quiza.state = 'overdue' THEN quiz.graceperiod ELSE 0 END";
839
 
840
    // SQL to select which attempts to process
841
    $attemptselect = implode("\n                         AND ", $wheres);
842
 
843
   /*
844
    * Each database handles updates with inner joins differently:
845
    *  - mysql does not allow a FROM clause
846
    *  - postgres and mssql allow FROM but handle table aliases differently
847
    *
848
    * Different code for each database.
849
    */
850
 
851
    $dbfamily = $DB->get_dbfamily();
852
    if ($dbfamily == 'mysql') {
853
        $updatesql = "UPDATE {quiz_attempts} quiza
854
                        JOIN {quiz} quiz ON quiz.id = quiza.quiz
855
                        JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id
856
                         SET quiza.timecheckstate = $timecheckstatesql
857
                       WHERE $attemptselect";
858
    } else if ($dbfamily == 'postgres') {
859
        $updatesql = "UPDATE {quiz_attempts} quiza
860
                         SET timecheckstate = $timecheckstatesql
861
                        FROM {quiz} quiz, ( $quizausersql ) quizauser
862
                       WHERE quiz.id = quiza.quiz
863
                         AND quizauser.id = quiza.id
864
                         AND $attemptselect";
865
    } else if ($dbfamily == 'mssql') {
866
        $updatesql = "UPDATE quiza
867
                         SET timecheckstate = $timecheckstatesql
868
                        FROM {quiz_attempts} quiza
869
                        JOIN {quiz} quiz ON quiz.id = quiza.quiz
870
                        JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id
871
                       WHERE $attemptselect";
872
    } else {
1441 ariadna 873
        throw new \core\exception\coding_exception("Unsupported database family: {$dbfamily}");
1 efrain 874
    }
875
 
876
    $DB->execute($updatesql, $params);
877
}
878
 
879
/**
880
 * Returns SQL to compute timeclose and timelimit for every attempt, taking into account user and group overrides.
881
 * The query used herein is very similar to the one in function quiz_get_user_timeclose, so, in case you
882
 * would change either one of them, make sure to apply your changes to both.
883
 *
884
 * @param string $redundantwhereclauses extra where clauses to add to the subquery
885
 *      for performance. These can use the table alias iquiza for the quiz attempts table.
886
 * @return string SQL select with columns attempt.id, usertimeclose, usertimelimit.
887
 */
888
function quiz_get_attempt_usertime_sql($redundantwhereclauses = '') {
889
    if ($redundantwhereclauses) {
890
        $redundantwhereclauses = 'WHERE ' . $redundantwhereclauses;
891
    }
892
    // The multiple qgo JOINS are necessary because we want timeclose/timelimit = 0 (unlimited) to supercede
893
    // any other group override
894
    $quizausersql = "
895
          SELECT iquiza.id,
896
           COALESCE(MAX(quo.timeclose), MAX(qgo1.timeclose), MAX(qgo2.timeclose), iquiz.timeclose) AS usertimeclose,
897
           COALESCE(MAX(quo.timelimit), MAX(qgo3.timelimit), MAX(qgo4.timelimit), iquiz.timelimit) AS usertimelimit
898
 
899
           FROM {quiz_attempts} iquiza
900
           JOIN {quiz} iquiz ON iquiz.id = iquiza.quiz
901
      LEFT JOIN {quiz_overrides} quo ON quo.quiz = iquiza.quiz AND quo.userid = iquiza.userid
902
      LEFT JOIN {groups_members} gm ON gm.userid = iquiza.userid
903
      LEFT JOIN {quiz_overrides} qgo1 ON qgo1.quiz = iquiza.quiz AND qgo1.groupid = gm.groupid AND qgo1.timeclose = 0
904
      LEFT JOIN {quiz_overrides} qgo2 ON qgo2.quiz = iquiza.quiz AND qgo2.groupid = gm.groupid AND qgo2.timeclose > 0
905
      LEFT JOIN {quiz_overrides} qgo3 ON qgo3.quiz = iquiza.quiz AND qgo3.groupid = gm.groupid AND qgo3.timelimit = 0
906
      LEFT JOIN {quiz_overrides} qgo4 ON qgo4.quiz = iquiza.quiz AND qgo4.groupid = gm.groupid AND qgo4.timelimit > 0
907
          $redundantwhereclauses
908
       GROUP BY iquiza.id, iquiz.id, iquiz.timeclose, iquiz.timelimit";
909
    return $quizausersql;
910
}
911
 
912
/**
913
 * @return array int => lang string the options for calculating the quiz grade
914
 *      from the individual attempt grades.
915
 */
916
function quiz_get_grading_options() {
917
    return [
918
        QUIZ_GRADEHIGHEST => get_string('gradehighest', 'quiz'),
919
        QUIZ_GRADEAVERAGE => get_string('gradeaverage', 'quiz'),
920
        QUIZ_ATTEMPTFIRST => get_string('attemptfirst', 'quiz'),
921
        QUIZ_ATTEMPTLAST  => get_string('attemptlast', 'quiz')
922
    ];
923
}
924
 
925
/**
926
 * @param int $option one of the values QUIZ_GRADEHIGHEST, QUIZ_GRADEAVERAGE,
927
 *      QUIZ_ATTEMPTFIRST or QUIZ_ATTEMPTLAST.
928
 * @return the lang string for that option.
929
 */
930
function quiz_get_grading_option_name($option) {
931
    $strings = quiz_get_grading_options();
932
    return $strings[$option];
933
}
934
 
935
/**
936
 * @return array string => lang string the options for handling overdue quiz
937
 *      attempts.
938
 */
939
function quiz_get_overdue_handling_options() {
940
    return [
941
        'autosubmit'  => get_string('overduehandlingautosubmit', 'quiz'),
942
        'graceperiod' => get_string('overduehandlinggraceperiod', 'quiz'),
943
        'autoabandon' => get_string('overduehandlingautoabandon', 'quiz'),
944
    ];
945
}
946
 
947
/**
948
 * Get the choices for what size user picture to show.
949
 * @return array string => lang string the options for whether to display the user's picture.
950
 */
951
function quiz_get_user_image_options() {
952
    return [
953
        QUIZ_SHOWIMAGE_NONE  => get_string('shownoimage', 'quiz'),
954
        QUIZ_SHOWIMAGE_SMALL => get_string('showsmallimage', 'quiz'),
955
        QUIZ_SHOWIMAGE_LARGE => get_string('showlargeimage', 'quiz'),
956
    ];
957
}
958
 
959
/**
960
 * Return an user's timeclose for all quizzes in a course, hereby taking into account group and user overrides.
961
 *
962
 * @param int $courseid the course id.
963
 * @return stdClass An object with of all quizids and close unixdates in this course, taking into account the most lenient
964
 * overrides, if existing and 0 if no close date is set.
965
 */
966
function quiz_get_user_timeclose($courseid) {
967
    global $DB, $USER;
968
 
969
    // For teacher and manager/admins return timeclose.
970
    if (has_capability('moodle/course:update', context_course::instance($courseid))) {
971
        $sql = "SELECT quiz.id, quiz.timeclose AS usertimeclose
972
                  FROM {quiz} quiz
973
                 WHERE quiz.course = :courseid";
974
 
975
        $results = $DB->get_records_sql($sql, ['courseid' => $courseid]);
976
        return $results;
977
    }
978
 
979
    $sql = "SELECT q.id,
980
  COALESCE(v.userclose, v.groupclose, q.timeclose, 0) AS usertimeclose
981
  FROM (
982
      SELECT quiz.id as quizid,
983
             MAX(quo.timeclose) AS userclose, MAX(qgo.timeclose) AS groupclose
984
       FROM {quiz} quiz
985
  LEFT JOIN {quiz_overrides} quo on quiz.id = quo.quiz AND quo.userid = :userid
986
  LEFT JOIN {groups_members} gm ON gm.userid = :useringroupid
987
  LEFT JOIN {quiz_overrides} qgo on quiz.id = qgo.quiz AND qgo.groupid = gm.groupid
988
      WHERE quiz.course = :courseid
989
   GROUP BY quiz.id) v
990
       JOIN {quiz} q ON q.id = v.quizid";
991
 
992
    $results = $DB->get_records_sql($sql, ['userid' => $USER->id, 'useringroupid' => $USER->id, 'courseid' => $courseid]);
993
    return $results;
994
 
995
}
996
 
997
/**
998
 * Get the choices to offer for the 'Questions per page' option.
999
 * @return array int => string.
1000
 */
1001
function quiz_questions_per_page_options() {
1002
    $pageoptions = [];
1003
    $pageoptions[0] = get_string('neverallononepage', 'quiz');
1004
    $pageoptions[1] = get_string('everyquestion', 'quiz');
1005
    for ($i = 2; $i <= QUIZ_MAX_QPP_OPTION; ++$i) {
1006
        $pageoptions[$i] = get_string('everynquestions', 'quiz', $i);
1007
    }
1008
    return $pageoptions;
1009
}
1010
 
1011
/**
1012
 * Get the human-readable name for a quiz attempt state.
1013
 * @param string $state one of the state constants like {@see quiz_attempt::IN_PROGRESS}.
1014
 * @return string The lang string to describe that state.
1015
 */
1016
function quiz_attempt_state_name($state) {
1017
    switch ($state) {
1441 ariadna 1018
        case quiz_attempt::NOT_STARTED:
1019
            return get_string('statenotstarted', 'quiz');
1 efrain 1020
        case quiz_attempt::IN_PROGRESS:
1021
            return get_string('stateinprogress', 'quiz');
1022
        case quiz_attempt::OVERDUE:
1023
            return get_string('stateoverdue', 'quiz');
1441 ariadna 1024
        case quiz_attempt::SUBMITTED:
1025
            return get_string('statesubmitted', 'quiz');
1 efrain 1026
        case quiz_attempt::FINISHED:
1027
            return get_string('statefinished', 'quiz');
1028
        case quiz_attempt::ABANDONED:
1029
            return get_string('stateabandoned', 'quiz');
1030
        default:
1031
            throw new coding_exception('Unknown quiz attempt state.');
1032
    }
1033
}
1034
 
1035
// Other quiz functions ////////////////////////////////////////////////////////
1036
 
1037
/**
1038
 * @param stdClass $quiz the quiz.
1039
 * @param int $cmid the course_module object for this quiz.
1040
 * @param stdClass $question the question.
1041
 * @param string $returnurl url to return to after action is done.
1042
 * @param int $variant which question variant to preview (optional).
1043
 * @return string html for a number of icons linked to action pages for a
1044
 * question - preview and edit / view icons depending on user capabilities.
1045
 */
1046
function quiz_question_action_icons($quiz, $cmid, $question, $returnurl, $variant = null) {
1047
    $html = '';
1048
    if ($question->qtype !== 'random') {
1049
        $html = quiz_question_preview_button($quiz, $question, false, $variant);
1050
    }
1051
    $html .= quiz_question_edit_button($cmid, $question, $returnurl);
1052
    return $html;
1053
}
1054
 
1055
/**
1056
 * @param int $cmid the course_module.id for this quiz.
1057
 * @param stdClass $question the question.
1058
 * @param string $returnurl url to return to after action is done.
1059
 * @param string $contentbeforeicon some HTML content to be added inside the link, before the icon.
1060
 * @return the HTML for an edit icon, view icon, or nothing for a question
1061
 *      (depending on permissions).
1062
 */
1063
function quiz_question_edit_button($cmid, $question, $returnurl, $contentaftericon = '') {
1064
    global $CFG, $OUTPUT;
1065
 
1066
    // Minor efficiency saving. Only get strings once, even if there are a lot of icons on one page.
1067
    static $stredit = null;
1068
    static $strview = null;
1069
    if ($stredit === null) {
1070
        $stredit = get_string('edit');
1071
        $strview = get_string('view');
1072
    }
1073
 
1074
    // What sort of icon should we show?
1075
    $action = '';
1076
    if (!empty($question->id) &&
1077
            (question_has_capability_on($question, 'edit') ||
1078
                    question_has_capability_on($question, 'move'))) {
1079
        $action = $stredit;
1080
        $icon = 't/edit';
1081
    } else if (!empty($question->id) &&
1082
            question_has_capability_on($question, 'view')) {
1083
        $action = $strview;
1084
        $icon = 'i/info';
1085
    }
1086
 
1087
    // Build the icon.
1088
    if ($action) {
1089
        if ($returnurl instanceof moodle_url) {
1090
            $returnurl = $returnurl->out_as_local_url(false);
1091
        }
1092
        $questionparams = ['returnurl' => $returnurl, 'cmid' => $cmid, 'id' => $question->id];
1093
        $questionurl = new moodle_url("$CFG->wwwroot/question/bank/editquestion/question.php", $questionparams);
1094
        return '<a title="' . $action . '" href="' . $questionurl->out() . '" class="questioneditbutton">' .
1095
                $OUTPUT->pix_icon($icon, $action) . $contentaftericon .
1096
                '</a>';
1097
    } else if ($contentaftericon) {
1098
        return '<span class="questioneditbutton">' . $contentaftericon . '</span>';
1099
    } else {
1100
        return '';
1101
    }
1102
}
1103
 
1104
/**
1105
 * @param stdClass $quiz the quiz settings
1106
 * @param stdClass $question the question
1107
 * @param int $variant which question variant to preview (optional).
1108
 * @param int $restartversion version of the question to use when restarting the preview.
1109
 * @return moodle_url to preview this question with the options from this quiz.
1110
 */
1111
function quiz_question_preview_url($quiz, $question, $variant = null, $restartversion = null) {
1112
    // Get the appropriate display options.
1113
    $displayoptions = display_options::make_from_quiz($quiz,
1114
            display_options::DURING);
1115
 
1116
    $maxmark = null;
1117
    if (isset($question->maxmark)) {
1118
        $maxmark = $question->maxmark;
1119
    }
1120
 
1121
    // Work out the correcte preview URL.
1122
    return \qbank_previewquestion\helper::question_preview_url($question->id, $quiz->preferredbehaviour,
1123
            $maxmark, $displayoptions, $variant, null, null, $restartversion);
1124
}
1125
 
1126
/**
1127
 * @param stdClass $quiz the quiz settings
1128
 * @param stdClass $question the question
1129
 * @param bool $label if true, show the preview question label after the icon
1130
 * @param int $variant which question variant to preview (optional).
1131
 * @param bool $random if question is random, true.
1132
 * @return string the HTML for a preview question icon.
1133
 */
1134
function quiz_question_preview_button($quiz, $question, $label = false, $variant = null, $random = null) {
1135
    global $PAGE;
1136
    if (!question_has_capability_on($question, 'use')) {
1137
        return '';
1138
    }
1139
    $structure = quiz_settings::create($quiz->id)->get_structure();
1140
    if (!empty($question->slot)) {
1141
        $requestedversion = $structure->get_slot_by_number($question->slot)->requestedversion
1142
                ?? question_preview_options::ALWAYS_LATEST;
1143
    } else {
1144
        $requestedversion = question_preview_options::ALWAYS_LATEST;
1145
    }
1146
    return $PAGE->get_renderer('mod_quiz', 'edit')->question_preview_icon(
1147
            $quiz, $question, $label, $variant, $requestedversion);
1148
}
1149
 
1150
/**
1151
 * @param stdClass $attempt the attempt.
1152
 * @param stdClass $context the quiz context.
1153
 * @return int whether flags should be shown/editable to the current user for this attempt.
1154
 */
1155
function quiz_get_flag_option($attempt, $context) {
1156
    global $USER;
1157
    if (!has_capability('moodle/question:flag', $context)) {
1158
        return question_display_options::HIDDEN;
1159
    } else if ($attempt->userid == $USER->id) {
1160
        return question_display_options::EDITABLE;
1161
    } else {
1162
        return question_display_options::VISIBLE;
1163
    }
1164
}
1165
 
1166
/**
1167
 * Work out what state this quiz attempt is in - in the sense used by
1168
 * quiz_get_review_options, not in the sense of $attempt->state.
1169
 * @param stdClass $quiz the quiz settings
1170
 * @param stdClass $attempt the quiz_attempt database row.
1171
 * @return int one of the display_options::DURING,
1172
 *      IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants.
1173
 */
1174
function quiz_attempt_state($quiz, $attempt) {
1175
    if ($attempt->state == quiz_attempt::IN_PROGRESS) {
1176
        return display_options::DURING;
1177
    } else if ($quiz->timeclose && time() >= $quiz->timeclose) {
1178
        return display_options::AFTER_CLOSE;
1179
    } else if (time() < $attempt->timefinish + quiz_attempt::IMMEDIATELY_AFTER_PERIOD) {
1180
        return display_options::IMMEDIATELY_AFTER;
1181
    } else {
1182
        return display_options::LATER_WHILE_OPEN;
1183
    }
1184
}
1185
 
1186
/**
1187
 * The appropriate display_options object for this attempt at this quiz right now.
1188
 *
1189
 * @param stdClass $quiz the quiz instance.
1190
 * @param stdClass $attempt the attempt in question.
1191
 * @param context $context the quiz context.
1192
 *
1193
 * @return display_options
1194
 */
1195
function quiz_get_review_options($quiz, $attempt, $context) {
1196
    $options = display_options::make_from_quiz($quiz, quiz_attempt_state($quiz, $attempt));
1197
 
1198
    $options->readonly = true;
1199
    $options->flags = quiz_get_flag_option($attempt, $context);
1200
    if (!empty($attempt->id)) {
1201
        $options->questionreviewlink = new moodle_url('/mod/quiz/reviewquestion.php',
1202
                ['attempt' => $attempt->id]);
1203
    }
1204
 
1205
    // Show a link to the comment box only for closed attempts.
1206
    if (!empty($attempt->id) && $attempt->state == quiz_attempt::FINISHED && !$attempt->preview &&
1207
            !is_null($context) && has_capability('mod/quiz:grade', $context)) {
1208
        $options->manualcomment = question_display_options::VISIBLE;
1209
        $options->manualcommentlink = new moodle_url('/mod/quiz/comment.php',
1210
                ['attempt' => $attempt->id]);
1211
    }
1212
 
1213
    if (!is_null($context) && !$attempt->preview &&
1214
            has_capability('mod/quiz:viewreports', $context) &&
1215
            has_capability('moodle/grade:viewhidden', $context)) {
1216
        // People who can see reports and hidden grades should be shown everything,
1217
        // except during preview when teachers want to see what students see.
1218
        $options->attempt = question_display_options::VISIBLE;
1219
        $options->correctness = question_display_options::VISIBLE;
1220
        $options->marks = question_display_options::MARK_AND_MAX;
1221
        $options->feedback = question_display_options::VISIBLE;
1222
        $options->numpartscorrect = question_display_options::VISIBLE;
1223
        $options->manualcomment = question_display_options::VISIBLE;
1224
        $options->generalfeedback = question_display_options::VISIBLE;
1225
        $options->rightanswer = question_display_options::VISIBLE;
1226
        $options->overallfeedback = question_display_options::VISIBLE;
1227
        $options->history = question_display_options::VISIBLE;
1228
        $options->userinfoinhistory = $attempt->userid;
1229
 
1230
    }
1231
 
1232
    return $options;
1233
}
1234
 
1235
/**
1236
 * Combines the review options from a number of different quiz attempts.
1237
 * Returns an array of two ojects, so the suggested way of calling this
1238
 * funciton is:
1239
 * list($someoptions, $alloptions) = quiz_get_combined_reviewoptions(...)
1240
 *
1241
 * @param stdClass $quiz the quiz instance.
1242
 * @param array $attempts an array of attempt objects.
1243
 *
1244
 * @return array of two options objects, one showing which options are true for
1245
 *          at least one of the attempts, the other showing which options are true
1246
 *          for all attempts.
1247
 */
1248
function quiz_get_combined_reviewoptions($quiz, $attempts) {
1249
    $fields = ['feedback', 'generalfeedback', 'rightanswer', 'overallfeedback'];
1250
    $someoptions = new stdClass();
1251
    $alloptions = new stdClass();
1252
    foreach ($fields as $field) {
1253
        $someoptions->$field = false;
1254
        $alloptions->$field = true;
1255
    }
1256
    $someoptions->marks = question_display_options::HIDDEN;
1257
    $alloptions->marks = question_display_options::MARK_AND_MAX;
1258
 
1259
    // This shouldn't happen, but we need to prevent reveal information.
1260
    if (empty($attempts)) {
1261
        return [$someoptions, $someoptions];
1262
    }
1263
 
1264
    foreach ($attempts as $attempt) {
1265
        $attemptoptions = display_options::make_from_quiz($quiz,
1266
                quiz_attempt_state($quiz, $attempt));
1267
        foreach ($fields as $field) {
1268
            $someoptions->$field = $someoptions->$field || $attemptoptions->$field;
1269
            $alloptions->$field = $alloptions->$field && $attemptoptions->$field;
1270
        }
1271
        $someoptions->marks = max($someoptions->marks, $attemptoptions->marks);
1272
        $alloptions->marks = min($alloptions->marks, $attemptoptions->marks);
1273
    }
1274
    return [$someoptions, $alloptions];
1275
}
1276
 
1277
// Functions for sending notification messages /////////////////////////////////
1278
 
1279
/**
1280
 * Sends a confirmation message to the student confirming that the attempt was processed.
1281
 *
1282
 * @param stdClass $recipient user object for the recipient.
1283
 * @param stdClass $a lots of useful information that can be used in the message
1284
 *      subject and body.
1285
 * @param bool $studentisonline is the student currently interacting with Moodle?
1286
 *
1287
 * @return int|false as for {@link message_send()}.
1288
 */
1289
function quiz_send_confirmation($recipient, $a, $studentisonline) {
1290
 
1291
    // Add information about the recipient to $a.
1292
    // Don't do idnumber. we want idnumber to be the submitter's idnumber.
1293
    $a->username     = fullname($recipient);
1294
    $a->userusername = $recipient->username;
1295
 
1296
    // Prepare the message.
1297
    $eventdata = new \core\message\message();
1298
    $eventdata->courseid          = $a->courseid;
1299
    $eventdata->component         = 'mod_quiz';
1300
    $eventdata->name              = 'confirmation';
1301
    $eventdata->notification      = 1;
1302
 
1303
    $eventdata->userfrom          = core_user::get_noreply_user();
1304
    $eventdata->userto            = $recipient;
1305
    $eventdata->subject           = get_string('emailconfirmsubject', 'quiz', $a);
1306
 
1307
    if ($studentisonline) {
1308
        $eventdata->fullmessage = get_string('emailconfirmbody', 'quiz', $a);
1309
    } else {
1310
        $eventdata->fullmessage = get_string('emailconfirmbodyautosubmit', 'quiz', $a);
1311
    }
1312
 
1313
    $eventdata->fullmessageformat = FORMAT_PLAIN;
1314
    $eventdata->fullmessagehtml   = '';
1315
 
1316
    $eventdata->smallmessage      = get_string('emailconfirmsmall', 'quiz', $a);
1317
    $eventdata->contexturl        = $a->quizurl;
1318
    $eventdata->contexturlname    = $a->quizname;
1319
    $eventdata->customdata        = [
1320
        'cmid' => $a->quizcmid,
1321
        'instance' => $a->quizid,
1322
        'attemptid' => $a->attemptid,
1323
    ];
1324
 
1325
    // ... and send it.
1326
    return message_send($eventdata);
1327
}
1328
 
1329
/**
1330
 * Sends notification messages to the interested parties that assign the role capability
1331
 *
1332
 * @param stdClass $recipient user object of the intended recipient
1333
 * @param stdClass $submitter user object for the user who submitted the attempt.
1334
 * @param stdClass $a associative array of replaceable fields for the templates
1335
 *
1336
 * @return int|false as for {@link message_send()}.
1337
 */
1338
function quiz_send_notification($recipient, $submitter, $a) {
1339
    global $PAGE;
1340
 
1341
    // Recipient info for template.
1342
    $a->useridnumber = $recipient->idnumber;
1343
    $a->username     = fullname($recipient);
1344
    $a->userusername = $recipient->username;
1345
 
1346
    // Prepare the message.
1347
    $eventdata = new \core\message\message();
1348
    $eventdata->courseid          = $a->courseid;
1349
    $eventdata->component         = 'mod_quiz';
1350
    $eventdata->name              = 'submission';
1351
    $eventdata->notification      = 1;
1352
 
1353
    $eventdata->userfrom          = $submitter;
1354
    $eventdata->userto            = $recipient;
1355
    $eventdata->subject           = get_string('emailnotifysubject', 'quiz', $a);
1356
    $eventdata->fullmessage       = get_string('emailnotifybody', 'quiz', $a);
1357
    $eventdata->fullmessageformat = FORMAT_PLAIN;
1358
    $eventdata->fullmessagehtml   = '';
1359
 
1360
    $eventdata->smallmessage      = get_string('emailnotifysmall', 'quiz', $a);
1361
    $eventdata->contexturl        = $a->quizreviewurl;
1362
    $eventdata->contexturlname    = $a->quizname;
1363
    $userpicture = new user_picture($submitter);
1364
    $userpicture->size = 1; // Use f1 size.
1365
    $userpicture->includetoken = $recipient->id; // Generate an out-of-session token for the user receiving the message.
1366
    $eventdata->customdata        = [
1367
        'cmid' => $a->quizcmid,
1368
        'instance' => $a->quizid,
1369
        'attemptid' => $a->attemptid,
1370
        'notificationiconurl' => $userpicture->get_url($PAGE)->out(false),
1371
    ];
1372
 
1373
    // ... and send it.
1374
    return message_send($eventdata);
1375
}
1376
 
1377
/**
1378
 * Send all the requried messages when a quiz attempt is submitted.
1379
 *
1380
 * @param stdClass $course the course
1381
 * @param stdClass $quiz the quiz
1382
 * @param stdClass $attempt this attempt just finished
1383
 * @param stdClass $context the quiz context
1384
 * @param stdClass $cm the coursemodule for this quiz
1385
 * @param bool $studentisonline is the student currently interacting with Moodle?
1386
 *
1387
 * @return bool true if all necessary messages were sent successfully, else false.
1388
 */
1389
function quiz_send_notification_messages($course, $quiz, $attempt, $context, $cm, $studentisonline) {
1390
    global $CFG, $DB;
1391
 
1392
    // Do nothing if required objects not present.
1393
    if (empty($course) or empty($quiz) or empty($attempt) or empty($context)) {
1394
        throw new coding_exception('$course, $quiz, $attempt, $context and $cm must all be set.');
1395
    }
1396
 
1397
    $submitter = $DB->get_record('user', ['id' => $attempt->userid], '*', MUST_EXIST);
1398
 
1399
    // Check for confirmation required.
1400
    $sendconfirm = false;
1401
    $notifyexcludeusers = '';
1402
    if (has_capability('mod/quiz:emailconfirmsubmission', $context, $submitter, false)) {
1403
        $notifyexcludeusers = $submitter->id;
1404
        $sendconfirm = true;
1405
    }
1406
 
1407
    // Check for notifications required.
1408
    $notifyfields = 'u.id, u.username, u.idnumber, u.email, u.emailstop, u.lang,
1409
            u.timezone, u.mailformat, u.maildisplay, u.auth, u.suspended, u.deleted, ';
1410
    $userfieldsapi = \core_user\fields::for_name();
1411
    $notifyfields .= $userfieldsapi->get_sql('u', false, '', '', false)->selects;
1412
    $groups = groups_get_all_groups($course->id, $submitter->id, $cm->groupingid);
1413
    if (is_array($groups) && count($groups) > 0) {
1414
        $groups = array_keys($groups);
1415
    } else if (groups_get_activity_groupmode($cm, $course) != NOGROUPS) {
1416
        // If the user is not in a group, and the quiz is set to group mode,
1417
        // then set $groups to a non-existant id so that only users with
1418
        // 'moodle/site:accessallgroups' get notified.
1419
        $groups = -1;
1420
    } else {
1421
        $groups = '';
1422
    }
1423
    $userstonotify = get_users_by_capability($context, 'mod/quiz:emailnotifysubmission',
1424
            $notifyfields, '', '', '', $groups, $notifyexcludeusers, false, false, true);
1425
 
1426
    if (empty($userstonotify) && !$sendconfirm) {
1427
        return true; // Nothing to do.
1428
    }
1429
 
1430
    $a = new stdClass();
1431
    // Course info.
1432
    $a->courseid        = $course->id;
1441 ariadna 1433
    $a->coursename      = format_string($course->fullname, true, ['context' => $context]);
1434
    $a->courseshortname = format_string($course->shortname, true, ['context' => $context]);
1 efrain 1435
    // Quiz info.
1441 ariadna 1436
    $a->quizname        = format_string($quiz->name, true, ['context' => $context]);
1 efrain 1437
    $a->quizreporturl   = $CFG->wwwroot . '/mod/quiz/report.php?id=' . $cm->id;
1441 ariadna 1438
    $a->quizreportlink  = '<a href="' . $a->quizreporturl . '">' . $a->quizname . ' report</a>';
1 efrain 1439
    $a->quizurl         = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $cm->id;
1441 ariadna 1440
    $a->quizlink        = '<a href="' . $a->quizurl . '">' . $a->quizname . '</a>';
1 efrain 1441
    $a->quizid          = $quiz->id;
1442
    $a->quizcmid        = $cm->id;
1443
    // Attempt info.
1444
    $a->submissiontime  = userdate($attempt->timefinish);
1445
    $a->timetaken       = format_time($attempt->timefinish - $attempt->timestart);
1446
    $a->quizreviewurl   = $CFG->wwwroot . '/mod/quiz/review.php?attempt=' . $attempt->id;
1441 ariadna 1447
    $a->quizreviewlink  = '<a href="' . $a->quizreviewurl . '">' . $a->quizname . ' review</a>';
1 efrain 1448
    $a->attemptid       = $attempt->id;
1449
    // Student who sat the quiz info.
1450
    $a->studentidnumber = $submitter->idnumber;
1451
    $a->studentname     = fullname($submitter);
1452
    $a->studentusername = $submitter->username;
1453
 
1454
    $allok = true;
1455
 
1456
    // Send notifications if required.
1457
    if (!empty($userstonotify)) {
1458
        foreach ($userstonotify as $recipient) {
1459
            $allok = $allok && quiz_send_notification($recipient, $submitter, $a);
1460
        }
1461
    }
1462
 
1463
    // Send confirmation if required. We send the student confirmation last, so
1464
    // that if message sending is being intermittently buggy, which means we send
1465
    // some but not all messages, and then try again later, then teachers may get
1466
    // duplicate messages, but the student will always get exactly one.
1467
    if ($sendconfirm) {
1468
        $allok = $allok && quiz_send_confirmation($submitter, $a, $studentisonline);
1469
    }
1470
 
1471
    return $allok;
1472
}
1473
 
1474
/**
1475
 * Send the notification message when a quiz attempt becomes overdue.
1476
 *
1477
 * @param quiz_attempt $attemptobj all the data about the quiz attempt.
1478
 */
1479
function quiz_send_overdue_message($attemptobj) {
1480
    global $CFG, $DB;
1481
 
1482
    $submitter = $DB->get_record('user', ['id' => $attemptobj->get_userid()], '*', MUST_EXIST);
1483
 
1484
    if (!$attemptobj->has_capability('mod/quiz:emailwarnoverdue', $submitter->id, false)) {
1485
        return; // Message not required.
1486
    }
1487
 
1488
    if (!$attemptobj->has_response_to_at_least_one_graded_question()) {
1489
        return; // Message not required.
1490
    }
1491
 
1492
    // Prepare lots of useful information that admins might want to include in
1493
    // the email message.
1494
    $quizname = format_string($attemptobj->get_quiz_name());
1495
 
1496
    $deadlines = [];
1497
    if ($attemptobj->get_quiz()->timelimit) {
1498
        $deadlines[] = $attemptobj->get_attempt()->timestart + $attemptobj->get_quiz()->timelimit;
1499
    }
1500
    if ($attemptobj->get_quiz()->timeclose) {
1501
        $deadlines[] = $attemptobj->get_quiz()->timeclose;
1502
    }
1503
    $duedate = min($deadlines);
1504
    $graceend = $duedate + $attemptobj->get_quiz()->graceperiod;
1505
 
1506
    $a = new stdClass();
1507
    // Course info.
1508
    $a->courseid           = $attemptobj->get_course()->id;
1509
    $a->coursename         = format_string($attemptobj->get_course()->fullname);
1510
    $a->courseshortname    = format_string($attemptobj->get_course()->shortname);
1511
    // Quiz info.
1512
    $a->quizname           = $quizname;
1513
    $a->quizurl            = $attemptobj->view_url()->out(false);
1514
    $a->quizlink           = '<a href="' . $a->quizurl . '">' . $quizname . '</a>';
1515
    // Attempt info.
1516
    $a->attemptduedate     = userdate($duedate);
1517
    $a->attemptgraceend    = userdate($graceend);
1518
    $a->attemptsummaryurl  = $attemptobj->summary_url()->out(false);
1519
    $a->attemptsummarylink = '<a href="' . $a->attemptsummaryurl . '">' . $quizname . ' review</a>';
1520
    // Student's info.
1521
    $a->studentidnumber    = $submitter->idnumber;
1522
    $a->studentname        = fullname($submitter);
1523
    $a->studentusername    = $submitter->username;
1524
 
1525
    // Prepare the message.
1526
    $eventdata = new \core\message\message();
1527
    $eventdata->courseid          = $a->courseid;
1528
    $eventdata->component         = 'mod_quiz';
1529
    $eventdata->name              = 'attempt_overdue';
1530
    $eventdata->notification      = 1;
1531
 
1532
    $eventdata->userfrom          = core_user::get_noreply_user();
1533
    $eventdata->userto            = $submitter;
1534
    $eventdata->subject           = get_string('emailoverduesubject', 'quiz', $a);
1535
    $eventdata->fullmessage       = get_string('emailoverduebody', 'quiz', $a);
1536
    $eventdata->fullmessageformat = FORMAT_PLAIN;
1537
    $eventdata->fullmessagehtml   = '';
1538
 
1539
    $eventdata->smallmessage      = get_string('emailoverduesmall', 'quiz', $a);
1540
    $eventdata->contexturl        = $a->quizurl;
1541
    $eventdata->contexturlname    = $a->quizname;
1542
    $eventdata->customdata        = [
1543
        'cmid' => $attemptobj->get_cmid(),
1544
        'instance' => $attemptobj->get_quizid(),
1545
        'attemptid' => $attemptobj->get_attemptid(),
1546
    ];
1547
 
1548
    // Send the message.
1549
    return message_send($eventdata);
1550
}
1551
 
1552
/**
1553
 * Handle the quiz_attempt_submitted event.
1554
 *
1555
 * This sends the confirmation and notification messages, if required.
1556
 *
1557
 * @param attempt_submitted $event the event object.
1558
 */
1559
function quiz_attempt_submitted_handler($event) {
1560
    $course = get_course($event->courseid);
1561
    $attempt = $event->get_record_snapshot('quiz_attempts', $event->objectid);
1562
    $quiz = $event->get_record_snapshot('quiz', $attempt->quiz);
1563
    $cm = get_coursemodule_from_id('quiz', $event->get_context()->instanceid, $event->courseid);
1564
    $eventdata = $event->get_data();
1565
 
1566
    if (!($course && $quiz && $cm && $attempt)) {
1567
        // Something has been deleted since the event was raised. Therefore, the
1568
        // event is no longer relevant.
1569
        return true;
1570
    }
1571
 
1572
    // Update completion state.
1573
    $completion = new completion_info($course);
1574
    if ($completion->is_enabled($cm) &&
1575
        ($quiz->completionattemptsexhausted || $quiz->completionminattempts)) {
1576
        $completion->update_state($cm, COMPLETION_COMPLETE, $event->userid);
1577
    }
1578
    return quiz_send_notification_messages($course, $quiz, $attempt,
1579
            context_module::instance($cm->id), $cm, $eventdata['other']['studentisonline']);
1580
}
1581
 
1582
/**
1583
 * Send the notification message when a quiz attempt has been manual graded.
1584
 *
1585
 * @param quiz_attempt $attemptobj Some data about the quiz attempt.
1586
 * @param stdClass $userto
1587
 * @return int|false As for message_send.
1588
 */
1589
function quiz_send_notify_manual_graded_message(quiz_attempt $attemptobj, object $userto): ?int {
1590
    global $CFG;
1591
 
1592
    $quizname = format_string($attemptobj->get_quiz_name());
1593
 
1594
    $a = new stdClass();
1595
    // Course info.
1596
    $a->courseid           = $attemptobj->get_courseid();
1597
    $a->coursename         = format_string($attemptobj->get_course()->fullname);
1598
    // Quiz info.
1599
    $a->quizname           = $quizname;
1600
    $a->quizurl            = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $attemptobj->get_cmid();
1601
 
1602
    // Attempt info.
1603
    $a->attempttimefinish  = userdate($attemptobj->get_attempt()->timefinish);
1604
    // Student's info.
1605
    $a->studentidnumber    = $userto->idnumber;
1606
    $a->studentname        = fullname($userto);
1607
 
1608
    $eventdata = new \core\message\message();
1609
    $eventdata->component = 'mod_quiz';
1610
    $eventdata->name = 'attempt_grading_complete';
1611
    $eventdata->userfrom = core_user::get_noreply_user();
1612
    $eventdata->userto = $userto;
1613
 
1614
    $eventdata->subject = get_string('emailmanualgradedsubject', 'quiz', $a);
1615
    $eventdata->fullmessage = get_string('emailmanualgradedbody', 'quiz', $a);
1616
    $eventdata->fullmessageformat = FORMAT_PLAIN;
1617
    $eventdata->fullmessagehtml = '';
1618
 
1619
    $eventdata->notification = 1;
1620
    $eventdata->contexturl = $a->quizurl;
1621
    $eventdata->contexturlname = $a->quizname;
1622
 
1623
    // Send the message.
1624
    return message_send($eventdata);
1625
}
1626
 
1627
 
1628
/**
1629
 * Logic to happen when a/some group(s) has/have been deleted in a course.
1630
 *
1631
 * @param int $courseid The course ID.
1632
 * @return void
1633
 */
1634
function quiz_process_group_deleted_in_course($courseid) {
1635
    $affectedquizzes = override_manager::delete_orphaned_group_overrides_in_course($courseid);
1636
 
1637
    if (!empty($affectedquizzes)) {
1638
        quiz_update_open_attempts(['quizid' => $affectedquizzes]);
1639
    }
1640
}
1641
 
1642
/**
1643
 * Get the information about the standard quiz JavaScript module.
1644
 * @return array a standard jsmodule structure.
1645
 */
1646
function quiz_get_js_module() {
1647
    global $PAGE;
1648
 
1649
    return [
1650
        'name' => 'mod_quiz',
1651
        'fullpath' => '/mod/quiz/module.js',
1652
        'requires' => ['base', 'dom', 'event-delegate', 'event-key',
1653
                'core_question_engine'],
1654
        'strings' => [
1655
            ['cancel', 'moodle'],
1656
            ['flagged', 'question'],
1657
            ['functiondisabledbysecuremode', 'quiz'],
1658
            ['startattempt', 'quiz'],
1659
            ['timesup', 'quiz'],
1660
            ['show', 'moodle'],
1661
            ['hide', 'moodle'],
1662
        ],
1663
    ];
1664
}
1665
 
1666
 
1667
/**
1668
 * Creates a textual representation of a question for display.
1669
 *
1670
 * @param stdClass $question A question object from the database questions table
1671
 * @param bool $showicon If true, show the question's icon with the question. False by default.
1672
 * @param bool $showquestiontext If true (default), show question text after question name.
1673
 *       If false, show only question name.
1674
 * @param bool $showidnumber If true, show the question's idnumber, if any. False by default.
1675
 * @param core_tag_tag[]|bool $showtags if array passed, show those tags. Else, if true, get and show tags,
1676
 *       else, don't show tags (which is the default).
1441 ariadna 1677
 * @param bool $displaytaglink Indicates whether the tag should be displayed as a link.
1 efrain 1678
 * @return string HTML fragment.
1679
 */
1680
function quiz_question_tostring($question, $showicon = false, $showquestiontext = true,
1441 ariadna 1681
        $showidnumber = false, $showtags = false, $displaytaglink = true) {
1 efrain 1682
    global $OUTPUT;
1683
    $result = '';
1684
 
1685
    // Question name.
1686
    $name = shorten_text(format_string($question->name), 200);
1687
    if ($showicon) {
1688
        $name .= print_question_icon($question) . ' ' . $name;
1689
    }
1690
    $result .= html_writer::span($name, 'questionname');
1691
 
1692
    // Question idnumber.
1693
    if ($showidnumber && $question->idnumber !== null && $question->idnumber !== '') {
1694
        $result .= ' ' . html_writer::span(
1695
                html_writer::span(get_string('idnumber', 'question'), 'accesshide') .
1696
                ' ' . s($question->idnumber), 'badge bg-primary text-white');
1697
    }
1698
 
1699
    // Question tags.
1700
    if (is_array($showtags)) {
1701
        $tags = $showtags;
1702
    } else if ($showtags) {
1703
        $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id);
1704
    } else {
1705
        $tags = [];
1706
    }
1707
    if ($tags) {
1441 ariadna 1708
        $result .= $OUTPUT->tag_list($tags, null, 'd-inline', 0, null, true, $displaytaglink);
1 efrain 1709
    }
1710
 
1711
    // Question text.
1712
    if ($showquestiontext) {
1713
        $questiontext = question_utils::to_plain_text($question->questiontext,
1714
                $question->questiontextformat, ['noclean' => true, 'para' => false, 'filter' => false]);
1715
        $questiontext = shorten_text($questiontext, 50);
1716
        if ($questiontext) {
1717
            $result .= ' ' . html_writer::span(s($questiontext), 'questiontext');
1718
        }
1719
    }
1720
 
1721
    return $result;
1722
}
1723
 
1724
/**
1725
 * Verify that the question exists, and the user has permission to use it.
1726
 * Does not return. Throws an exception if the question cannot be used.
1727
 * @param int $questionid The id of the question.
1728
 */
1729
function quiz_require_question_use($questionid) {
1730
    global $DB;
1731
    $question = $DB->get_record('question', ['id' => $questionid], '*', MUST_EXIST);
1732
    question_require_capability_on($question, 'use');
1733
}
1734
 
1735
/**
1736
 * Add a question to a quiz
1737
 *
1738
 * Adds a question to a quiz by updating $quiz as well as the
1739
 * quiz and quiz_slots tables. It also adds a page break if required.
1740
 * @param int $questionid The id of the question to be added
1741
 * @param stdClass $quiz The extended quiz object as used by edit.php
1742
 *      This is updated by this function
1743
 * @param int $page Which page in quiz to add the question on. If 0 (default),
1744
 *      add at the end
1745
 * @param float $maxmark The maximum mark to set for this question. (Optional,
1746
 *      defaults to question.defaultmark.
1747
 * @return bool false if the question was already in the quiz
1748
 */
1749
function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null) {
1750
    global $DB;
1751
 
1752
    if (!isset($quiz->cmid)) {
1753
        $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course);
1754
        $quiz->cmid = $cm->id;
1755
    }
1756
 
1757
    // Make sue the question is not of the "random" type.
1758
    $questiontype = $DB->get_field('question', 'qtype', ['id' => $questionid]);
1759
    if ($questiontype == 'random') {
1760
        throw new coding_exception(
1761
                'Adding "random" questions via quiz_add_quiz_question() is deprecated. Please use quiz_add_random_questions().'
1762
        );
1763
    }
1764
 
1441 ariadna 1765
    // If the question type is invalid, we cannot add it to the quiz. It shouldn't be possible to get to this
1766
    // point without fiddling with the DOM so we can just throw an exception.
1767
    if (!\question_bank::is_qtype_installed($questiontype)) {
1768
        throw new coding_exception('Invalid question type: ' . $questiontype);
1769
    }
1770
 
1 efrain 1771
    $trans = $DB->start_delegated_transaction();
1772
 
1773
    $sql = "SELECT qbe.id
1774
              FROM {quiz_slots} slot
1775
              JOIN {question_references} qr ON qr.itemid = slot.id
1776
              JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid
1777
             WHERE slot.quizid = ?
1778
               AND qr.component = ?
1779
               AND qr.questionarea = ?
1780
               AND qr.usingcontextid = ?";
1781
 
1782
    $questionslots = $DB->get_records_sql($sql, [$quiz->id, 'mod_quiz', 'slot',
1783
            context_module::instance($quiz->cmid)->id]);
1784
 
1785
    $currententry = get_question_bank_entry($questionid);
1786
 
1787
    if (array_key_exists($currententry->id, $questionslots)) {
1788
        $trans->allow_commit();
1789
        return false;
1790
    }
1791
 
1792
    $sql = "SELECT slot.slot, slot.page, slot.id
1793
              FROM {quiz_slots} slot
1794
             WHERE slot.quizid = ?
1795
          ORDER BY slot.slot";
1796
 
1797
    $slots = $DB->get_records_sql($sql, [$quiz->id]);
1798
 
1799
    $maxpage = 1;
1800
    $numonlastpage = 0;
1801
    foreach ($slots as $slot) {
1802
        if ($slot->page > $maxpage) {
1803
            $maxpage = $slot->page;
1804
            $numonlastpage = 1;
1805
        } else {
1806
            $numonlastpage += 1;
1807
        }
1808
    }
1809
 
1810
    // Add the new instance.
1811
    $slot = new stdClass();
1812
    $slot->quizid = $quiz->id;
1813
 
1814
    if ($maxmark !== null) {
1815
        $slot->maxmark = $maxmark;
1816
    } else {
1817
        $slot->maxmark = $DB->get_field('question', 'defaultmark', ['id' => $questionid]);
1818
    }
1819
 
1820
    if (is_int($page) && $page >= 1) {
1821
        // Adding on a given page.
1822
        $lastslotbefore = 0;
1823
        foreach (array_reverse($slots) as $otherslot) {
1824
            if ($otherslot->page > $page) {
1825
                $DB->set_field('quiz_slots', 'slot', $otherslot->slot + 1, ['id' => $otherslot->id]);
1826
            } else {
1827
                $lastslotbefore = $otherslot->slot;
1828
                break;
1829
            }
1830
        }
1831
        $slot->slot = $lastslotbefore + 1;
1832
        $slot->page = min($page, $maxpage + 1);
1833
 
1834
        quiz_update_section_firstslots($quiz->id, 1, max($lastslotbefore, 1));
1835
 
1836
    } else {
1837
        $lastslot = end($slots);
1838
        if ($lastslot) {
1839
            $slot->slot = $lastslot->slot + 1;
1840
        } else {
1841
            $slot->slot = 1;
1842
        }
1843
        if ($quiz->questionsperpage && $numonlastpage >= $quiz->questionsperpage) {
1844
            $slot->page = $maxpage + 1;
1845
        } else {
1846
            $slot->page = $maxpage;
1847
        }
1848
    }
1849
 
1850
    $slotid = $DB->insert_record('quiz_slots', $slot);
1851
 
1852
    // Update or insert record in question_reference table.
1853
    $sql = "SELECT DISTINCT qr.id, qr.itemid
1854
              FROM {question} q
1855
              JOIN {question_versions} qv ON q.id = qv.questionid
1856
              JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
1857
              JOIN {question_references} qr ON qbe.id = qr.questionbankentryid AND qr.version = qv.version
1858
              JOIN {quiz_slots} qs ON qs.id = qr.itemid
1859
             WHERE q.id = ?
1860
               AND qs.id = ?
1861
               AND qr.component = ?
1862
               AND qr.questionarea = ?";
1863
    $qreferenceitem = $DB->get_record_sql($sql, [$questionid, $slotid, 'mod_quiz', 'slot']);
1864
 
1865
    if (!$qreferenceitem) {
1866
        // Create a new reference record for questions created already.
1867
        $questionreferences = new stdClass();
1868
        $questionreferences->usingcontextid = context_module::instance($quiz->cmid)->id;
1869
        $questionreferences->component = 'mod_quiz';
1870
        $questionreferences->questionarea = 'slot';
1871
        $questionreferences->itemid = $slotid;
1872
        $questionreferences->questionbankentryid = get_question_bank_entry($questionid)->id;
1873
        $questionreferences->version = null; // Always latest.
1874
        $DB->insert_record('question_references', $questionreferences);
1875
 
1876
    } else if ($qreferenceitem->itemid === 0 || $qreferenceitem->itemid === null) {
1877
        $questionreferences = new stdClass();
1878
        $questionreferences->id = $qreferenceitem->id;
1879
        $questionreferences->itemid = $slotid;
1880
        $DB->update_record('question_references', $questionreferences);
1881
    } else {
1882
        // If the reference record exits for another quiz.
1883
        $questionreferences = new stdClass();
1884
        $questionreferences->usingcontextid = context_module::instance($quiz->cmid)->id;
1885
        $questionreferences->component = 'mod_quiz';
1886
        $questionreferences->questionarea = 'slot';
1887
        $questionreferences->itemid = $slotid;
1888
        $questionreferences->questionbankentryid = get_question_bank_entry($questionid)->id;
1889
        $questionreferences->version = null; // Always latest.
1890
        $DB->insert_record('question_references', $questionreferences);
1891
    }
1892
 
1893
    $trans->allow_commit();
1894
 
1895
    // Log slot created event.
1896
    $cm = get_coursemodule_from_instance('quiz', $quiz->id);
1897
    $event = \mod_quiz\event\slot_created::create([
1898
        'context' => context_module::instance($cm->id),
1899
        'objectid' => $slotid,
1900
        'other' => [
1901
            'quizid' => $quiz->id,
1902
            'slotnumber' => $slot->slot,
1903
            'page' => $slot->page
1904
        ]
1905
    ]);
1906
    $event->trigger();
1907
}
1908
 
1909
/**
1910
 * Move all the section headings in a certain slot range by a certain offset.
1911
 *
1912
 * @param int $quizid the id of a quiz
1913
 * @param int $direction amount to adjust section heading positions. Normally +1 or -1.
1914
 * @param int $afterslot adjust headings that start after this slot.
1915
 * @param int|null $beforeslot optionally, only adjust headings before this slot.
1916
 */
1917
function quiz_update_section_firstslots($quizid, $direction, $afterslot, $beforeslot = null) {
1918
    global $DB;
1919
    $where = 'quizid = ? AND firstslot > ?';
1920
    $params = [$direction, $quizid, $afterslot];
1921
    if ($beforeslot) {
1922
        $where .= ' AND firstslot < ?';
1923
        $params[] = $beforeslot;
1924
    }
1925
    $firstslotschanges = $DB->get_records_select_menu('quiz_sections',
1926
            $where, $params, '', 'firstslot, firstslot + ?');
1927
    update_field_with_unique_index('quiz_sections', 'firstslot', $firstslotschanges, ['quizid' => $quizid]);
1928
}
1929
 
1930
/**
1931
 * Add a random question to the quiz at a given point.
1932
 * @param stdClass $quiz the quiz settings.
1933
 * @param int $addonpage the page on which to add the question.
1934
 * @param int $categoryid the question category to add the question from.
1935
 * @param int $number the number of random questions to add.
1936
 * @deprecated Since Moodle 4.3 MDL-72321
1937
 * @todo Final deprecation in Moodle 4.7 MDL-78091
1938
 */
1939
function quiz_add_random_questions(stdClass $quiz, int $addonpage, int $categoryid, int $number): void {
1940
    debugging(
1941
        'quiz_add_random_questions is deprecated. Please use mod_quiz\structure::add_random_questions() instead.',
1942
        DEBUG_DEVELOPER
1943
    );
1944
 
1945
    $settings = quiz_settings::create($quiz->id);
1946
    $structure = structure::create_for_quiz($settings);
1947
    $structure->add_random_questions($addonpage, $number, [
1948
        'filter' => [
1949
            'category' => [
1950
                'jointype' => condition::JOINTYPE_DEFAULT,
1951
                'values' => [$categoryid],
1952
                'filteroptions' => ['includesubcategories' => false],
1953
            ],
1954
        ],
1955
    ]);
1956
}
1957
 
1958
/**
1959
 * Mark the activity completed (if required) and trigger the course_module_viewed event.
1960
 *
1961
 * @param  stdClass $quiz       quiz object
1962
 * @param  stdClass $course     course object
1963
 * @param  stdClass $cm         course module object
1964
 * @param  stdClass $context    context object
1965
 * @since Moodle 3.1
1966
 */
1967
function quiz_view($quiz, $course, $cm, $context) {
1968
 
1969
    $params = [
1970
        'objectid' => $quiz->id,
1971
        'context' => $context
1972
    ];
1973
 
1974
    $event = \mod_quiz\event\course_module_viewed::create($params);
1975
    $event->add_record_snapshot('quiz', $quiz);
1976
    $event->trigger();
1977
 
1978
    // Completion.
1979
    $completion = new completion_info($course);
1980
    $completion->set_module_viewed($cm);
1981
}
1982
 
1983
/**
1984
 * Validate permissions for creating a new attempt and start a new preview attempt if required.
1985
 *
1986
 * @param  quiz_settings $quizobj quiz object
1987
 * @param  access_manager $accessmanager quiz access manager
1988
 * @param  bool $forcenew whether was required to start a new preview attempt
1989
 * @param  int $page page to jump to in the attempt
1990
 * @param  bool $redirect whether to redirect or throw exceptions (for web or ws usage)
1991
 * @return array an array containing the attempt information, access error messages and the page to jump to in the attempt
1992
 * @since Moodle 3.1
1993
 */
1994
function quiz_validate_new_attempt(quiz_settings $quizobj, access_manager $accessmanager, $forcenew, $page, $redirect) {
1995
    global $DB, $USER;
1996
    $timenow = time();
1997
 
1998
    if ($quizobj->is_preview_user() && $forcenew) {
1999
        $accessmanager->current_attempt_finished();
2000
    }
2001
 
2002
    // Check capabilities.
2003
    if (!$quizobj->is_preview_user()) {
2004
        $quizobj->require_capability('mod/quiz:attempt');
2005
    }
2006
 
2007
    // Check to see if a new preview was requested.
2008
    if ($quizobj->is_preview_user() && $forcenew) {
2009
        // To force the creation of a new preview, we mark the current attempt (if any)
2010
        // as abandoned. It will then automatically be deleted below.
2011
        $DB->set_field('quiz_attempts', 'state', quiz_attempt::ABANDONED,
2012
                ['quiz' => $quizobj->get_quizid(), 'userid' => $USER->id]);
2013
    }
2014
 
2015
    // Look for an existing attempt.
2016
    $attempts = quiz_get_user_attempts($quizobj->get_quizid(), $USER->id, 'all', true);
2017
    $lastattempt = end($attempts);
2018
 
2019
    $attemptnumber = null;
1441 ariadna 2020
    if (
2021
        $lastattempt
2022
        && in_array($lastattempt->state, [quiz_attempt::NOT_STARTED, quiz_attempt::IN_PROGRESS, quiz_attempt::OVERDUE])
2023
    ) {
2024
        // If an in-progress or not-started attempt exists, check password then redirect to it.
1 efrain 2025
        $currentattemptid = $lastattempt->id;
1441 ariadna 2026
 
1 efrain 2027
        $messages = $accessmanager->prevent_access();
2028
 
2029
        // If the attempt is now overdue, deal with that.
2030
        $quizobj->create_attempt_object($lastattempt)->handle_if_time_expired($timenow, true);
2031
 
2032
        // And, if the attempt is now no longer in progress, redirect to the appropriate place.
2033
        if ($lastattempt->state == quiz_attempt::ABANDONED || $lastattempt->state == quiz_attempt::FINISHED) {
2034
            if ($redirect) {
2035
                redirect($quizobj->review_url($lastattempt->id));
2036
            } else {
2037
                throw new moodle_exception('attemptalreadyclosed', 'quiz', $quizobj->view_url());
2038
            }
2039
        }
2040
 
2041
        // If the page number was not explicitly in the URL, go to the current page.
2042
        if ($page == -1) {
2043
            $page = $lastattempt->currentpage;
2044
        }
2045
 
2046
    } else {
2047
        while ($lastattempt && $lastattempt->preview) {
2048
            $lastattempt = array_pop($attempts);
2049
        }
2050
 
2051
        // Get number for the next or unfinished attempt.
2052
        if ($lastattempt) {
2053
            $attemptnumber = $lastattempt->attempt + 1;
2054
        } else {
2055
            $lastattempt = false;
2056
            $attemptnumber = 1;
2057
        }
2058
        $currentattemptid = null;
2059
 
2060
        $messages = $accessmanager->prevent_access() +
2061
            $accessmanager->prevent_new_attempt(count($attempts), $lastattempt);
2062
 
2063
        if ($page == -1) {
2064
            $page = 0;
2065
        }
2066
    }
2067
    return [$currentattemptid, $attemptnumber, $lastattempt, $messages, $page];
2068
}
2069
 
2070
/**
2071
 * Prepare and start a new attempt deleting the previous preview attempts.
2072
 *
2073
 * @param quiz_settings $quizobj quiz object
2074
 * @param int $attemptnumber the attempt number
2075
 * @param stdClass $lastattempt last attempt object
2076
 * @param bool $offlineattempt whether is an offline attempt or not
2077
 * @param array $forcedrandomquestions slot number => question id. Used for random questions,
2078
 *      to force the choice of a particular actual question. Intended for testing purposes only.
2079
 * @param array $forcedvariants slot number => variant. Used for questions with variants,
2080
 *      to force the choice of a particular variant. Intended for testing purposes only.
2081
 * @param int $userid Specific user id to create an attempt for that user, null for current logged in user
2082
 * @return stdClass the new attempt
2083
 * @since  Moodle 3.1
2084
 */
2085
function quiz_prepare_and_start_new_attempt(quiz_settings $quizobj, $attemptnumber, $lastattempt,
2086
        $offlineattempt = false, $forcedrandomquestions = [], $forcedvariants = [], $userid = null) {
2087
    global $DB, $USER;
2088
 
2089
    if ($userid === null) {
2090
        $userid = $USER->id;
2091
        $ispreviewuser = $quizobj->is_preview_user();
2092
    } else {
2093
        $ispreviewuser = has_capability('mod/quiz:preview', $quizobj->get_context(), $userid);
2094
    }
2095
    // Delete any previous preview attempts belonging to this user.
2096
    quiz_delete_previews($quizobj->get_quiz(), $userid);
2097
 
2098
    $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
2099
    $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
2100
 
1441 ariadna 2101
    $attempt = $DB->get_record(
2102
        'quiz_attempts',
2103
        [
2104
            'quiz' => $quizobj->get_quizid(),
2105
            'userid' => $userid,
2106
            'preview' => 0,
2107
            'state' => quiz_attempt::NOT_STARTED,
2108
        ],
2109
    );
2110
    if (!$attempt) {
2111
        // Create the new attempt and initialize the question sessions.
2112
        $timenow = time(); // Update time now, in case the server is running really slowly.
2113
        $attempt = quiz_create_attempt($quizobj, $attemptnumber, $lastattempt, $timenow, $ispreviewuser, $userid);
1 efrain 2114
 
1441 ariadna 2115
        if (!($quizobj->get_quiz()->attemptonlast && $lastattempt)) {
2116
            $attempt = quiz_start_new_attempt(
2117
                $quizobj,
2118
                $quba,
2119
                $attempt,
2120
                $attemptnumber,
2121
                $timenow,
2122
                $forcedrandomquestions,
2123
                $forcedvariants,
2124
            );
2125
        } else {
2126
            $attempt = quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt);
2127
        }
1 efrain 2128
    }
2129
 
2130
    $transaction = $DB->start_delegated_transaction();
2131
 
2132
    // Init the timemodifiedoffline for offline attempts.
2133
    if ($offlineattempt) {
2134
        $attempt->timemodifiedoffline = $attempt->timemodified;
2135
    }
2136
    $attempt = quiz_attempt_save_started($quizobj, $quba, $attempt);
2137
 
2138
    $transaction->allow_commit();
2139
 
2140
    return $attempt;
2141
}
2142
 
2143
/**
2144
 * Check if the given calendar_event is either a user or group override
2145
 * event for quiz.
2146
 *
2147
 * @param calendar_event $event The calendar event to check
2148
 * @return bool
2149
 */
2150
function quiz_is_overriden_calendar_event(\calendar_event $event) {
2151
    global $DB;
2152
 
2153
    if (!isset($event->modulename)) {
2154
        return false;
2155
    }
2156
 
2157
    if ($event->modulename != 'quiz') {
2158
        return false;
2159
    }
2160
 
2161
    if (!isset($event->instance)) {
2162
        return false;
2163
    }
2164
 
2165
    if (!isset($event->userid) && !isset($event->groupid)) {
2166
        return false;
2167
    }
2168
 
2169
    $overrideparams = [
2170
        'quiz' => $event->instance
2171
    ];
2172
 
2173
    if (isset($event->groupid)) {
2174
        $overrideparams['groupid'] = $event->groupid;
2175
    } else if (isset($event->userid)) {
2176
        $overrideparams['userid'] = $event->userid;
2177
    }
2178
 
2179
    return $DB->record_exists('quiz_overrides', $overrideparams);
2180
}
2181
 
2182
/**
2183
 * Get quiz attempt and handling error.
2184
 *
2185
 * @param int $attemptid the id of the current attempt.
2186
 * @param int|null $cmid the course_module id for this quiz.
2187
 * @return quiz_attempt all the data about the quiz attempt.
2188
 */
2189
function quiz_create_attempt_handling_errors($attemptid, $cmid = null) {
2190
    try {
2191
        $attempobj = quiz_attempt::create($attemptid);
2192
    } catch (moodle_exception $e) {
2193
        if (!empty($cmid)) {
2194
            list($course, $cm) = get_course_and_cm_from_cmid($cmid, 'quiz');
2195
            $continuelink = new moodle_url('/mod/quiz/view.php', ['id' => $cmid]);
2196
            $context = context_module::instance($cm->id);
2197
            if (has_capability('mod/quiz:preview', $context)) {
2198
                throw new moodle_exception('attempterrorcontentchange', 'quiz', $continuelink);
2199
            } else {
2200
                throw new moodle_exception('attempterrorcontentchangeforuser', 'quiz', $continuelink);
2201
            }
2202
        } else {
2203
            throw new moodle_exception('attempterrorinvalid', 'quiz');
2204
        }
2205
    }
2206
    if (!empty($cmid) && $attempobj->get_cmid() != $cmid) {
2207
        throw new moodle_exception('invalidcoursemodule');
2208
    } else {
2209
        return $attempobj;
2210
    }
2211
}