3f27b807d9a077e046434e7218e2b37788d74107
[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             // The context instanceid is the user's ID.
315             if (isguestuser($context->instanceid) || is_siteadmin($context->instanceid)) {
316                 // This is an admin, or the guest and cannot be deleted.
317                 $shouldskip = true;
318             }
320             if (!$shouldskip) {
321                 $courses = enrol_get_users_courses($context->instanceid, false, ['enddate']);
322                 $requireenddate = self::require_all_end_dates_for_user_deletion();
324                 foreach ($courses as $course) {
325                     if (empty($course->enddate)) {
326                         // This course has no end date.
327                         if ($requireenddate) {
328                             // Course end dates are required, and this course has no end date.
329                             $shouldskip = true;
330                             break;
331                         }
333                         // Course end dates are not required. The subsequent checks are pointless at this time so just
334                         // skip them.
335                         continue;
336                     }
338                     if ($course->enddate >= time()) {
339                         // This course is still in the future.
340                         $shouldskip = true;
341                         break;
342                     }
344                     // This course has an end date which is in the past.
345                     if (!self::is_course_expired($course)) {
346                         // This course has not expired yet.
347                         $shouldskip = true;
348                         break;
349                     }
350                 }
351             }
352         }
354         if ($shouldskip) {
355             // Add this to the list of contexts to skip for parentage checks.
356             $pathstoskip[] = $context->path;
357         }
359         return !$shouldskip;
360     }
362     /**
363      * Deletes the expired contexts.
364      *
365      * @return  int[]       The number of deleted contexts.
366      */
367     public function process_approved_deletions() : array {
368         if (!$this->check_requirements()) {
369             return [0, 0];
370         }
372         $expiredcontexts = expired_context::get_records(['status' => expired_context::STATUS_APPROVED]);
373         $totalprocessed = 0;
374         $usercount = 0;
375         $coursecount = 0;
376         foreach ($expiredcontexts as $expiredctx) {
377             $context = \context::instance_by_id($expiredctx->get('contextid'), IGNORE_MISSING);
378             if (empty($context)) {
379                 // Unable to process this request further.
380                 // We have no context to delete.
381                 $expiredctx->delete();
382                 continue;
383             }
385             if ($this->delete_expired_context($expiredctx)) {
386                 if ($context instanceof \context_user) {
387                     $usercount++;
388                 } else {
389                     $coursecount++;
390                 }
392                 $totalprocessed++;
393                 if ($totalprocessed >= $this->get_delete_limit()) {
394                     break;
395                 }
396             }
397         }
399         return [$coursecount, $usercount];
400     }
402     /**
403      * Deletes user data from the provided context.
404      *
405      * @param expired_context $expiredctx
406      * @return \context|false
407      */
408     protected function delete_expired_context(expired_context $expiredctx) {
409         $context = \context::instance_by_id($expiredctx->get('contextid'));
411         $this->get_progress()->output("Deleting context {$context->id} - " . $context->get_context_name(true, true));
413         // Update the expired_context and verify that it is still ready for deletion.
414         $expiredctx = $this->update_expired_context($expiredctx);
415         if (empty($expiredctx)) {
416             $this->get_progress()->output("Context has changed since approval and is no longer pending approval. Skipping", 1);
417             return false;
418         }
420         if (!$expiredctx->can_process_deletion()) {
421             // This only happens if the record was updated after being first fetched.
422             $this->get_progress()->output("Context has changed since approval and must be re-approved. Skipping", 1);
423             $expiredctx->set('status', expired_context::STATUS_EXPIRED);
424             $expiredctx->save();
426             return false;
427         }
429         $privacymanager = $this->get_privacy_manager();
430         if ($expiredctx->is_fully_expired()) {
431             if ($context instanceof \context_user) {
432                 $this->delete_expired_user_context($expiredctx);
433             } else {
434                 // This context is fully expired - that is that the default retention period has been reached, and there are
435                 // no remaining overrides.
436                 $privacymanager->delete_data_for_all_users_in_context($context);
437             }
439             // Mark the record as cleaned.
440             $expiredctx->set('status', expired_context::STATUS_CLEANED);
441             $expiredctx->save();
443             return $context;
444         }
446         // We need to find all users in the context, and delete just those who have expired.
447         $collection = $privacymanager->get_users_in_context($context);
449         // Apply the expired and unexpired filters to remove the users in these categories.
450         $userassignments = $this->get_role_users_for_expired_context($expiredctx, $context);
451         $approvedcollection = new \core_privacy\local\request\userlist_collection($context);
452         foreach ($collection as $pendinguserlist) {
453             $userlist = filtered_userlist::create_from_userlist($pendinguserlist);
454             $userlist->apply_expired_context_filters($userassignments->expired, $userassignments->unexpired);
455             if (count($userlist)) {
456                 $approvedcollection->add_userlist($userlist);
457             }
458         }
460         if (count($approvedcollection)) {
461             // Perform the deletion with the newly approved collection.
462             $privacymanager->delete_data_for_users_in_context($approvedcollection);
463         }
465         // Mark the record as cleaned.
466         $expiredctx->set('status', expired_context::STATUS_CLEANED);
467         $expiredctx->save();
469         return $context;
470     }
472     /**
473      * Deletes user data from the provided user context.
474      *
475      * @param expired_context $expiredctx
476      */
477     protected function delete_expired_user_context(expired_context $expiredctx) {
478         global $DB;
480         $contextid = $expiredctx->get('contextid');
481         $context = \context::instance_by_id($contextid);
482         $user = \core_user::get_user($context->instanceid, '*', MUST_EXIST);
484         $privacymanager = $this->get_privacy_manager();
486         // Delete all child contexts of the user context.
487         $parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
489         $params = [
490             'contextlevel'  => CONTEXT_USER,
491             'contextid'     => $expiredctx->get('contextid'),
492         ];
494         $fields = \context_helper::get_preload_record_columns_sql('ctx');
495         $sql = "SELECT ctx.id, $fields
496                   FROM {context} ctxuser
497                   JOIN {context} ctx ON ctx.path LIKE {$parentpath}
498                  WHERE ctxuser.contextlevel = :contextlevel AND ctxuser.id = :contextid
499               ORDER BY ctx.path DESC";
501         $children = $DB->get_recordset_sql($sql, $params);
502         foreach ($children as $child) {
503             \context_helper::preload_from_record($child);
504             $context = \context::instance_by_id($child->id);
506             $privacymanager->delete_data_for_all_users_in_context($context);
507         }
508         $children->close();
510         // Delete all unprotected data that the user holds.
511         $approvedlistcollection = new \core_privacy\local\request\contextlist_collection($user->id);
512         $contextlistcollection = $privacymanager->get_contexts_for_userid($user->id);
514         foreach ($contextlistcollection as $contextlist) {
515             $contextids = [];
516             $approvedlistcollection->add_contextlist(new \core_privacy\local\request\approved_contextlist(
517                     $user,
518                     $contextlist->get_component(),
519                     $contextlist->get_contextids()
520                 ));
521         }
522         $privacymanager->delete_data_for_user($approvedlistcollection, $this->get_progress());
524         // Delete the user context.
525         $context = \context::instance_by_id($expiredctx->get('contextid'));
526         $privacymanager->delete_data_for_all_users_in_context($context);
528         // This user is now fully expired - finish by deleting the user.
529         delete_user($user);
530     }
532     /**
533      * Whether end dates are required on all courses in order for a user to be expired from them.
534      *
535      * @return bool
536      */
537     protected static function require_all_end_dates_for_user_deletion() : bool {
538         $requireenddate = get_config('tool_dataprivacy', 'requireallenddatesforuserdeletion');
540         return !empty($requireenddate);
541     }
543     /**
544      * Check that the requirements to start deleting contexts are satisified.
545      *
546      * @return bool
547      */
548     protected function check_requirements() {
549         if (!data_registry::defaults_set()) {
550             return false;
551         }
552         return true;
553     }
555     /**
556      * Check whether a date is beyond the specified period.
557      *
558      * @param   string      $period The Expiry Period
559      * @param   int         $comparisondate The date for comparison
560      * @return  bool
561      */
562     protected static function has_expired(string $period, int $comparisondate) : bool {
563         $dt = new \DateTime();
564         $dt->setTimestamp($comparisondate);
565         $dt->add(new \DateInterval($period));
567         return (time() >= $dt->getTimestamp());
568     }
570     /**
571      * Get the expiry info object for the specified purpose and comparison date.
572      *
573      * @param   purpose     $purpose The purpose of this context
574      * @param   int         $comparisondate The date for comparison
575      * @return  expiry_info
576      */
577     protected static function get_expiry_info(purpose $purpose, int $comparisondate = 0) : expiry_info {
578         $overrides = $purpose->get_purpose_overrides();
579         $expiredroles = $unexpiredroles = [];
580         if (empty($overrides)) {
581             // There are no overrides for this purpose.
582             if (empty($comparisondate)) {
583                 // The date is empty, therefore this context cannot be considered for automatic expiry.
584                 $defaultexpired = false;
585             } else {
586                 $defaultexpired = static::has_expired($purpose->get('retentionperiod'), $comparisondate);
587             }
589             return new expiry_info($defaultexpired, [], []);
590         } else {
591             foreach ($overrides as $override) {
592                 if (static::has_expired($override->get('retentionperiod'), $comparisondate)) {
593                     // This role has expired.
594                     $expiredroles[] = $override->get('roleid');
595                 } else {
596                     // This role has not yet expired.
597                     $unexpiredroles[] = $override->get('roleid');
598                 }
599             }
601             $defaultexpired = false;
602             if (static::has_expired($purpose->get('retentionperiod'), $comparisondate)) {
603                 $defaultexpired = true;
604             }
606             if ($defaultexpired) {
607                 $expiredroles = [];
608             }
610             return new expiry_info($defaultexpired, $expiredroles, $unexpiredroles);
611         }
612     }
614     /**
615      * Update or delete the expired_context from the expiry_info object.
616      * This function depends upon the data structure returned from get_nested_expiry_info.
617      *
618      * If the context is expired in any way, then an expired_context will be returned, otherwise null will be returned.
619      *
620      * @param   \stdClass   $expiryrecord
621      * @return  expired_context|null
622      */
623     protected function update_from_expiry_info(\stdClass $expiryrecord) {
624         if ($isanyexpired = $expiryrecord->info->is_any_expired()) {
625             // The context is expired in some fashion.
626             // Create or update as required.
627             if ($expiryrecord->record->expiredctxid) {
628                 $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
629                 $expiredcontext->update_from_expiry_info($expiryrecord->info);
631                 if ($expiredcontext->is_complete()) {
632                     return null;
633                 }
634             } else {
635                 $expiredcontext = expired_context::create_from_expiry_info($expiryrecord->context, $expiryrecord->info);
636             }
638             if ($expiryrecord->context instanceof \context_user) {
639                 $userassignments = $this->get_role_users_for_expired_context($expiredcontext, $expiryrecord->context);
640                 if (!empty($userassignments->unexpired)) {
641                     $expiredcontext->delete();
643                     return null;
644                 }
645             }
647             return $expiredcontext;
648         } else {
649             // The context is not expired.
650             if ($expiryrecord->record->expiredctxid) {
651                 // There was previously an expired context record, but it is no longer relevant.
652                 // Delete it to be on the safe side.
653                 $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
654                 $expiredcontext->delete();
655             }
657             return null;
658         }
659     }
661     /**
662      * Update the expired context record.
663      *
664      * Note: You should use the return value as the provided value will be used to fetch data only.
665      *
666      * @param   expired_context $expiredctx The record to update
667      * @return  expired_context|null
668      */
669     protected function update_expired_context(expired_context $expiredctx) {
670         // Fetch the context from the expired_context record.
671         $context = \context::instance_by_id($expiredctx->get('contextid'));
673         // Fetch the current nested expiry data.
674         $expiryrecords = self::get_nested_expiry_info($context->path);
676         if (empty($expiryrecords[$context->path])) {
677             $expiredctx->delete();
678             return null;
679         }
681         // Refresh the record.
682         // Note: Use the returned expiredctx.
683         $expiredctx = $this->update_from_expiry_info($expiryrecords[$context->path]);
684         if (empty($expiredctx)) {
685             return null;
686         }
688         if (!$context instanceof \context_user) {
689             // Where the target context is not a user, we check all children of the context.
690             // The expiryrecords array only contains children, fetched from the get_nested_expiry_info call above.
691             // No need to check that these _are_ children.
692             foreach ($expiryrecords as $expiryrecord) {
693                 if ($expiryrecord->context->id === $context->id) {
694                     // This is record for the context being tested that we checked earlier.
695                     continue;
696                 }
698                 if (empty($expiryrecord->record->expiredctxid)) {
699                     // There is no expired context record for this context.
700                     // If there is no record, then this context cannot have been approved for removal.
701                     return null;
702                 }
704                 // Fetch the expired_context object for this record.
705                 // This needs to be updated from the expiry_info data too as there may be child changes to consider.
706                 $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
707                 $expiredcontext->update_from_expiry_info($expiryrecord->info);
708                 if (!$expiredcontext->is_complete()) {
709                     return null;
710                 }
711             }
712         }
714         return $expiredctx;
715     }
717     /**
718      * Get the list of actual users for the combination of expired, and unexpired roles.
719      *
720      * @param   expired_context $expiredctx
721      * @param   \context        $context
722      * @return  \stdClass
723      */
724     protected function get_role_users_for_expired_context(expired_context $expiredctx, \context $context) : \stdClass {
725         $expiredroles = $expiredctx->get('expiredroles');
726         $expiredroleusers = [];
727         if (!empty($expiredroles)) {
728             // Find the list of expired role users.
729             $expiredroleuserassignments = get_role_users($expiredroles, $context, true, 'ra.id, u.id AS userid', 'ra.id');
730             $expiredroleusers = array_map(function($assignment) {
731                     return $assignment->userid;
732                 }, $expiredroleuserassignments);
733         }
734         $expiredroleusers = array_unique($expiredroleusers);
736         $unexpiredroles = $expiredctx->get('unexpiredroles');
737         $unexpiredroleusers = [];
738         if (!empty($unexpiredroles)) {
739             // Find the list of unexpired role users.
740             $unexpiredroleuserassignments = get_role_users($unexpiredroles, $context, true, 'ra.id, u.id AS userid', 'ra.id');
741             $unexpiredroleusers = array_map(function($assignment) {
742                     return $assignment->userid;
743                 }, $unexpiredroleuserassignments);
744         }
745         $unexpiredroleusers = array_unique($unexpiredroleusers);
747         if (!$expiredctx->get('defaultexpired')) {
748             $tofilter = get_users_roles($context, $expiredroleusers);
749             $tofilter = array_filter($tofilter, function($userroles) use ($expiredroles) {
750                 // Each iteration contains the list of role assignment for a specific user.
751                 // All roles that the user holds must match those in the list of expired roles.
752                 if (count($userroles) === 1) {
753                     // Shortcut - only one role held which must be one of the expired roles.
754                     // TODO I think this is wrong.
755                     return false;
756                 }
758                 foreach ($userroles as $ra) {
759                     if (false === array_search($ra->roleid, $expiredroles)) {
760                         // This role was not found in the list of assignments.
761                         return true;
762                     }
763                 }
765                 return false;
766             });
767             $unexpiredroleusers = array_merge($unexpiredroleusers, array_keys($tofilter));
768         }
770         return (object) [
771             'expired' => $expiredroleusers,
772             'unexpired' => $unexpiredroleusers,
773         ];
774     }
776     /**
777      * Check whether the course has expired.
778      *
779      * @param   \stdClass   $course
780      * @return  bool
781      */
782     protected static function is_course_expired(\stdClass $course) : bool {
783         $context = \context_course::instance($course->id);
784         $expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
786         return !empty($expiryrecords[$context->path]) && $expiryrecords[$context->path]->info->is_fully_expired();
787     }
789     /**
790      * Create a new instance of the privacy manager.
791      *
792      * @return  manager
793      */
794     protected function get_privacy_manager() : manager {
795         if (null === $this->manager) {
796             $this->manager = new manager();
797             $this->manager->set_observer(new \tool_dataprivacy\manager_observer());
798         }
800         return $this->manager;
801     }
803     /**
804      * Fetch the limit for the maximum number of contexts to delete in one session.
805      *
806      * @return  int
807      */
808     protected function get_delete_limit() : int {
809         return self::DELETE_LIMIT;
810     }
812     /**
813      * Get the progress tracer.
814      *
815      * @return  \progress_trace
816      */
817     protected function get_progress() : \progress_trace {
818         if (null === $this->progresstracer) {
819             $this->set_progress(new \text_progress_trace());
820         }
822         return $this->progresstracer;
823     }
825     /**
826      * Set a specific tracer for the task.
827      *
828      * @param   \progress_trace $trace
829      * @return  $this
830      */
831     public function set_progress(\progress_trace $trace) : expired_contexts_manager {
832         $this->progresstracer = $trace;
834         return $this;
835     }