Proyectos de Subversion Moodle

Rev

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

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
// This file is part of Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
 
17
namespace mod_quiz\local;
18
 
19
use mod_quiz\event\group_override_created;
20
use mod_quiz\event\group_override_deleted;
21
use mod_quiz\event\group_override_updated;
22
use mod_quiz\event\user_override_created;
23
use mod_quiz\event\user_override_deleted;
24
use mod_quiz\event\user_override_updated;
11 efrain 25
use mod_quiz\quiz_settings;
1 efrain 26
 
27
/**
28
 * Manager class for quiz overrides
29
 *
30
 * @package   mod_quiz
31
 * @copyright 2024 Matthew Hilton <matthewhilton@catalyst-au.net>
32
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
33
 */
34
class override_manager {
35
    /** @var array quiz setting keys that can be overwritten **/
36
    private const OVERRIDEABLE_QUIZ_SETTINGS = ['timeopen', 'timeclose', 'timelimit', 'attempts', 'password'];
37
 
38
    /**
39
     * Create override manager
40
     *
41
     * @param \stdClass $quiz The quiz to link the manager to.
42
     * @param \context_module $context Context being operated in
43
     */
44
    public function __construct(
45
        /** @var \stdClass The quiz linked to this manager instance **/
46
        protected readonly \stdClass $quiz,
47
        /** @var \context_module The context being operated in **/
48
        public readonly \context_module $context
49
    ) {
50
        global $CFG;
51
        // Required for quiz_* methods.
52
        require_once($CFG->dirroot . '/mod/quiz/locallib.php');
53
 
54
        // Sanity check that the context matches the quiz.
55
        if (empty($quiz->cmid) || $quiz->cmid != $context->instanceid) {
56
            throw new \coding_exception("Given context does not match the quiz object");
57
        }
58
    }
59
 
60
    /**
61
     * Returns all overrides for the linked quiz.
62
     *
63
     * @return array of quiz_override records
64
     */
65
    public function get_all_overrides(): array {
66
        global $DB;
67
        return $DB->get_records('quiz_overrides', ['quiz' => $this->quiz->id]);
68
    }
69
 
70
    /**
71
     * Validates the data, usually from a moodleform or a webservice call.
72
     * If it contains an 'id' property, additional validation is performed against the existing record.
73
     *
74
     * @param array $formdata data from moodleform or webservice call.
75
     * @return array array where the keys are error elements, and the values are lists of errors for each element.
76
     */
77
    public function validate_data(array $formdata): array {
78
        global $DB;
79
 
80
        // Because this can be called directly (e.g. via edit_override_form)
81
        // and not just through save_override, we must ensure the data
82
        // is parsed in the same way.
83
        $formdata = $this->parse_formdata($formdata);
84
 
85
        $formdata = (object) $formdata;
86
 
87
        $errors = [];
88
 
89
        // Ensure at least one of the overrideable settings is set.
90
        $keysthatareset = array_map(function ($key) use ($formdata) {
91
            return isset($formdata->$key) && !is_null($formdata->$key);
92
        }, self::OVERRIDEABLE_QUIZ_SETTINGS);
93
 
94
        if (!in_array(true, $keysthatareset)) {
95
            $errors['general'][] = new \lang_string('nooverridedata', 'quiz');
96
        }
97
 
98
        // Ensure quiz is a valid quiz.
99
        if (empty($formdata->quiz) || empty(get_coursemodule_from_instance('quiz', $formdata->quiz))) {
100
            $errors['quiz'][] = new \lang_string('overrideinvalidquiz', 'quiz');
101
        }
102
 
103
        // Ensure either userid or groupid is set.
104
        if (empty($formdata->userid) && empty($formdata->groupid)) {
105
            $errors['general'][] = new \lang_string('overridemustsetuserorgroup', 'quiz');
106
        }
107
 
108
        // Ensure not both userid and groupid are set.
109
        if (!empty($formdata->userid) && !empty($formdata->groupid)) {
110
            $errors['general'][] = new \lang_string('overridecannotsetbothgroupanduser', 'quiz');
111
        }
112
 
113
        // If group is set, ensure it is a real group.
114
        if (!empty($formdata->groupid) && empty(groups_get_group($formdata->groupid))) {
115
            $errors['groupid'][] = new \lang_string('overrideinvalidgroup', 'quiz');
116
        }
117
 
118
        // If user is set, ensure it is a valid user.
119
        if (!empty($formdata->userid) && !\core_user::is_real_user($formdata->userid, true)) {
120
            $errors['userid'][] = new \lang_string('overrideinvaliduser', 'quiz');
121
        }
122
 
123
        // Ensure timeclose is later than timeopen, if both are set.
124
        if (!empty($formdata->timeclose) && !empty($formdata->timeopen) && $formdata->timeclose <= $formdata->timeopen) {
125
            $errors['timeclose'][] = new \lang_string('closebeforeopen', 'quiz');
126
        }
127
 
128
        // Ensure attempts is a integer greater than or equal to 0 (0 is unlimited attempts).
129
        if (isset($formdata->attempts) && ((int) $formdata->attempts < 0)) {
130
            $errors['attempts'][] = new \lang_string('overrideinvalidattempts', 'quiz');
131
        }
132
 
133
        // Ensure timelimit is greather than zero.
134
        if (!empty($formdata->timelimit) && $formdata->timelimit <= 0) {
135
            $errors['timelimit'][] = new \lang_string('overrideinvalidtimelimit', 'quiz');
136
        }
137
 
138
        // Ensure other records do not exist with the same group or user.
139
        if (!empty($formdata->quiz) && (!empty($formdata->userid) || !empty($formdata->groupid))) {
140
            $existingrecordparams = ['quiz' => $formdata->quiz, 'groupid' => $formdata->groupid ?? null,
141
                'userid' => $formdata->userid ?? null, ];
142
            $records = $DB->get_records('quiz_overrides', $existingrecordparams, '', 'id');
143
 
144
            // Ignore self if updating.
145
            if (!empty($formdata->id)) {
146
                unset($records[$formdata->id]);
147
            }
148
 
149
            // If count is not zero, it means existing records exist already for this user/group.
150
            if (!empty($records)) {
151
                $errors['general'][] = new \lang_string('overridemultiplerecordsexist', 'quiz');
152
            }
153
        }
154
 
155
        // If is existing record, validate it against the existing record.
156
        if (!empty($formdata->id)) {
157
            $existingrecorderrors = self::validate_against_existing_record($formdata->id, $formdata);
158
            $errors = array_merge($errors, $existingrecorderrors);
159
        }
160
 
161
        // Implode each value (array of error strings) into a single error string.
162
        foreach ($errors as $key => $value) {
163
            $errors[$key] = implode(",", $value);
164
        }
165
 
166
        return $errors;
167
    }
168
 
169
    /**
170
     * Returns the existing quiz override record with the given ID or null if does not exist.
171
     *
172
     * @param int $id existing quiz override id
173
     * @return ?\stdClass record, if exists
174
     */
175
    private static function get_existing(int $id): ?\stdClass {
176
        global $DB;
177
        return $DB->get_record('quiz_overrides', ['id' => $id]) ?: null;
178
    }
179
 
180
    /**
181
     * Validates the formdata against an existing record.
182
     *
183
     * @param int $existingid id of existing quiz override record
184
     * @param \stdClass $formdata formdata, usually from moodleform or webservice call.
185
     * @return array array where the keys are error elements, and the values are lists of errors for each element.
186
     */
187
    private static function validate_against_existing_record(int $existingid, \stdClass $formdata): array {
188
        $existingrecord = self::get_existing($existingid);
189
        $errors = [];
190
 
191
        // Existing record must exist.
192
        if (empty($existingrecord)) {
193
            $errors['general'][] = new \lang_string('overrideinvalidexistingid', 'quiz');
194
        }
195
 
196
        // Group value must match existing record if it is set in the formdata.
197
        if (!empty($existingrecord) && !empty($formdata->groupid) && $existingrecord->groupid != $formdata->groupid) {
198
            $errors['groupid'][] = new \lang_string('overridecannotchange', 'quiz');
199
        }
200
 
201
        // User value must match existing record if it is set in the formdata.
202
        if (!empty($existingrecord) && !empty($formdata->userid) && $existingrecord->userid != $formdata->userid) {
203
            $errors['userid'][] = new \lang_string('overridecannotchange', 'quiz');
204
        }
205
 
206
        return $errors;
207
    }
208
 
209
    /**
210
     * Parses the formdata by finding only the OVERRIDEABLE_QUIZ_SETTINGS,
211
     * clearing any values that match the existing quiz, and re-adds the user or group id.
212
     *
213
     * @param array $formdata data usually from moodleform or webservice call.
214
     * @return array array containing parsed formdata, with keys as the properties and values as the values.
215
     * Any values set the same as the existing quiz are set to null.
216
     */
217
    public function parse_formdata(array $formdata): array {
218
        // Get the data from the form that we want to update.
219
        $settings = array_intersect_key($formdata, array_flip(self::OVERRIDEABLE_QUIZ_SETTINGS));
220
 
221
        // Remove values that are the same as currently in the quiz.
222
        $settings = $this->clear_unused_values($settings);
223
 
224
        // Add the user / group back as applicable.
225
        $userorgroupdata = array_intersect_key($formdata, array_flip(['userid', 'groupid', 'quiz', 'id']));
226
 
227
        return array_merge($settings, $userorgroupdata);
228
    }
229
 
230
    /**
231
     * Saves the given override. If an id is given, it updates, otherwise it creates a new one.
232
     * Note, capabilities are not checked, {@see require_manage_capability()}
233
     *
234
     * @param array $formdata data usually from moodleform or webservice call.
235
     * @return int updated/inserted record id
236
     */
237
    public function save_override(array $formdata): int {
238
        global $DB;
239
 
240
        // Extract only the necessary data.
241
        $datatoset = $this->parse_formdata($formdata);
242
        $datatoset['quiz'] = $this->quiz->id;
243
 
244
        // Validate the data is OK.
245
        $errors = $this->validate_data($datatoset);
246
        if (!empty($errors)) {
247
            $errorstr = implode(',', $errors);
248
            throw new \invalid_parameter_exception($errorstr);
249
        }
250
 
251
        // Insert or update.
252
        $id = $datatoset['id'] ?? 0;
253
        if (!empty($id)) {
254
            $DB->update_record('quiz_overrides', $datatoset);
255
        } else {
256
            $id = $DB->insert_record('quiz_overrides', $datatoset);
257
        }
258
 
259
        $userid = $datatoset['userid'] ?? null;
260
        $groupid = $datatoset['groupid'] ?? null;
261
 
262
        // Clear the cache.
263
        $cache = new override_cache($this->quiz->id);
264
        $cache->clear_for($userid, $groupid);
265
 
266
        // Trigger moodle events.
267
        if (empty($formdata['id'])) {
268
            $this->fire_created_event($id, $userid, $groupid);
269
        } else {
270
            $this->fire_updated_event($id, $userid, $groupid);
271
        }
272
 
273
        // Update open events.
274
        quiz_update_open_attempts(['quizid' => $this->quiz->id]);
275
 
276
        // Update calendar events.
277
        $isgroup = !empty($datatoset['groupid']);
278
        if ($isgroup) {
279
            // If is group, must update the entire quiz calendar events.
280
            quiz_update_events($this->quiz);
281
        } else {
282
            // If is just a user, can update only their calendar event.
283
            quiz_update_events($this->quiz, (object) $datatoset);
284
        }
285
 
286
        return $id;
287
    }
288
 
289
    /**
290
     * Deletes all the overrides for the linked quiz
291
     *
292
     * @param bool $shouldlog If true, will log a override_deleted event
293
     */
294
    public function delete_all_overrides(bool $shouldlog = true): void {
295
        global $DB;
296
        $overrides = $DB->get_records('quiz_overrides', ['quiz' => $this->quiz->id], '', 'id,userid,groupid');
297
        $this->delete_overrides($overrides, $shouldlog);
298
    }
299
 
300
    /**
301
     * Deletes overrides given just their ID.
11 efrain 302
     * Note, the given IDs must exist and user must have access to them otherwise an exception will be thrown.
1 efrain 303
     * Also note, capabilities are not checked, {@see require_manage_capability()}
304
     *
305
     * @param array $ids IDs of overrides to delete
306
     * @param bool $shouldlog If true, will log a override_deleted event
307
     */
308
    public function delete_overrides_by_id(array $ids, bool $shouldlog = true): void {
309
        global $DB;
11 efrain 310
 
311
        $quizsettings = quiz_settings::create($this->quiz->id);
312
 
313
        // Filter for those overrides user can access.
1 efrain 314
        [$sql, $params] = self::get_override_in_sql($this->quiz->id, $ids);
11 efrain 315
        $records = array_filter(
316
            $DB->get_records_select('quiz_overrides', $sql, $params, '', 'id,userid,groupid'),
317
            fn(\stdClass $override) => $this->can_view_override(
318
                $override,
319
                $quizsettings->get_course(),
320
                $quizsettings->get_cm(),
321
            ),
322
        );
1 efrain 323
 
324
        // Ensure all the given ids exist, so the user is aware if they give a dodgy id.
325
        $missingids = array_diff($ids, array_keys($records));
326
        if (!empty($missingids)) {
327
            throw new \invalid_parameter_exception(get_string('overridemissingdelete', 'quiz', implode(',', $missingids)));
328
        }
329
 
330
        $this->delete_overrides($records, $shouldlog);
331
    }
332
 
333
 
334
    /**
335
     * Builds sql and parameters to find overrides in quiz with the given ids
336
     *
337
     * @param int $quizid id of quiz
338
     * @param array $ids array of quiz override ids
339
     * @return array sql and params
340
     */
341
    private static function get_override_in_sql(int $quizid, array $ids): array {
342
        global $DB;
343
 
344
        [$insql, $inparams] = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED);
345
        $params = array_merge($inparams, ['quizid' => $quizid]);
346
        $sql = 'id ' . $insql . ' AND quiz = :quizid';
347
        return [$sql, $params];
348
    }
