Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

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