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
 * Expired contexts manager.
19
 *
20
 * @package    tool_dataprivacy
21
 * @copyright  2018 David Monllao
22
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
namespace tool_dataprivacy;
25
 
26
use core_privacy\manager;
27
use tool_dataprivacy\expired_context;
28
 
29
defined('MOODLE_INTERNAL') || die();
30
 
31
/**
32
 * Expired contexts manager.
33
 *
34
 * @copyright  2018 David Monllao
35
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
36
 */
37
class expired_contexts_manager {
38
 
39
    /**
40
     * Number of deleted contexts for each scheduled task run.
41
     */
42
    const DELETE_LIMIT = 200;
43
 
44
    /** @var progress_trace The log progress tracer */
45
    protected $progresstracer = null;
46
 
47
    /** @var manager The privacy manager */
48
    protected $manager = null;
49
 
50
    /** @var \progress_trace Trace tool for logging */
51
    protected $trace = null;
52
 
53
    /**
54
     * Constructor for the expired_contexts_manager.
55
     *
56
     * @param   \progress_trace $trace
57
     */
1441 ariadna 58
    public function __construct(?\progress_trace $trace = null) {
1 efrain 59
        if (null === $trace) {
60
            $trace = new \null_progress_trace();
61
        }
62
 
63
        $this->trace = $trace;
64
    }
65
 
66
    /**
67
     * Flag expired contexts as expired.
68
     *
69
     * @return  int[]   The number of contexts flagged as expired for courses, and users.
70
     */
71
    public function flag_expired_contexts(): array {
72
        $this->trace->output('Checking requirements');
73
        if (!$this->check_requirements()) {
74
            $this->trace->output('Requirements not met. Cannot process expired retentions.', 1);
75
            return [0, 0];
76
        }
77
 
78
        // Clear old and stale records first.
79
        $this->trace->output('Clearing obselete records.', 0);
80
        static::clear_old_records();
81
        $this->trace->output('Done.', 1);
82
 
83
        $this->trace->output('Calculating potential course expiries.', 0);
84
        $data = static::get_nested_expiry_info_for_courses();
85
 
86
        $coursecount = 0;
87
        $this->trace->output('Updating course expiry data.', 0);
88
        foreach ($data as $expiryrecord) {
89
            if ($this->update_from_expiry_info($expiryrecord)) {
90
                $coursecount++;
91
            }
92
        }
93
        $this->trace->output('Done.', 1);
94
 
95
        $this->trace->output('Calculating potential user expiries.', 0);
96
        $data = static::get_nested_expiry_info_for_user();
97
 
98
        $usercount = 0;
99
        $this->trace->output('Updating user expiry data.', 0);
100
        foreach ($data as $expiryrecord) {
101
            if ($this->update_from_expiry_info($expiryrecord)) {
102
                $usercount++;
103
            }
104
        }
105
        $this->trace->output('Done.', 1);
106
 
107
        return [$coursecount, $usercount];
108
    }
109
 
110
    /**
111
     * Clear old and stale records.
112
     */
113
    protected static function clear_old_records() {
114
        global $DB;
115
 
116
        $sql = "SELECT dpctx.*
117
                  FROM {tool_dataprivacy_ctxexpired} dpctx
118
             LEFT JOIN {context} ctx ON ctx.id = dpctx.contextid
119
                 WHERE ctx.id IS NULL";
120
 
121
        $orphaned = $DB->get_recordset_sql($sql);
122
        foreach ($orphaned as $orphan) {
123
            $expiredcontext = new expired_context(0, $orphan);
124
            $expiredcontext->delete();
125
        }
1441 ariadna 126
        $orphaned->close();
1 efrain 127
 
128
        // Delete any child of a user context.
129
        $parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
130
        $params = [
131
            'contextuser' => CONTEXT_USER,
132
        ];
133
 
134
        $sql = "SELECT dpctx.*
135
                  FROM {tool_dataprivacy_ctxexpired} dpctx
136
                 WHERE dpctx.contextid IN (
137
                    SELECT ctx.id
138
                        FROM {context} ctxuser
139
                        JOIN {context} ctx ON ctx.path LIKE {$parentpath}
140
                       WHERE ctxuser.contextlevel = :contextuser
141
                    )";
142
        $userchildren = $DB->get_recordset_sql($sql, $params);
143
        foreach ($userchildren as $child) {
144
            $expiredcontext = new expired_context(0, $child);
145
            $expiredcontext->delete();
146
        }
1441 ariadna 147
        $userchildren->close();
1 efrain 148
    }
149
 
150
    /**
151
     * Get the full nested set of expiry data relating to all contexts.
152
     *
153
     * @param   string      $contextpath A contexpath to restrict results to
154
     * @return  \stdClass[]
155
     */
156
    protected static function get_nested_expiry_info($contextpath = ''): array {
157
        $coursepaths = self::get_nested_expiry_info_for_courses($contextpath);
158
        $userpaths = self::get_nested_expiry_info_for_user($contextpath);
159
 
160
        return array_merge($coursepaths, $userpaths);
161
    }
162
 
163
    /**
164
     * Get the full nested set of expiry data relating to course-related contexts.
165
     *
166
     * @param   string      $contextpath A contexpath to restrict results to
167
     * @return  \stdClass[]
168
     */
169
    protected static function get_nested_expiry_info_for_courses($contextpath = ''): array {
170
        global $DB;
171
 
172
        $contextfields = \context_helper::get_preload_record_columns_sql('ctx');
173
        $expiredfields = expired_context::get_sql_fields('expiredctx', 'expiredctx');
174
        $purposefields = 'dpctx.purposeid';
175
        $coursefields = 'ctxcourse.expirydate AS expirydate';
176
        $fields = implode(', ', ['ctx.id', $contextfields, $expiredfields, $coursefields, $purposefields]);
177
 
178
        // We want all contexts at course-dependant levels.
179
        $parentpath = $DB->sql_concat('ctxcourse.path', "'/%'");
180
 
181
        // This SQL query returns all course-dependant contexts (including the course context)
182
        // which course end date already passed.
183
        // This is ordered by the context path in reverse order, which will give the child nodes before any parent node.
184
        $params = [
185
            'contextlevel' => CONTEXT_COURSE,
186
        ];
187
        $where = '';
188
 
189
        if (!empty($contextpath)) {
190
            $where = "WHERE (ctx.path = :pathmatchexact OR ctx.path LIKE :pathmatchchildren)";
191
            $params['pathmatchexact'] = $contextpath;
192
            $params['pathmatchchildren'] = "{$contextpath}/%";
193
        }
194
 
195
        $sql = "SELECT $fields
196
                  FROM {context} ctx
197
                  JOIN (
198
                        SELECT c.enddate AS expirydate, subctx.path
199
                          FROM {context} subctx
200
                          JOIN {course} c
201
                            ON subctx.contextlevel = :contextlevel
202
                           AND subctx.instanceid = c.id
203
                           AND c.format != 'site'
204
                       ) ctxcourse
205
                    ON ctx.path LIKE {$parentpath} OR ctx.path = ctxcourse.path
206
             LEFT JOIN {tool_dataprivacy_ctxinstance} dpctx
207
                    ON dpctx.contextid = ctx.id
208
             LEFT JOIN {tool_dataprivacy_ctxexpired} expiredctx
209
                    ON ctx.id = expiredctx.contextid
210
                 {$where}
211
              ORDER BY ctx.path DESC";
212
 
213
        return self::get_nested_expiry_info_from_sql($sql, $params);
214
    }
215
 
216
    /**
217
     * Get the full nested set of expiry data.
218
     *
219
     * @param   string      $contextpath A contexpath to restrict results to
220
     * @return  \stdClass[]
221
     */
222
    protected static function get_nested_expiry_info_for_user($contextpath = ''): array {
223
        global $DB;
224
 
225
        $contextfields = \context_helper::get_preload_record_columns_sql('ctx');
226
        $expiredfields = expired_context::get_sql_fields('expiredctx', 'expiredctx');
227
        $purposefields = 'dpctx.purposeid';
228
        $userfields = 'u.lastaccess AS expirydate';
229
        $fields = implode(', ', ['ctx.id', $contextfields, $expiredfields, $userfields, $purposefields]);
230
 
231
        // We want all contexts at user-dependant levels.
232
        $parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
233
 
234
        // This SQL query returns all user-dependant contexts (including the user context)
235
        // This is ordered by the context path in reverse order, which will give the child nodes before any parent node.
236
        $params = [
237
            'contextlevel' => CONTEXT_USER,
238
        ];
239
        $where = '';
240
 
241
        if (!empty($contextpath)) {
242
            $where = "AND ctx.path = :pathmatchexact";
243
            $params['pathmatchexact'] = $contextpath;
244
        }
245
 
246
        $sql = "SELECT $fields, u.deleted AS userdeleted
247
                  FROM {context} ctx
248
                  JOIN {user} u ON ctx.instanceid = u.id
249
             LEFT JOIN {tool_dataprivacy_ctxinstance} dpctx
250
                    ON dpctx.contextid = ctx.id
251
             LEFT JOIN {tool_dataprivacy_ctxexpired} expiredctx
252
                    ON ctx.id = expiredctx.contextid
253
                 WHERE ctx.contextlevel = :contextlevel {$where}
254
              ORDER BY ctx.path DESC";
255
 
256
        return self::get_nested_expiry_info_from_sql($sql, $params);
257
    }
258
 
259
    /**
260
     * Get the full nested set of expiry data given appropriate SQL.
261
     * Only contexts which have expired will be included.
262
     *
263
     * @param   string      $sql The SQL used to select the nested information.
264
     * @param   array       $params The params required by the SQL.
265
     * @return  \stdClass[]
266
     */
267
    protected static function get_nested_expiry_info_from_sql(string $sql, array $params): array {
268
        global $DB;
269
 
270
        $fulllist = $DB->get_recordset_sql($sql, $params);
271
        $datalist = [];
272
        $expiredcontents = [];
273
        $pathstoskip = [];
274
 
275
        $userpurpose = data_registry::get_effective_contextlevel_value(CONTEXT_USER, 'purpose');
276
        foreach ($fulllist as $record) {
277
            \context_helper::preload_from_record($record);
278
            $context = \context::instance_by_id($record->id, false);
279
 
280
            if (!self::is_eligible_for_deletion($pathstoskip, $context)) {
281
                // We should skip this context, and therefore all of it's children.
282
                $datalist = array_filter($datalist, function($data, $path) use ($context) {
283
                    // Remove any child of this context.
284
                    // Technically this should never be fulfilled because the query is ordered in path DESC, but is kept
285
                    // in to be certain.
286
                    return (false === strpos($path, "{$context->path}/"));
287
                }, ARRAY_FILTER_USE_BOTH);
288
 
289
                if ($record->expiredctxid) {
290
                    // There was previously an expired context record.
291
                    // Delete it to be on the safe side.
292
                    $expiredcontext = new expired_context(null, expired_context::extract_record($record, 'expiredctx'));
293
                    $expiredcontext->delete();
294
                }
295
                continue;
296
            }
297
 
298
            if ($context instanceof \context_user) {
299
                $purpose = $userpurpose;
300
            } else {
301
                $purposevalue = $record->purposeid !== null ? $record->purposeid : context_instance::NOTSET;
302
                $purpose = api::get_effective_context_purpose($context, $purposevalue);
303
            }
304
 
305
            if ($context instanceof \context_user && !empty($record->userdeleted)) {
306
                $expiryinfo = static::get_expiry_info($purpose, $record->userdeleted);
307
            } else {
308
                $expiryinfo = static::get_expiry_info($purpose, $record->expirydate);
309
            }
310
 
311
            foreach ($datalist as $path => $data) {
312
                // Merge with already-processed children.
313
                if (strpos($path, $context->path) !== 0) {
314
                    continue;
315
                }
316
 
317
                $expiryinfo->merge_with_child($data->info);
318
            }
319
 
320
            $datalist[$context->path] = (object) [
321
                'context' => $context,
322
                'record' => $record,
323
                'purpose' => $purpose,
324
                'info' => $expiryinfo,
325
            ];
326
        }
327
        $fulllist->close();
328
 
329
        return $datalist;
330
    }
331
 
332
    /**
333
     * Check whether the supplied context would be elible for deletion.
334
     *
335
     * @param   array       $pathstoskip A set of paths which should be skipped
336
     * @param   \context    $context
337
     * @return  bool
338
     */
339
    protected static function is_eligible_for_deletion(array &$pathstoskip, \context $context): bool {
340
        $shouldskip = false;
341
        // Check whether any of the child contexts are ineligble.
342
        $shouldskip = !empty(array_filter($pathstoskip, function($path) use ($context) {
343
            // If any child context has already been skipped then it will appear in this list.
344
            // Since paths include parents, test if the context under test appears as the haystack in the skipped
345
            // context's needle.
346
            return false !== (strpos($context->path, $path));
347
        }));
348
 
349
        if (!$shouldskip && $context instanceof \context_user) {
350
            $shouldskip = !self::are_user_context_dependencies_expired($context);
351
        }
352
 
353
        if ($shouldskip) {
354
            // Add this to the list of contexts to skip for parentage checks.
355
            $pathstoskip[] = $context->path;
356
        }
357
 
358
        return !$shouldskip;
359
    }
360
 
361
    /**
362
     * Deletes the expired contexts.
363
     *
364
     * @return  int[]       The number of deleted contexts.
365
     */
366
    public function process_approved_deletions(): array {
367
        $this->trace->output('Checking requirements');
368
        if (!$this->check_requirements()) {
369
            $this->trace->output('Requirements not met. Cannot process expired retentions.', 1);
370
            return [0, 0];
371
        }
372
 
373
        $this->trace->output('Fetching all approved and expired contexts for deletion.');
374
        $expiredcontexts = expired_context::get_records(['status' => expired_context::STATUS_APPROVED]);
375
        $this->trace->output('Done.', 1);
376
        $totalprocessed = 0;
377
        $usercount = 0;
378
        $coursecount = 0;
379
        foreach ($expiredcontexts as $expiredctx) {
380
            $context = \context::instance_by_id($expiredctx->get('contextid'), IGNORE_MISSING);
381
 
382
            if (empty($context)) {
383
                // Unable to process this request further.
384
                // We have no context to delete.
385
                $expiredctx->delete();
386
                continue;
387
            }
388
 
389
            $this->trace->output("Deleting data for " . $context->get_context_name(), 2);
390
            if ($this->delete_expired_context($expiredctx)) {
391
                $this->trace->output("Done.", 3);
392
                if ($context instanceof \context_user) {
393
                    $usercount++;
394
                } else {
395
                    $coursecount++;
396
                }
397
 
398
                $totalprocessed++;
399
                if ($totalprocessed >= $this->get_delete_limit()) {
400
                    break;
401
                }
402
            }
403
        }
404
 
405
        return [$coursecount, $usercount];
406
    }
407
 
408
    /**
409
     * Deletes user data from the provided context.
410
     *
411
     * @param expired_context $expiredctx
412
     * @return \context|false
413
     */
414
    protected function delete_expired_context(expired_context $expiredctx) {
415
        $context = \context::instance_by_id($expiredctx->get('contextid'));
416
 
417
        $this->get_progress()->output("Deleting context {$context->id} - " . $context->get_context_name(true, true));
418
 
419
        // Update the expired_context and verify that it is still ready for deletion.
420
        $expiredctx = $this->update_expired_context($expiredctx);
421
        if (empty($expiredctx)) {
422
            $this->get_progress()->output("Context has changed since approval and is no longer pending approval. Skipping", 1);
423
            return false;
424
        }
425
 
426
        if (!$expiredctx->can_process_deletion()) {
427
            // This only happens if the record was updated after being first fetched.
428
            $this->get_progress()->output("Context has changed since approval and must be re-approved. Skipping", 1);
429
            $expiredctx->set('status', expired_context::STATUS_EXPIRED);
430
            $expiredctx->save();
431
 
432
            return false;
433
        }
434
 
435
        $privacymanager = $this->get_privacy_manager();
436
        if ($expiredctx->is_fully_expired()) {
437
            if ($context instanceof \context_user) {
438
                $this->delete_expired_user_context($expiredctx);
439
            } else {
440
                // This context is fully expired - that is that the default retention period has been reached, and there are
441
                // no remaining overrides.
442
                $privacymanager->delete_data_for_all_users_in_context($context);
443
            }
444
 
445
            // Mark the record as cleaned.
446
            $expiredctx->set('status', expired_context::STATUS_CLEANED);
447
            $expiredctx->save();
448
 
449
            return $context;
450
        }
451
 
452
        // We need to find all users in the context, and delete just those who have expired.
453
        $collection = $privacymanager->get_users_in_context($context);
454
 
455
        // Apply the expired and unexpired filters to remove the users in these categories.
456
        $userassignments = $this->get_role_users_for_expired_context($expiredctx, $context);
457
        $approvedcollection = new \core_privacy\local\request\userlist_collection($context);
458
        foreach ($collection as $pendinguserlist) {
459
            $userlist = filtered_userlist::create_from_userlist($pendinguserlist);
460
            $userlist->apply_expired_context_filters($userassignments->expired, $userassignments->unexpired);
461
            if (count($userlist)) {
462
                $approvedcollection->add_userlist($userlist);
463
            }
464
        }
465
 
466
        if (count($approvedcollection)) {
467
            // Perform the deletion with the newly approved collection.
468
            $privacymanager->delete_data_for_users_in_context($approvedcollection);
469
        }
470
 
471
        // Mark the record as cleaned.
472
        $expiredctx->set('status', expired_context::STATUS_CLEANED);
473
        $expiredctx->save();
474
 
475
        return $context;
476
    }
477
 
478
    /**
479
     * Deletes user data from the provided user context.
480
     *
481
     * @param expired_context $expiredctx
482
     */
483
    protected function delete_expired_user_context(expired_context $expiredctx) {
484
        global $DB;
485
 
486
        $contextid = $expiredctx->get('contextid');
487
        $context = \context::instance_by_id($contextid);
488
        $user = \core_user::get_user($context->instanceid, '*', MUST_EXIST);
489
 
490
        $privacymanager = $this->get_privacy_manager();
491
 
492
        // Delete all child contexts of the user context.
493
        $parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
494
 
495
        $params = [
496
            'contextlevel'  => CONTEXT_USER,
497
            'contextid'     => $expiredctx->get('contextid'),
498
        ];
499
 
500
        $fields = \context_helper::get_preload_record_columns_sql('ctx');
501
        $sql = "SELECT ctx.id, $fields
502
                  FROM {context} ctxuser
503
                  JOIN {context} ctx ON ctx.path LIKE {$parentpath}
504
                 WHERE ctxuser.contextlevel = :contextlevel AND ctxuser.id = :contextid
505
              ORDER BY ctx.path DESC";
506
 
507
        $children = $DB->get_recordset_sql($sql, $params);
508
        foreach ($children as $child) {
509
            \context_helper::preload_from_record($child);
510
            $context = \context::instance_by_id($child->id);
511
 
512
            $privacymanager->delete_data_for_all_users_in_context($context);
513
        }
514
        $children->close();
515
 
516
        // Delete all unprotected data that the user holds.
517
        $approvedlistcollection = new \core_privacy\local\request\contextlist_collection($user->id);
518
        $contextlistcollection = $privacymanager->get_contexts_for_userid($user->id);
519
 
520
        foreach ($contextlistcollection as $contextlist) {
521
            $contextids = [];
522
            $approvedlistcollection->add_contextlist(new \core_privacy\local\request\approved_contextlist(
523
                    $user,
524
                    $contextlist->get_component(),
525
                    $contextlist->get_contextids()
526
                ));
527
        }
528
        $privacymanager->delete_data_for_user($approvedlistcollection, $this->get_progress());
529
 
530
        // Delete the user context.
531
        $context = \context::instance_by_id($expiredctx->get('contextid'));
532
        $privacymanager->delete_data_for_all_users_in_context($context);
533
 
534
        // This user is now fully expired - finish by deleting the user.
535
        delete_user($user);
536
    }
537
 
538
    /**
539
     * Whether end dates are required on all courses in order for a user to be expired from them.
540
     *
541
     * @return bool
542
     */
543
    protected static function require_all_end_dates_for_user_deletion(): bool {
544
        $requireenddate = get_config('tool_dataprivacy', 'requireallenddatesforuserdeletion');
545
 
546
        return !empty($requireenddate);
547
    }
548
 
549
    /**
550
     * Check that the requirements to start deleting contexts are satisified.
551
     *
552
     * @return bool
553
     */
554
    protected function check_requirements() {
555
        if (!data_registry::defaults_set()) {
556
            return false;
557
        }
558
        return true;
559
    }
560
 
561
    /**
562
     * Check whether a date is beyond the specified period.
563
     *
564
     * @param   string      $period The Expiry Period
565
     * @param   int         $comparisondate The date for comparison
566
     * @return  bool
567
     */
568
    protected static function has_expired(string $period, int $comparisondate): bool {
569
        $dt = new \DateTime();
570
        $dt->setTimestamp($comparisondate);
571
        $dt->add(new \DateInterval($period));
572
 
573
        return (time() >= $dt->getTimestamp());
574
    }
575
 
576
    /**
577
     * Get the expiry info object for the specified purpose and comparison date.
578
     *
579
     * @param   purpose     $purpose The purpose of this context
580
     * @param   int         $comparisondate The date for comparison
581
     * @return  expiry_info
582
     */
583
    protected static function get_expiry_info(purpose $purpose, int $comparisondate = 0): expiry_info {
584
        $overrides = $purpose->get_purpose_overrides();
585
        $expiredroles = $unexpiredroles = [];
586
        if (empty($overrides)) {
587
            // There are no overrides for this purpose.
588
            if (empty($comparisondate)) {
589
                // The date is empty, therefore this context cannot be considered for automatic expiry.
590
                $defaultexpired = false;
591
            } else {
592
                $defaultexpired = static::has_expired($purpose->get('retentionperiod'), $comparisondate);
593
            }
594
 
595
            return new expiry_info($defaultexpired, $purpose->get('protected'), [], [], []);
596
        } else {
597
            $protectedroles = [];
598
            foreach ($overrides as $override) {
599
                if (static::has_expired($override->get('retentionperiod'), $comparisondate)) {
600
                    // This role has expired.
601
                    $expiredroles[] = $override->get('roleid');
602
                } else {
603
                    // This role has not yet expired.
604
                    $unexpiredroles[] = $override->get('roleid');
605
 
606
                    if ($override->get('protected')) {
607
                        $protectedroles[$override->get('roleid')] = true;
608
                    }
609
                }
610
            }
611
 
612
            $defaultexpired = false;
613
            if (static::has_expired($purpose->get('retentionperiod'), $comparisondate)) {
614
                $defaultexpired = true;
615
            }
616
 
617
            if ($defaultexpired) {
618
                $expiredroles = [];
619
            }
620
 
621
            return new expiry_info($defaultexpired, $purpose->get('protected'), $expiredroles, $unexpiredroles, $protectedroles);
622
        }
623
    }
624
 
625
    /**
626
     * Update or delete the expired_context from the expiry_info object.
627
     * This function depends upon the data structure returned from get_nested_expiry_info.
628
     *
629
     * If the context is expired in any way, then an expired_context will be returned, otherwise null will be returned.
630
     *
631
     * @param   \stdClass   $expiryrecord
632
     * @return  expired_context|null
633
     */
634
    protected function update_from_expiry_info(\stdClass $expiryrecord) {
635
        if ($isanyexpired = $expiryrecord->info->is_any_expired()) {
636
            // The context is expired in some fashion.
637
            // Create or update as required.
638
            if ($expiryrecord->record->expiredctxid) {
639
                $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
640
                $expiredcontext->update_from_expiry_info($expiryrecord->info);
641
 
642
                if ($expiredcontext->is_complete()) {
643
                    return null;
644
                }
645
            } else {
646
                $expiredcontext = expired_context::create_from_expiry_info($expiryrecord->context, $expiryrecord->info);
647
            }
648
 
649
            if ($expiryrecord->context instanceof \context_user) {
650
                $userassignments = $this->get_role_users_for_expired_context($expiredcontext, $expiryrecord->context);
651
                if (!empty($userassignments->unexpired)) {
652
                    $expiredcontext->delete();
653
 
654
                    return null;
655
                }
656
            }
657
 
658
            return $expiredcontext;
659
        } else {
660
            // The context is not expired.
661
            if ($expiryrecord->record->expiredctxid) {
662
                // There was previously an expired context record, but it is no longer relevant.
663
                // Delete it to be on the safe side.
664
                $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
665
                $expiredcontext->delete();
666
            }
667
 
668
            return null;
669
        }
670
    }
671
 
672
    /**
673
     * Update the expired context record.
674
     *
675
     * Note: You should use the return value as the provided value will be used to fetch data only.
676
     *
677
     * @param   expired_context $expiredctx The record to update
678
     * @return  expired_context|null
679
     */
680
    protected function update_expired_context(expired_context $expiredctx) {
681
        // Fetch the context from the expired_context record.
682
        $context = \context::instance_by_id($expiredctx->get('contextid'));
683
 
684
        // Fetch the current nested expiry data.
685
        $expiryrecords = self::get_nested_expiry_info($context->path);
686
 
687
        if (empty($expiryrecords[$context->path])) {
688
            $expiredctx->delete();
689
            return null;
690
        }
691
 
692
        // Refresh the record.
693
        // Note: Use the returned expiredctx.
694
        $expiredctx = $this->update_from_expiry_info($expiryrecords[$context->path]);
695
        if (empty($expiredctx)) {
696
            return null;
697
        }
698
 
699
        if (!$context instanceof \context_user) {
700
            // Where the target context is not a user, we check all children of the context.
701
            // The expiryrecords array only contains children, fetched from the get_nested_expiry_info call above.
702
            // No need to check that these _are_ children.
703
            foreach ($expiryrecords as $expiryrecord) {
704
                if ($expiryrecord->context->id === $context->id) {
705
                    // This is record for the context being tested that we checked earlier.
706
                    continue;
707
                }
708
 
709
                if (empty($expiryrecord->record->expiredctxid)) {
710
                    // There is no expired context record for this context.
711
                    // If there is no record, then this context cannot have been approved for removal.
712
                    return null;
713
                }
714
 
715
                // Fetch the expired_context object for this record.
716
                // This needs to be updated from the expiry_info data too as there may be child changes to consider.
717
                $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
718
                $expiredcontext->update_from_expiry_info($expiryrecord->info);
719
                if (!$expiredcontext->is_complete()) {
720
                    return null;
721
                }
722
            }
723
        }
724
 
725
        return $expiredctx;
726
    }
727
 
728
    /**
729
     * Get the list of actual users for the combination of expired, and unexpired roles.
730
     *
731
     * @param   expired_context $expiredctx
732
     * @param   \context        $context
733
     * @return  \stdClass
734
     */
735
    protected function get_role_users_for_expired_context(expired_context $expiredctx, \context $context): \stdClass {
736
        $expiredroles = $expiredctx->get('expiredroles');
737
        $expiredroleusers = [];
738
        if (!empty($expiredroles)) {
739
            // Find the list of expired role users.
740
            $expiredroleuserassignments = get_role_users($expiredroles, $context, true, 'ra.id, u.id AS userid', 'ra.id');
741
            $expiredroleusers = array_map(function($assignment) {
742
                return $assignment->userid;
743
            }, $expiredroleuserassignments);
744
        }
745
        $expiredroleusers = array_unique($expiredroleusers);
746
 
747
        $unexpiredroles = $expiredctx->get('unexpiredroles');
748
        $unexpiredroleusers = [];
749
        if (!empty($unexpiredroles)) {
750
            // Find the list of unexpired role users.
751
            $unexpiredroleuserassignments = get_role_users($unexpiredroles, $context, true, 'ra.id, u.id AS userid', 'ra.id');
752
            $unexpiredroleusers = array_map(function($assignment) {
753
                return $assignment->userid;
754
            }, $unexpiredroleuserassignments);
755
        }
756
        $unexpiredroleusers = array_unique($unexpiredroleusers);
757
 
758
        if (!$expiredctx->get('defaultexpired')) {
759
            $tofilter = get_users_roles($context, $expiredroleusers);
760
            $tofilter = array_filter($tofilter, function($userroles) use ($expiredroles) {
761
                // Each iteration contains the list of role assignment for a specific user.
762
                // All roles that the user holds must match those in the list of expired roles.
763
                foreach ($userroles as $ra) {
764
                    if (false === array_search($ra->roleid, $expiredroles)) {
765
                        // This role was not found in the list of assignments.
766
                        return true;
767
                    }
768
                }
769
 
770
                return false;
771
            });
772
            $unexpiredroleusers = array_merge($unexpiredroleusers, array_keys($tofilter));
773
        }
774
 
775
        return (object) [
776
            'expired' => $expiredroleusers,
777
            'unexpired' => $unexpiredroleusers,
778
        ];
779
    }
780
 
781
    /**
782
     * Determine whether the supplied context has expired.
783
     *
784
     * @param   \context    $context
785
     * @return  bool
786
     */
787
    public static function is_context_expired(\context $context): bool {
788
        $parents = $context->get_parent_contexts(true);
789
        foreach ($parents as $parent) {
790
            if ($parent instanceof \context_course) {
791
                // This is a context within a course. Check whether _this context_ is expired as a function of a course.
792
                return self::is_course_context_expired($context);
793
            }
794
 
795
            if ($parent instanceof \context_user) {
796
                // This is a context within a user. Check whether the _user_ has expired.
797
                return self::are_user_context_dependencies_expired($parent);
798
            }
799
        }
800
 
801
        return false;
802
    }
803
 
804
    /**
805
     * Check whether the course has expired.
806
     *
807
     * @param   \stdClass   $course
808
     * @return  bool
809
     */
810
    protected static function is_course_expired(\stdClass $course): bool {
811
        $context = \context_course::instance($course->id);
812
 
813
        return self::is_course_context_expired($context);
814
    }
815
 
816
    /**
817
     * Determine whether the supplied course-related context has expired.
818
     * Note: This is not necessarily a _course_ context, but a context which is _within_ a course.
819
     *
820
     * @param   \context        $context
821
     * @return  bool
822
     */
823
    protected static function is_course_context_expired(\context $context): bool {
824
        $expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
825
 
826
        return !empty($expiryrecords[$context->path]) && $expiryrecords[$context->path]->info->is_fully_expired();
827
    }
828
 
829
    /**
830
     * Determine whether the supplied user context's dependencies have expired.
831
     *
832
     * This checks whether courses have expired, and some other check, but does not check whether the user themself has expired.
833
     *
834
     * Although this seems unusual at first, each location calling this actually checks whether the user is elgible for
835
     * deletion, irrespective if they have actually expired.
836
     *
837
     * For example, a request to delete the user only cares about course dependencies and the user's lack of expiry
838
     * should not block their own request to be deleted; whilst the expiry eligibility check has already tested for the
839
     * user being expired.
840
     *
841
     * @param   \context_user   $context
842
     * @return  bool
843
     */
844
    protected static function are_user_context_dependencies_expired(\context_user $context): bool {
845
        // The context instanceid is the user's ID.
846
        if (isguestuser($context->instanceid) || is_siteadmin($context->instanceid)) {
847
            // This is an admin, or the guest and cannot expire.
848
            return false;
849
        }
850
 
851
        $courses = enrol_get_users_courses($context->instanceid, false, ['enddate']);
852
        $requireenddate = self::require_all_end_dates_for_user_deletion();
853
 
854
        $expired = true;
855
 
856
        foreach ($courses as $course) {
857
            if (empty($course->enddate)) {
858
                // This course has no end date.
859
                if ($requireenddate) {
860
                    // Course end dates are required, and this course has no end date.
861
                    $expired = false;
862
                    break;
863
                }
864
 
865
                // Course end dates are not required. The subsequent checks are pointless at this time so just
866
                // skip them.
867
                continue;
868
            }
869
 
870
            if ($course->enddate >= time()) {
871
                // This course is still in the future.
872
                $expired = false;
873
                break;
874
            }
875
 
876
            // This course has an end date which is in the past.
877
            if (!self::is_course_expired($course)) {
878
                // This course has not expired yet.
879
                $expired = false;
880
                break;
881
            }
882
        }
883
 
884
        return $expired;
885
    }
886
 
887
    /**
888
     * Determine whether the supplied context has expired or unprotected for the specified user.
889
     *
890
     * @param   \context    $context
891
     * @param   \stdClass   $user
892
     * @return  bool
893
     */
894
    public static function is_context_expired_or_unprotected_for_user(\context $context, \stdClass $user): bool {
895
        // User/course contexts can't expire if no purpose is set in the system context.
896
        if (!data_registry::defaults_set()) {
897
            return false;
898
        }
899
 
900
        $parents = $context->get_parent_contexts(true);
901
        foreach ($parents as $parent) {
902
            if ($parent instanceof \context_course) {
903
                // This is a context within a course. Check whether _this context_ is expired as a function of a course.
904
                return self::is_course_context_expired_or_unprotected_for_user($context, $user);
905
            }
906
 
907
            if ($parent instanceof \context_user) {
908
                // This is a context within a user. Check whether the _user_ has expired.
909
                return self::are_user_context_dependencies_expired($parent);
910
            }
911
        }
912
 
913
        return false;
914
    }
915
 
916
    /**
917
     * Determine whether the supplied course-related context has expired, or is unprotected.
918
     * Note: This is not necessarily a _course_ context, but a context which is _within_ a course.
919
     *
920
     * @param   \context        $context
921
     * @param   \stdClass       $user
922
     * @return  bool
923
     */
924
    protected static function is_course_context_expired_or_unprotected_for_user(\context $context, \stdClass $user) {
925
 
926
        if ($context->get_course_context()->instanceid == SITEID) {
927
            // The is an activity in the site course (front page).
928
            $purpose = data_registry::get_effective_contextlevel_value(CONTEXT_SYSTEM, 'purpose');
929
            $info = static::get_expiry_info($purpose);
930
 
931
        } else {
932
            $expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
933
            $info = $expiryrecords[$context->path]->info;
934
        }
935
 
936
        if ($info->is_fully_expired()) {
937
            // This context is fully expired.
938
            return true;
939
        }
940
 
941
        // Now perform user checks.
942
        $userroles = array_map(function($assignment) {
943
            return $assignment->roleid;
944
        }, get_user_roles($context, $user->id));
945
 
946
        $unexpiredprotectedroles = $info->get_unexpired_protected_roles();
947
        if (!empty(array_intersect($unexpiredprotectedroles, $userroles))) {
948
            // The user holds an unexpired and protected role.
949
            return false;
950
        }
951
 
952
        $unprotectedoverriddenroles = $info->get_unprotected_overridden_roles();
953
        $matchingroles = array_intersect($unprotectedoverriddenroles, $userroles);
954
        if (!empty($matchingroles)) {
955
            // This user has at least one overridden role which is not a protected.
956
            // However, All such roles must match.
957
            // If the user has multiple roles then all must be expired, otherwise we should fall back to the default behaviour.
958
            if (empty(array_diff($userroles, $unprotectedoverriddenroles))) {
959
                // All roles that this user holds are a combination of expired, or unprotected.
960
                return true;
961
            }
962
        }
963
 
964
        if ($info->is_default_expired()) {
965
            // If the user has no unexpired roles, and the context is expired by default then this must be expired.
966
            return true;
967
        }
968
 
969
        return !$info->is_default_protected();
970
    }
971
 
972
    /**
973
     * Create a new instance of the privacy manager.
974
     *
975
     * @return  manager
976
     */
977
    protected function get_privacy_manager(): manager {
978
        if (null === $this->manager) {
979
            $this->manager = new manager();
980
            $this->manager->set_observer(new \tool_dataprivacy\manager_observer());
981
        }
982
 
983
        return $this->manager;
984
    }
985
 
986
    /**
987
     * Fetch the limit for the maximum number of contexts to delete in one session.
988
     *
989
     * @return  int
990
     */
991
    protected function get_delete_limit(): int {
992
        return self::DELETE_LIMIT;
993
    }
994
 
995
    /**
996
     * Get the progress tracer.
997
     *
998
     * @return  \progress_trace
999
     */
1000
    protected function get_progress(): \progress_trace {
1001
        if (null === $this->progresstracer) {
1002
            $this->set_progress(new \text_progress_trace());
1003
        }
1004
 
1005
        return $this->progresstracer;
1006
    }
1007
 
1008
    /**
1009
     * Set a specific tracer for the task.
1010
     *
1011
     * @param   \progress_trace $trace
1012
     * @return  $this
1013
     */
1014
    public function set_progress(\progress_trace $trace): expired_contexts_manager {
1015
        $this->progresstracer = $trace;
1016
 
1017
        return $this;
1018
    }
1019
}