349
 
350
    /**
351
     * Deletes the given overrides in the quiz linked to the override manager.
352
     * Note - capabilities are not checked, {@see require_manage_capability()}
353
     *
354
     * @param array $overrides override to delete. Must specify an id, quizid, and either a userid or groupid.
355
     * @param bool $shouldlog If true, will log a override_deleted event
356
     */
357
    public function delete_overrides(array $overrides, bool $shouldlog = true): void {
358
        global $DB;
359
 
360
        foreach ($overrides as $override) {
361
            if (empty($override->id)) {
362
                throw new \coding_exception("All overrides must specify an ID");
363
            }
364
 
365
            // Sanity check that user xor group is specified.
366
            // User or group is required to clear the cache.
367
            self::ensure_userid_xor_groupid_set($override->userid ?? null, $override->groupid ?? null);
368
        }
369
 
370
        if (empty($overrides)) {
371
            // Exit early, since delete select requires at least 1 record.
372
            return;
373
        }
374
 
375
        // Match id and quiz.
376
        [$sql, $params] = self::get_override_in_sql($this->quiz->id, array_column($overrides, 'id'));
377
        $DB->delete_records_select('quiz_overrides', $sql, $params);
378
 
379
        $cache = new override_cache($this->quiz->id);
380
 
381
        // Perform other cleanup.
382
        foreach ($overrides as $override) {
383
            $userid = $override->userid ?? null;
384
            $groupid = $override->groupid ?? null;
385
 
386
            $cache->clear_for($userid, $groupid);
387
            $this->delete_override_events($userid, $groupid);
388
 
389
            if ($shouldlog) {
390
                $this->fire_deleted_event($override->id, $userid, $groupid);
391
            }
392
        }
393
    }
