MDL-63401 tool_dataprivacy: Allow expiriration of users without end date
[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 = "AND (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 1 = 1 {$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             }
274             foreach ($datalist as $path => $data) {
275                 // Merge with already-processed children.
276                 if (strpos($path, $context->path) !== 0) {
277                     continue;
278                 }
280                 $expiryinfo->merge_with_child($data->info);
281             }
282             $datalist[$context->path] = (object) [
283                 'context' => $context,
284                 'record' => $record,
285                 'purpose' => $purpose,
286                 'info' => $expiryinfo,
287             ];
288         }
289         $fulllist->close();
291         return $datalist;
292     }
294     /**
295      * Check whether the supplied context would be elible for deletion.
296      *
297      * @param   array       $pathstoskip A set of paths which should be skipped
298      * @param   \context    $context
299      * @return  bool
300      */
301     protected static function is_eligible_for_deletion(array &$pathstoskip, \context $context) : bool {
302         $shouldskip = false;
303         // Check whether any of the child contexts are ineligble.
304         $shouldskip = !empty(array_filter($pathstoskip, function($path) use ($context) {
305             // If any child context has already been skipped then it will appear in this list.
306             // Since paths include parents, test if the context under test appears as the haystack in the skipped
307             // context's needle.
308             return false !== (strpos($context->path, $path));
309         }));
311         if (!$shouldskip && $context instanceof \context_user) {
312             // The context instanceid is the user's ID.
313             if (isguestuser($context->instanceid) || is_siteadmin($context->instanceid)) {
314                 // This is an admin, or the guest and cannot be deleted.
315                 $shouldskip = true;
316             }
318             if (!$shouldskip) {
319                 $courses = enrol_get_users_courses($context->instanceid, false, ['enddate']);
320                 $requireenddate = self::require_all_end_dates_for_user_deletion();
322                 foreach ($courses as $course) {
323                     if (empty($course->enddate)) {
324                         // This course has no end date.
325                         if ($requireenddate) {
326                             // Course end dates are required, and this course has no end date.
327                             $shouldskip = true;
328                             break;
329                         }
331                         // Course end dates are not required. The subsequent checks are pointless at this time so just
332                         // skip them.
333                         continue;
334                     }
336                     if ($course->enddate >= time()) {
337                         // This course is still in the future.
338                         $shouldskip = true;
339                         break;
340                     }
342                     // This course has an end date which is in the past.
343                     if (!self::is_course_expired($course)) {
344                         // This course has not expired yet.
345                         $shouldskip = true;
346                         break;
347                     }
348                 }
349             }
350         }
352         if ($shouldskip) {
353             // Add this to the list of contexts to skip for parentage checks.
354             $pathstoskip[] = $context->path;
355         }
357         return !$shouldskip;
358     }
360     /**
361      * Deletes the expired contexts.
362      *
363      * @return  int[]       The number of deleted contexts.
364      */
365     public function process_approved_deletions() : array {
366         if (!$this->check_requirements()) {
367             return [0, 0];
368         }
370         $expiredcontexts = expired_context::get_records(['status' => expired_context::STATUS_APPROVED]);
371         $totalprocessed = 0;
372         $usercount = 0;
373         $coursecount = 0;
374         foreach ($expiredcontexts as $expiredctx) {
375             $context = \context::instance_by_id($expiredctx->get('contextid'), IGNORE_MISSING);
376             if (empty($context)) {
377                 // Unable to process this request further.
378                 // We have no context to delete.
379                 $expiredctx->delete();
380                 continue;
381             }
383             if ($this->delete_expired_context($expiredctx)) {
384                 if ($context instanceof \context_user) {
385                     $usercount++;
386                 } else {
387                     $coursecount++;
388                 }
390                 $totalprocessed++;
391                 if ($totalprocessed >= $this->get_delete_limit()) {
392                     break;
393                 }
394             }
395         }
397         return [$coursecount, $usercount];
398     }
400     /**
401      * Deletes user data from the provided context.
402      *
403      * @param expired_context $expiredctx
404      * @return \context|false
405      */
406     protected function delete_expired_context(expired_context $expiredctx) {
407         $context = \context::instance_by_id($expiredctx->get('contextid'));
409         $this->get_progress()->output("Deleting context {$context->id} - " . $context->get_context_name(true, true));
411         // Update the expired_context and verify that it is still ready for deletion.
412         $expiredctx = $this->update_expired_context($expiredctx);
413         if (empty($expiredctx)) {
414             $this->get_progress()->output("Context has changed since approval and is no longer pending approval. Skipping", 1);
415             return false;
416         }
418         if (!$expiredctx->can_process_deletion()) {
419             // This only happens if the record was updated after being first fetched.
420             $this->get_progress()->output("Context has changed since approval and must be re-approved. Skipping", 1);
421             $expiredctx->set('status', expired_context::STATUS_EXPIRED);
422             $expiredctx->save();
424             return false;
425         }
427         $privacymanager = $this->get_privacy_manager();
428         if ($context instanceof \context_user) {
429             $this->delete_expired_user_context($expiredctx);
430         } else {
431             // This context is fully expired - that is that the default retention period has been reached.
432             $privacymanager->delete_data_for_all_users_in_context($context);
433         }
435         // Mark the record as cleaned.
436         $expiredctx->set('status', expired_context::STATUS_CLEANED);
437         $expiredctx->save();
439         return $context;
440     }
442     /**
443      * Deletes user data from the provided user context.
444      *
445      * @param expired_context $expiredctx
446      */
447     protected function delete_expired_user_context(expired_context $expiredctx) {
448         global $DB;
450         $contextid = $expiredctx->get('contextid');
451         $context = \context::instance_by_id($contextid);
452         $user = \core_user::get_user($context->instanceid, '*', MUST_EXIST);
454         $privacymanager = $this->get_privacy_manager();
456         // Delete all child contexts of the user context.
457         $parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
459         $params = [
460             'contextlevel'  => CONTEXT_USER,
461             'contextid'     => $expiredctx->get('contextid'),
462         ];
464         $fields = \context_helper::get_preload_record_columns_sql('ctx');
465         $sql = "SELECT ctx.id, $fields
466                   FROM {context} ctxuser
467                   JOIN {context} ctx ON ctx.path LIKE {$parentpath}
468                  WHERE ctxuser.contextlevel = :contextlevel AND ctxuser.id = :contextid
469               ORDER BY ctx.path DESC";
471         $children = $DB->get_recordset_sql($sql, $params);
472         foreach ($children as $child) {
473             \context_helper::preload_from_record($child);
474             $context = \context::instance_by_id($child->id);
476             $privacymanager->delete_data_for_all_users_in_context($context);
477         }
478         $children->close();
480         // Delete all unprotected data that the user holds.
481         $approvedlistcollection = new \core_privacy\local\request\contextlist_collection($user->id);
482         $contextlistcollection = $privacymanager->get_contexts_for_userid($user->id);
484         foreach ($contextlistcollection as $contextlist) {
485             $contextids = [];
486             $approvedlistcollection->add_contextlist(new \core_privacy\local\request\approved_contextlist(
487                     $user,
488                     $contextlist->get_component(),
489                     $contextlist->get_contextids()
490                 ));
491         }
492         $privacymanager->delete_data_for_user($approvedlistcollection, $this->get_progress());
494         // Delete the user context.
495         $context = \context::instance_by_id($expiredctx->get('contextid'));
496         $privacymanager->delete_data_for_all_users_in_context($context);
498         // This user is now fully expired - finish by deleting the user.
499         delete_user($user);
500     }
502     /**
503      * Whether end dates are required on all courses in order for a user to be expired from them.
504      *
505      * @return bool
506      */
507     protected static function require_all_end_dates_for_user_deletion() : bool {
508         $requireenddate = get_config('tool_dataprivacy', 'requireallenddatesforuserdeletion');
510         return !empty($requireenddate);
511     }
513     /**
514      * Check that the requirements to start deleting contexts are satisified.
515      *
516      * @return bool
517      */
518     protected function check_requirements() {
519         if (!data_registry::defaults_set()) {
520             return false;
521         }
522         return true;
523     }
525     /**
526      * Check whether a date is beyond the specified period.
527      *
528      * @param   string      $period The Expiry Period
529      * @param   int         $comparisondate The date for comparison
530      * @return  bool
531      */
532     protected static function has_expired(string $period, int $comparisondate) : bool {
533         $dt = new \DateTime();
534         $dt->setTimestamp($comparisondate);
535         $dt->add(new \DateInterval($period));
537         return (time() >= $dt->getTimestamp());
538     }
540     /**
541      * Get the expiry info object for the specified purpose and comparison date.
542      *
543      * @param   purpose     $purpose The purpose of this context
544      * @param   int         $comparisondate The date for comparison
545      * @return  expiry_info
546      */
547     protected static function get_expiry_info(purpose $purpose, int $comparisondate = 0) : expiry_info {
548         if (empty($comparisondate)) {
549             // The date is empty, therefore this context cannot be considered for automatic expiry.
550             $defaultexpired = false;
551         } else {
552             $defaultexpired = static::has_expired($purpose->get('retentionperiod'), $comparisondate);
553         }
555         return new expiry_info($defaultexpired);
556     }
558     /**
559      * Update or delete the expired_context from the expiry_info object.
560      * This function depends upon the data structure returned from get_nested_expiry_info.
561      *
562      * If the context is expired in any way, then an expired_context will be returned, otherwise null will be returned.
563      *
564      * @param   \stdClass   $expiryrecord
565      * @return  expired_context|null
566      */
567     protected function update_from_expiry_info(\stdClass $expiryrecord) {
568         if ($expiryrecord->info->is_any_expired()) {
569             // The context is expired in some fashion.
570             // Create or update as required.
571             if ($expiryrecord->record->expiredctxid) {
572                 $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
573                 $expiredcontext->update_from_expiry_info($expiryrecord->info);
575                 if ($expiredcontext->is_complete()) {
576                     return null;
577                 }
578             } else {
579                 $expiredcontext = expired_context::create_from_expiry_info($expiryrecord->context, $expiryrecord->info);
580             }
582             return $expiredcontext;
583         } else {
584             // The context is not expired.
585             if ($expiryrecord->record->expiredctxid) {
586                 // There was previously an expired context record, but it is no longer relevant.
587                 // Delete it to be on the safe side.
588                 $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
589                 $expiredcontext->delete();
590             }
592             return null;
593         }
594     }
596     /**
597      * Update the expired context record.
598      *
599      * Note: You should use the return value as the provided value will be used to fetch data only.
600      *
601      * @param   expired_context $expiredctx The record to update
602      * @return  expired_context|null
603      */
604     protected function update_expired_context(expired_context $expiredctx) {
605         // Fetch the context from the expired_context record.
606         $context = \context::instance_by_id($expiredctx->get('contextid'));
608         // Fetch the current nested expiry data.
609         $expiryrecords = self::get_nested_expiry_info($context->path);
611         // Find the current record.
612         if (empty($expiryrecords[$context->path])) {
613             $expiredctx->delete();
614             return null;
615         }
617         // Refresh the record.
618         // Note: Use the returned expiredctx.
619         $expiredctx = $this->update_from_expiry_info($expiryrecords[$context->path]);
620         if (empty($expiredctx)) {
621             return null;
622         }
624         if (!$context instanceof \context_user) {
625             // Where the target context is not a user, we check all children of the context.
626             // The expiryrecords array only contains children, fetched from the get_nested_expiry_info call above.
627             // No need to check that these _are_ children.
628             foreach ($expiryrecords as $expiryrecord) {
629                 if ($expiryrecord->context->id === $context->id) {
630                     // This is record for the context being tested that we checked earlier.
631                     continue;
632                 }
634                 if (empty($expiryrecord->record->expiredctxid)) {
635                     // There is no expired context record for this context.
636                     // If there is no record, then this context cannot have been approved for removal.
637                     return null;
638                 }
640                 // Fetch the expired_context object for this record.
641                 // This needs to be updated from the expiry_info data too as there may be child changes to consider.
642                 $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
643                 $expiredcontext->update_from_expiry_info($expiryrecord->info);
644                 if (!$expiredcontext->is_complete()) {
645                     return null;
646                 }
647             }
648         }
650         return $expiredctx;
651     }
653     /**
654      * Check whether the course has expired.
655      *
656      * @param   \stdClass   $course
657      * @return  bool
658      */
659     protected static function is_course_expired(\stdClass $course) : bool {
660         $context = \context_course::instance($course->id);
661         $expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
663         return !empty($expiryrecords[$context->path]) && $expiryrecords[$context->path]->info->is_fully_expired();
664     }
666     /**
667      * Create a new instance of the privacy manager.
668      *
669      * @return  manager
670      */
671     protected function get_privacy_manager() : manager {
672         if (null === $this->manager) {
673             $this->manager = new manager();
674             $this->manager->set_observer(new \tool_dataprivacy\manager_observer());
675         }
677         return $this->manager;
678     }
680     /**
681      * Fetch the limit for the maximum number of contexts to delete in one session.
682      *
683      * @return  int
684      */
685     protected function get_delete_limit() : int {
686         return self::DELETE_LIMIT;
687     }
689     /**
690      * Get the progress tracer.
691      *
692      * @return  \progress_trace
693      */
694     protected function get_progress() : \progress_trace {
695         if (null === $this->progresstracer) {
696             $this->set_progress(new \text_progress_trace());
697         }
699         return $this->progresstracer;
700     }
702     /**
703      * Set a specific tracer for the task.
704      *
705      * @param   \progress_trace $trace
706      * @return  $this
707      */
708     public function set_progress(\progress_trace $trace) : expired_contexts_manager {
709         $this->progresstracer = $trace;
711         return $this;
712     }