Proyectos de Subversion Moodle

Rev

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