394
 
395
    /**
396
     * Ensures either userid or groupid is set, but not both.
397
     * If neither or both are set, a coding exception is thrown.
398
     *
399
     * @param ?int $userid user for the record, or null
400
     * @param ?int $groupid group for the record, or null
401
     */
402
    private static function ensure_userid_xor_groupid_set(?int $userid = null, ?int $groupid = null): void {
403
        $groupset = !empty($groupid);
404
        $userset = !empty($userid);
405
 
406
        // If either set, but not both (xor).
407
        $xorset = $groupset ^ $userset;
408
 
409
        if (!$xorset) {
410
            throw new \coding_exception("Either userid or groupid must be specified, but not both.");
411
        }
412
    }
413
 
414
    /**
415
     * Deletes the events associated with the override.
416
     *
417
     * @param ?int $userid or null if groupid is specified
418
     * @param ?int $groupid or null if the userid is specified
419
     */
420
    private function delete_override_events(?int $userid = null, ?int $groupid = null): void {
421
        global $DB;
422
 
423
        // Sanity check.
424
        self::ensure_userid_xor_groupid_set($userid, $groupid);
425
 
426
        $eventssearchparams = ['modulename' => 'quiz', 'instance' => $this->quiz->id];
427
 
428
        if (!empty($userid)) {
429
            $eventssearchparams['userid'] = $userid;
430
        }
431
 
432
        if (!empty($groupid)) {
433
            $eventssearchparams['groupid'] = $groupid;
434
        }
435
 
436
        $events = $DB->get_records('event', $eventssearchparams);
437
        foreach ($events as $event) {
438
            $eventold = \calendar_event::load($event);
439
            $eventold->delete();
440
        }
441
    }
