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