c2dc16949a558e9f71e4621bb1b93da12247ebc6
[moodle.git] / admin / tool / dataprivacy / classes / expired_contexts_manager.php
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/>.
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;
26 use core_privacy\manager;
27 use tool_dataprivacy\expired_context;
29 defined('MOODLE_INTERNAL') || die();
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 {
39     /**
40      * Number of deleted contexts for each scheduled task run.
41      */
42     const DELETE_LIMIT = 200;
44     /** @var progress_trace The log progress tracer */
45     protected $progresstracer = null;
47     /** @var manager The privacy manager */
48     protected $manager = null;
50     /** @var \progress_trace Trace tool for logging */
51     protected $trace = null;
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         }
63         $this->trace = $trace;
64     }
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         }
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);
83         $this->trace->output('Calculating potential course expiries.', 0);
84         $data = static::get_nested_expiry_info_for_courses();
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);
95         $this->trace->output('Calculating potential user expiries.', 0);
96         $data = static::get_nested_expiry_info_for_user();
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);
107         return [$coursecount, $usercount];
108     }
110     /**
111      * Clear old and stale records.
112      */
113     protected static function clear_old_records() {
114         global $DB;
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";
121         $orphaned = $DB->get_recordset_sql($sql);
122         foreach ($orphaned as $orphan) {
123             $expiredcontext = new expired_context(0, $orphan);
124             $expiredcontext->delete();
125         }
127         // Delete any child of a user context.
128         $parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
129         $params = [
130             'contextuser' => CONTEXT_USER,
131         ];
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     }
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);
158         return array_merge($coursepaths, $userpaths);
159     }
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;
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]);
176         // We want all contexts at course-dependant levels.
177         $parentpath = $DB->sql_concat('ctxcourse.path', "'/%'");
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 = '';
187         if (!empty($contextpath)) {
188             $where = "WHERE (ctx.path = :pathmatchexact OR ctx.path LIKE :pathmatchchildren)";
189             $params['pathmatchexact'] = $contextpath;
190             $params['pathmatchchildren'] = "{$contextpath}/%";
191         }
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";
211         return self::get_nested_expiry_info_from_sql($sql, $params);
212     }
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;
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]);
229         // We want all contexts at user-dependant levels.
230         $parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
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 = '';
239         if (!empty($contextpath)) {
240             $where = "AND ctx.path = :pathmatchexact";
241             $params['pathmatchexact'] = $contextpath;
242         }
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";
254         return self::get_nested_expiry_info_from_sql($sql, $params);
255     }
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;
268         $fulllist = $DB->get_recordset_sql($sql, $params);
269         $datalist = [];
270         $expiredcontents = [];
271         $pathstoskip = [];
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);
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);
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             }
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             }
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             }
309             foreach ($datalist as $path => $data) {
310                 // Merge with already-processed children.
311                 if (strpos($path, $context->path) !== 0) {
312                     continue;
313                 }
315                 $expiryinfo->merge_with_child($data->info);
316             }
318             $datalist[$context->path] = (object) [
319                 'context' => $context,
320                 'record' => $record,
321                 'purpose' => $purpose,
322                 'info' => $expiryinfo,
323             ];
324         }
325         $fulllist->close();
327         return $datalist;
328     }
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         }));
347         if (!$shouldskip && $context instanceof \context_user) {
348             $shouldskip = !self::are_user_context_dependencies_expired($context);
349         }
351         if ($shouldskip) {
352             // Add this to the list of contexts to skip for parentage checks.
353             $pathstoskip[] = $context->path;
354         }
356         return !$shouldskip;
357     }
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         }
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);
380             if (empty($context)) {
381                 // Unable to process this request further.
382                 // We have no context to delete.
383                 $expiredctx->delete();
384                 continue;
385             }
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                 }
396                 $totalprocessed++;
397                 if ($totalprocessed >= $this->get_delete_limit()) {
398                     break;
399                 }
400             }
401         }
403         return [$coursecount, $usercount];
404     }
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'));
415         $this->get_progress()->output("Deleting context {$context->id} - " . $context->get_context_name(true, true));
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         }
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();
430             return false;
431         }
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             }
443             // Mark the record as cleaned.
444             $expiredctx->set('status', expired_context::STATUS_CLEANED);
445             $expiredctx->save();
447             return $context;
448         }
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);
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         }
464         if (count($approvedcollection)) {
465             // Perform the deletion with the newly approved collection.
466             $privacymanager->delete_data_for_users_in_context($approvedcollection);
467         }
469         // Mark the record as cleaned.
470         $expiredctx->set('status', expired_context::STATUS_CLEANED);
471         $expiredctx->save();
473         return $context;
474     }
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;
484         $contextid = $expiredctx->get('contextid');
485         $context = \context::instance_by_id($contextid);
486         $user = \core_user::get_user($context->instanceid, '*', MUST_EXIST);
488         $privacymanager = $this->get_privacy_manager();
490         // Delete all child contexts of the user context.
491         $parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
493         $params = [
494             'contextlevel'  => CONTEXT_USER,
495             'contextid'     => $expiredctx->get('contextid'),
496         ];
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";
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);
510             $privacymanager->delete_data_for_all_users_in_context($context);
511         }
512         $children->close();
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);
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());
528         // Delete the user context.
529         $context = \context::instance_by_id($expiredctx->get('contextid'));
530         $privacymanager->delete_data_for_all_users_in_context($context);
532         // This user is now fully expired - finish by deleting the user.
533         delete_user($user);
534     }
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');
544         return !empty($requireenddate);
545     }
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     }
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));
571         return (time() >= $dt->getTimestamp());
572     }
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             }
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');
604                     if ($override->get('protected')) {
605                         $protectedroles[$override->get('roleid')] = true;
606                     }
607                 }
608             }
610             $defaultexpired = false;
611             if (static::has_expired($purpose->get('retentionperiod'), $comparisondate)) {
612                 $defaultexpired = true;
613             }
615             if ($defaultexpired) {
616                 $expiredroles = [];
617             }
619             return new expiry_info($defaultexpired, $purpose->get('protected'), $expiredroles, $unexpiredroles, $protectedroles);
620         }
621     }
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);
640                 if ($expiredcontext->is_complete()) {
641                     return null;
642                 }
643             } else {
644                 $expiredcontext = expired_context::create_from_expiry_info($expiryrecord->context, $expiryrecord->info);
645             }
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();
652                     return null;
653                 }
654             }
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             }
666             return null;
667         }
668     }
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'));
682         // Fetch the current nested expiry data.
683         $expiryrecords = self::get_nested_expiry_info($context->path);
685         if (empty($expiryrecords[$context->path])) {
686             $expiredctx->delete();
687             return null;
688         }
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         }
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                 }
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                 }
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         }
723         return $expiredctx;
724     }
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);
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);
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                 }
768                 return false;
769             });
770             $unexpiredroleusers = array_merge($unexpiredroleusers, array_keys($tofilter));
771         }
773         return (object) [
774             'expired' => $expiredroleusers,
775             'unexpired' => $unexpiredroleusers,
776         ];
777     }
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                 return self::is_course_context_expired($context);
790             }
792             if ($parent instanceof \context_user) {
793                 return self::are_user_context_dependencies_expired($context);
794             }
795         }
797         return false;
798     }
800     /**
801      * Check whether the course has expired.
802      *
803      * @param   \stdClass   $course
804      * @return  bool
805      */
806     protected static function is_course_expired(\stdClass $course) : bool {
807         $context = \context_course::instance($course->id);
809         return self::is_course_context_expired($context);
810     }
812     /**
813      * Determine whether the supplied course context has expired.
814      *
815      * @param   \context_course $context
816      * @return  bool
817      */
818     protected static function is_course_context_expired(\context_course $context) : bool {
819         $expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
821         return !empty($expiryrecords[$context->path]) && $expiryrecords[$context->path]->info->is_fully_expired();
822     }
824     /**
825      * Determine whether the supplied user context's dependencies have expired.
826      *
827      * This checks whether courses have expired, and some other check, but does not check whether the user themself has expired.
828      *
829      * Although this seems unusual at first, each location calling this actually checks whether the user is elgible for
830      * deletion, irrespective if they have actually expired.
831      *
832      * For example, a request to delete the user only cares about course dependencies and the user's lack of expiry
833      * should not block their own request to be deleted; whilst the expiry eligibility check has already tested for the
834      * user being expired.
835      *
836      * @param   \context_user   $context
837      * @return  bool
838      */
839     protected static function are_user_context_dependencies_expired(\context_user $context) : bool {
840         // The context instanceid is the user's ID.
841         if (isguestuser($context->instanceid) || is_siteadmin($context->instanceid)) {
842             // This is an admin, or the guest and cannot expire.
843             return false;
844         }
846         $courses = enrol_get_users_courses($context->instanceid, false, ['enddate']);
847         $requireenddate = self::require_all_end_dates_for_user_deletion();
849         $expired = true;
851         foreach ($courses as $course) {
852             if (empty($course->enddate)) {
853                 // This course has no end date.
854                 if ($requireenddate) {
855                     // Course end dates are required, and this course has no end date.
856                     $expired = false;
857                     break;
858                 }
860                 // Course end dates are not required. The subsequent checks are pointless at this time so just
861                 // skip them.
862                 continue;
863             }
865             if ($course->enddate >= time()) {
866                 // This course is still in the future.
867                 $expired = false;
868                 break;
869             }
871             // This course has an end date which is in the past.
872             if (!self::is_course_expired($course)) {
873                 // This course has not expired yet.
874                 $expired = false;
875                 break;
876             }
877         }
879         return $expired;
880     }
882     /**
883      * Determine whether the supplied context has expired or unprotected for the specified user.
884      *
885      * @param   \context    $context
886      * @param   \stdClass   $user
887      * @return  bool
888      */
889     public static function is_context_expired_or_unprotected_for_user(\context $context, \stdClass $user) : bool {
890         $parents = $context->get_parent_contexts(true);
891         foreach ($parents as $parent) {
892             if ($parent instanceof \context_course) {
893                 return self::is_course_context_expired_or_unprotected_for_user($parent, $user);
894             }
896             if ($parent instanceof \context_user) {
897                 return self::are_user_context_dependencies_expired($context);
898             }
899         }
901         return false;
902     }
904     /**
905      * Determine whether the supplied course context has expired, or is unprotected.
906      *
907      * @param   \context_course $context
908      * @param   \stdClass       $user
909      * @return  bool
910      */
911     protected static function is_course_context_expired_or_unprotected_for_user(\context_course $context, \stdClass $user) {
912         $expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
914         $info = $expiryrecords[$context->path]->info;
915         if ($info->is_fully_expired()) {
916             // This context is fully expired.
917             return true;
918         }
920         // Now perform user checks.
921         $userroles = array_map(function($assignment) {
922             return $assignment->roleid;
923         }, get_user_roles($context, $user->id));
925         $unexpiredprotectedroles = $info->get_unexpired_protected_roles();
926         if (!empty(array_intersect($unexpiredprotectedroles, $userroles))) {
927             // The user holds an unexpired and protected role.
928             return false;
929         }
931         $unprotectedoverriddenroles = $info->get_unprotected_overridden_roles();
932         $matchingroles = array_intersect($unprotectedoverriddenroles, $userroles);
933         if (!empty($matchingroles)) {
934             // This user has at least one overridden role which is not a protected.
935             // However, All such roles must match.
936             // If the user has multiple roles then all must be expired, otherwise we should fall back to the default behaviour.
937             if (empty(array_diff($userroles, $unprotectedoverriddenroles))) {
938                 // All roles that this user holds are a combination of expired, or unprotected.
939                 return true;
940             }
941         }
943         if ($info->is_default_expired()) {
944             // If the user has no unexpired roles, and the context is expired by default then this must be expired.
945             return true;
946         }
948         return !$info->is_default_protected();
949     }
951     /**
952      * Create a new instance of the privacy manager.
953      *
954      * @return  manager
955      */
956     protected function get_privacy_manager() : manager {
957         if (null === $this->manager) {
958             $this->manager = new manager();
959             $this->manager->set_observer(new \tool_dataprivacy\manager_observer());
960         }
962         return $this->manager;
963     }
965     /**
966      * Fetch the limit for the maximum number of contexts to delete in one session.
967      *
968      * @return  int
969      */
970     protected function get_delete_limit() : int {
971         return self::DELETE_LIMIT;
972     }
974     /**
975      * Get the progress tracer.
976      *
977      * @return  \progress_trace
978      */
979     protected function get_progress() : \progress_trace {
980         if (null === $this->progresstracer) {
981             $this->set_progress(new \text_progress_trace());
982         }
984         return $this->progresstracer;
985     }
987     /**
988      * Set a specific tracer for the task.
989      *
990      * @param   \progress_trace $trace
991      * @return  $this
992      */
993     public function set_progress(\progress_trace $trace) : expired_contexts_manager {
994         $this->progresstracer = $trace;
996         return $this;
997     }