442
 
443
    /**
444
     * Requires the user has the override management capability
445
     */
446
    public function require_manage_capability(): void {
447
        require_capability('mod/quiz:manageoverrides', $this->context);
448
    }
449
 
450
    /**
451
     * Requires the user has the override viewing capability
452
     */
453
    public function require_read_capability(): void {
454
        // If user can manage, they can also view.
455
        // It would not make sense to be able to create and edit overrides without being able to view them.
456
        if (!has_any_capability(['mod/quiz:viewoverrides', 'mod/quiz:manageoverrides'], $this->context)) {
457
            throw new \required_capability_exception($this->context, 'mod/quiz:viewoverrides', 'nopermissions', '');
458
        }
459
    }
460
 
461
    /**
11 efrain 462
     * Determine whether user can view a given override record
463
     *
464
     * @param \stdClass $override
465
     * @param \stdClass $course
466
     * @param \cm_info $cm
467
     * @return bool
468
     */
469
    public function can_view_override(\stdClass $override, \stdClass $course, \cm_info $cm): bool {
470
        if ($override->groupid) {
471
            return groups_group_visible($override->groupid, $course, $cm);
472
        } else {
473
            return groups_user_groups_visible($course, $override->userid, $cm);
474
        }
475
    }
476
 
477
    /**
1 efrain 478
     * Builds common event data
479
     *
480
     * @param int $id override id
481
     * @return array of data to add as parameters to an event.
482
     */
483
    private function get_base_event_params(int $id): array {
484
        return [
485
            'context' => $this->context,
486
            'other' => [
487
                'quizid' => $this->quiz->id,
488
            ],
489
            'objectid' => $id,
490
        ];
491
    }
492
 
493
    /**
494
     * Log that a given override was deleted
495
     *
496
     * @param int $id of quiz override that was just deleted
497
     * @param ?int $userid user attached to override record, or null
498
     * @param ?int $groupid group attached to override record, or null
499
     */
500
    private function fire_deleted_event(int $id, ?int $userid = null, ?int $groupid = null): void {
501
        // Sanity check.
502
        self::ensure_userid_xor_groupid_set($userid, $groupid);
503
 
504
        $params = $this->get_base_event_params($id);
505
        $params['objectid'] = $id;
506
 
507
        if (!empty($userid)) {
508
            $params['relateduserid'] = $userid;
509
            user_override_deleted::create($params)->trigger();
510
        }
511
 
512
        if (!empty($groupid)) {
513
            $params['other']['groupid'] = $groupid;
514
            group_override_deleted::create($params)->trigger();
515
        }
516
    }
517
 
518
 
519
    /**
520
     * Log that a given override was created
521
     *
522
     * @param int $id of quiz override that was just created
523
     * @param ?int $userid user attached to override record, or null
524
     * @param ?int $groupid group attached to override record, or null
525
     */
526
    private function fire_created_event(int $id, ?int $userid = null, ?int $groupid = null): void {
527
        // Sanity check.
528
        self::ensure_userid_xor_groupid_set($userid, $groupid);
529
 
530
        $params = $this->get_base_event_params($id);
531
 
532
        if (!empty($userid)) {
533
            $params['relateduserid'] = $userid;
534
            user_override_created::create($params)->trigger();
535
        }
536
 
537
        if (!empty($groupid)) {
538
            $params['other']['groupid'] = $groupid;
539
            group_override_created::create($params)->trigger();
540
        }
541
    }
542
 
543
    /**
544
     * Log that a given override was updated
545
     *
546
     * @param int $id of quiz override that was just updated
547
     * @param ?int $userid user attached to override record, or null
548
     * @param ?int $groupid group attached to override record, or null
549
     */
550
    private function fire_updated_event(int $id, ?int $userid = null, ?int $groupid = null): void {
551
        // Sanity check.
552
        self::ensure_userid_xor_groupid_set($userid, $groupid);
553
 
554
        $params = $this->get_base_event_params($id);
555
 
556
        if (!empty($userid)) {
557
            $params['relateduserid'] = $userid;
558
            user_override_updated::create($params)->trigger();
559
        }
560
 
561
        if (!empty($groupid)) {
562
            $params['other']['groupid'] = $groupid;
563
            group_override_updated::create($params)->trigger();
564
        }
565
    }
566
 
567
    /**
568
     * Clears any overrideable settings in the formdata, where the value matches what is already in the quiz
569
     * If they match, the data is set to null.
570
     *
571
     * @param array $formdata data usually from moodleform or webservice call.
572
     * @return array formdata with same values cleared
573
     */
574
    private function clear_unused_values(array $formdata): array {
575
        foreach (self::OVERRIDEABLE_QUIZ_SETTINGS as $key) {
576
            // If the formdata is the same as the current quiz object data, clear it.
577
            if (isset($formdata[$key]) && $formdata[$key] == $this->quiz->$key) {
578
                $formdata[$key] = null;
579
            }
580
 
581
            // Ensure these keys always are set (even if null).
582
            $formdata[$key] = $formdata[$key] ?? null;
583
 
584
            // If the formdata is empty, set it to null.
585
            // This avoids putting 0, false, or '' into the DB since the override logic expects null.
586
            // Attempts is the exception, it can have a integer value of '0', so we use is_numeric instead.
587
            if ($key != 'attempts' && empty($formdata[$key])) {
588
                $formdata[$key] = null;
589
            }
590
 
591
            if ($key == 'attempts' && !is_numeric($formdata[$key])) {
592
                $formdata[$key] = null;
593
            }
594
        }
595
 
596
        return $formdata;
597
    }
598
 
599
    /**
600
     * Deletes orphaned group overrides in a given course.
601
     * Note - permissions are not checked and events are not logged for performance reasons.
602
     *
603
     * @param int $courseid ID of course to delete orphaned group overrides in
604
     * @return array array of quizzes that had orphaned group overrides.
605
     */
606
    public static function delete_orphaned_group_overrides_in_course(int $courseid): array {
607
        global $DB;
608
 
609
        // It would be nice if we got the groupid that was deleted.
610
        // Instead, we just update all quizzes with orphaned group overrides.
611
        $sql = "SELECT o.id, o.quiz, o.groupid
612
                  FROM {quiz_overrides} o
613
                  JOIN {quiz} quiz ON quiz.id = o.quiz
614
             LEFT JOIN {groups} grp ON grp.id = o.groupid
615
                 WHERE quiz.course = :courseid
616
                   AND o.groupid IS NOT NULL
617
                   AND grp.id IS NULL";
618
        $params = ['courseid' => $courseid];
619
        $records = $DB->get_records_sql($sql, $params);
620
 
621
        $DB->delete_records_list('quiz_overrides', 'id', array_keys($records));
622
 
623
        // Purge cache for each record.
624
        foreach ($records as $record) {
625
            $cache = new override_cache($record->quiz);
626
            $cache->clear_for_group($record->groupid);
627
        }
628
        return array_unique(array_column($records, 'quiz'));
629
    }
630
}