c660c0144f8095a9a6e2661d1ce8c3368cb52bb7
[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      *
232      * @param   string      $sql The SQL used to select the nested information.
233      * @param   array       $params The params required by the SQL.
234      * @return  \stdClass[]
235      */
236     protected static function get_nested_expiry_info_from_sql(string $sql, array $params) : array {
237         global $DB;
239         $fulllist = $DB->get_recordset_sql($sql, $params);
240         $datalist = [];
241         $expiredcontents = [];
242         $pathstoskip = [];
243         foreach ($fulllist as $record) {
244             \context_helper::preload_from_record($record);
245             $context = \context::instance_by_id($record->id, false);
247             if (!self::is_eligible_for_deletion($pathstoskip, $context)) {
248                 // We should skip this context, and therefore all of it's children.
249                 $datalist = array_filter($datalist, function($data, $path) use ($context) {
250                     // Remove any child of this context.
251                     // Technically this should never be fulfilled because the query is ordered in path DESC, but is kept
252                     // in to be certain.
253                     return (false === strpos($path, "{$context->path}/"));
254                 }, ARRAY_FILTER_USE_BOTH);
256                 if ($record->expiredctxid) {
257                     // There was previously an expired context record.
258                     // Delete it to be on the safe side.
259                     $expiredcontext = new expired_context(null, expired_context::extract_record($record, 'expiredctx'));
260                     $expiredcontext->delete();
261                 }
262                 continue;
263             }
265             $purposevalue = $record->purposeid !== null ? $record->purposeid : context_instance::NOTSET;
266             $purpose = api::get_effective_context_purpose($context, $purposevalue);
268             if ($context instanceof \context_user && !empty($record->userdeleted)) {
269                 $expiryinfo = static::get_expiry_info($purpose, $record->userdeleted);
270             } else {
271                 $expiryinfo = static::get_expiry_info($purpose, $record->expirydate);
272             }
273             foreach ($datalist as $path => $data) {
274                 // Merge with already-processed children.
275                 if (strpos($path, $context->path) !== 0) {
276                     continue;
277                 }
279                 $expiryinfo->merge_with_child($data->info);
280             }
281             $datalist[$context->path] = (object) [
282                 'context' => $context,
283                 'record' => $record,
284                 'purpose' => $purpose,
285                 'info' => $expiryinfo,
286             ];
287         }
288         $fulllist->close();
290         return $datalist;
291     }
293     /**
294      * Check whether the supplied context would be elible for deletion.
295      *
296      * @param   array       $pathstoskip A set of paths which should be skipped
297      * @param   \context    $context
298      * @return  bool
299      */
300     protected static function is_eligible_for_deletion(array &$pathstoskip, \context $context) : bool {
301         $shouldskip = false;
302         // Check whether any of the child contexts are ineligble.
303         $shouldskip = !empty(array_filter($pathstoskip, function($path) use ($context) {
304             // If any child context has already been skipped then it will appear in this list.
305             // Since paths include parents, test if the context under test appears as the haystack in the skipped
306             // context's needle.
307             return false !== (strpos($context->path, $path));
308         }));
310         if (!$shouldskip && $context instanceof \context_user) {
311             // The context instanceid is the user's ID.
312             if (isguestuser($context->instanceid) || is_siteadmin($context->instanceid)) {
313                 // This is an admin, or the guest and cannot be deleted.
314                 $shouldskip = true;
315             }
317             if (!$shouldskip) {
318                 $courses = enrol_get_users_courses($context->instanceid, false, ['enddate']);
319                 foreach ($courses as $course) {
320                     if (empty($course->enddate) || $course->enddate >= time()) {
321                         // This user still has an active enrolment.
322                         $shouldskip = true;
323                         break;
324                     }
325                 }
326             }
327         }
329         if ($shouldskip) {
330             // Add this to the list of contexts to skip for parentage checks.
331             $pathstoskip[] = $context->path;
332         }
334         return !$shouldskip;
335     }
337     /**
338      * Deletes the expired contexts.
339      *
340      * @return  int[]       The number of deleted contexts.
341      */
342     public function process_approved_deletions() : array {
343         if (!$this->check_requirements()) {
344             return [0, 0];
345         }
347         $expiredcontexts = expired_context::get_records(['status' => expired_context::STATUS_APPROVED]);
348         $totalprocessed = 0;
349         $usercount = 0;
350         $coursecount = 0;
351         foreach ($expiredcontexts as $expiredctx) {
352             $context = \context::instance_by_id($expiredctx->get('contextid'), IGNORE_MISSING);
353             if (empty($context)) {
354                 // Unable to process this request further.
355                 // We have no context to delete.
356                 $expiredctx->delete();
357                 continue;
358             }
360             if ($this->delete_expired_context($expiredctx)) {
361                 if ($context instanceof \context_user) {
362                     $usercount++;
363                 } else {
364                     $coursecount++;
365                 }
367                 $totalprocessed++;
368                 if ($totalprocessed >= $this->get_delete_limit()) {
369                     break;
370                 }
371             }
372         }
374         return [$coursecount, $usercount];
375     }
377     /**
378      * Deletes user data from the provided context.
379      *
380      * @param expired_context $expiredctx
381      * @return \context|false
382      */
383     protected function delete_expired_context(expired_context $expiredctx) {
384         $context = \context::instance_by_id($expiredctx->get('contextid'));
386         $this->get_progress()->output("Deleting context {$context->id} - " . $context->get_context_name(true, true));
388         // Update the expired_context and verify that it is still ready for deletion.
389         $expiredctx = $this->update_expired_context($expiredctx);
390         if (empty($expiredctx)) {
391             $this->get_progress()->output("Context has changed since approval and is no longer pending approval. Skipping", 1);
392             return false;
393         }
395         if (!$expiredctx->can_process_deletion()) {
396             // This only happens if the record was updated after being first fetched.
397             $this->get_progress()->output("Context has changed since approval and must be re-approved. Skipping", 1);
398             $expiredctx->set('status', expired_context::STATUS_EXPIRED);
399             $expiredctx->save();
401             return false;
402         }
404         $privacymanager = $this->get_privacy_manager();
405         if ($context instanceof \context_user) {
406             $this->delete_expired_user_context($expiredctx);
407         } else {
408             // This context is fully expired - that is that the default retention period has been reached.
409             $privacymanager->delete_data_for_all_users_in_context($context);
410         }
412         // Mark the record as cleaned.
413         $expiredctx->set('status', expired_context::STATUS_CLEANED);
414         $expiredctx->save();
416         return $context;
417     }
419     /**
420      * Deletes user data from the provided user context.
421      *
422      * @param expired_context $expiredctx
423      */
424     protected function delete_expired_user_context(expired_context $expiredctx) {
425         global $DB;
427         $contextid = $expiredctx->get('contextid');
428         $context = \context::instance_by_id($contextid);
429         $user = \core_user::get_user($context->instanceid, '*', MUST_EXIST);
431         $privacymanager = $this->get_privacy_manager();
433         // Delete all child contexts of the user context.
434         $parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
436         $params = [
437             'contextlevel'  => CONTEXT_USER,
438             'contextid'     => $expiredctx->get('contextid'),
439         ];
441         $fields = \context_helper::get_preload_record_columns_sql('ctx');
442         $sql = "SELECT ctx.id, $fields
443                   FROM {context} ctxuser
444                   JOIN {context} ctx ON ctx.path LIKE {$parentpath}
445                  WHERE ctxuser.contextlevel = :contextlevel AND ctxuser.id = :contextid
446               ORDER BY ctx.path DESC";
448         $children = $DB->get_recordset_sql($sql, $params);
449         foreach ($children as $child) {
450             \context_helper::preload_from_record($child);
451             $context = \context::instance_by_id($child->id);
453             $privacymanager->delete_data_for_all_users_in_context($context);
454         }
455         $children->close();
457         // Delete all unprotected data that the user holds.
458         $approvedlistcollection = new \core_privacy\local\request\contextlist_collection($user->id);
459         $contextlistcollection = $privacymanager->get_contexts_for_userid($user->id);
461         foreach ($contextlistcollection as $contextlist) {
462             $contextids = [];
463             $approvedlistcollection->add_contextlist(new \core_privacy\local\request\approved_contextlist(
464                     $user,
465                     $contextlist->get_component(),
466                     $contextlist->get_contextids()
467                 ));
468         }
469         $privacymanager->delete_data_for_user($approvedlistcollection, $this->get_progress());
471         // Delete the user context.
472         $context = \context::instance_by_id($expiredctx->get('contextid'));
473         $privacymanager->delete_data_for_all_users_in_context($context);
475         // This user is now fully expired - finish by deleting the user.
476         delete_user($user);
477     }
479     /**
480      * Check that the requirements to start deleting contexts are satisified.
481      *
482      * @return bool
483      */
484     protected function check_requirements() {
485         if (!data_registry::defaults_set()) {
486             return false;
487         }
488         return true;
489     }
491     /**
492      * Check whether a date is beyond the specified period.
493      *
494      * @param   string      $period The Expiry Period
495      * @param   int         $comparisondate The date for comparison
496      * @return  bool
497      */
498     protected static function has_expired(string $period, int $comparisondate) : bool {
499         $dt = new \DateTime();
500         $dt->setTimestamp($comparisondate);
501         $dt->add(new \DateInterval($period));
503         return (time() >= $dt->getTimestamp());
504     }
506     /**
507      * Get the expiry info object for the specified purpose and comparison date.
508      *
509      * @param   purpose     $purpose The purpose of this context
510      * @param   int         $comparisondate The date for comparison
511      * @return  expiry_info
512      */
513     protected static function get_expiry_info(purpose $purpose, int $comparisondate = 0) : expiry_info {
514         if (empty($comparisondate)) {
515             // The date is empty, therefore this context cannot be considered for automatic expiry.
516             $defaultexpired = false;
517         } else {
518             $defaultexpired = static::has_expired($purpose->get('retentionperiod'), $comparisondate);
519         }
521         return new expiry_info($defaultexpired);
522     }
524     /**
525      * Update or delete the expired_context from the expiry_info object.
526      * This function depends upon the data structure returned from get_nested_expiry_info.
527      *
528      * If the context is expired in any way, then an expired_context will be returned, otherwise null will be returned.
529      *
530      * @param   \stdClass   $expiryrecord
531      * @return  expired_context|null
532      */
533     protected function update_from_expiry_info(\stdClass $expiryrecord) {
534         if ($expiryrecord->info->is_any_expired()) {
535             // The context is expired in some fashion.
536             // Create or update as required.
537             if ($expiryrecord->record->expiredctxid) {
538                 $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
539                 $expiredcontext->update_from_expiry_info($expiryrecord->info);
541                 if ($expiredcontext->is_complete()) {
542                     return null;
543                 }
544             } else {
545                 $expiredcontext = expired_context::create_from_expiry_info($expiryrecord->context, $expiryrecord->info);
546             }
548             return $expiredcontext;
549         } else {
550             // The context is not expired.
551             if ($expiryrecord->record->expiredctxid) {
552                 // There was previously an expired context record, but it is no longer relevant.
553                 // Delete it to be on the safe side.
554                 $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
555                 $expiredcontext->delete();
556             }
558             return null;
559         }
560     }
562     /**
563      * Update the expired context record.
564      *
565      * Note: You should use the return value as the provided value will be used to fetch data only.
566      *
567      * @param   expired_context $expiredctx The record to update
568      * @return  expired_context|null
569      */
570     protected function update_expired_context(expired_context $expiredctx) {
571         // Fetch the context from the expired_context record.
572         $context = \context::instance_by_id($expiredctx->get('contextid'));
574         // Fetch the current nested expiry data.
575         $expiryrecords = self::get_nested_expiry_info($context->path);
577         // Find the current record.
578         if (empty($expiryrecords[$context->path])) {
579             $expiredctx->delete();
580             return null;
581         }
583         // Refresh the record.
584         // Note: Use the returned expiredctx.
585         $expiredctx = $this->update_from_expiry_info($expiryrecords[$context->path]);
586         if (empty($expiredctx)) {
587             return null;
588         }
590         if (!$context instanceof \context_user) {
591             // Where the target context is not a user, we check all children of the context.
592             // The expiryrecords array only contains children, fetched from the get_nested_expiry_info call above.
593             // No need to check that these _are_ children.
594             foreach ($expiryrecords as $expiryrecord) {
595                 if ($expiryrecord->context->id === $context->id) {
596                     // This is record for the context being tested that we checked earlier.
597                     continue;
598                 }
600                 if (empty($expiryrecord->record->expiredctxid)) {
601                     // There is no expired context record for this context.
602                     // If there is no record, then this context cannot have been approved for removal.
603                     return null;
604                 }
606                 // Fetch the expired_context object for this record.
607                 // This needs to be updated from the expiry_info data too as there may be child changes to consider.
608                 $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
609                 $expiredcontext->update_from_expiry_info($expiryrecord->info);
610                 if (!$expiredcontext->is_complete()) {
611                     return null;
612                 }
613             }
614         }
616         return $expiredctx;
617     }
619     /**
620      * Create a new instance of the privacy manager.
621      *
622      * @return  manager
623      */
624     protected function get_privacy_manager() : manager {
625         if (null === $this->manager) {
626             $this->manager = new manager();
627             $this->manager->set_observer(new \tool_dataprivacy\manager_observer());
628         }
630         return $this->manager;
631     }
633     /**
634      * Fetch the limit for the maximum number of contexts to delete in one session.
635      *
636      * @return  int
637      */
638     protected function get_delete_limit() : int {
639         return self::DELETE_LIMIT;
640     }
642     /**
643      * Get the progress tracer.
644      *
645      * @return  \progress_trace
646      */
647     protected function get_progress() : \progress_trace {
648         if (null === $this->progresstracer) {
649             $this->set_progress(new \text_progress_trace());
650         }
652         return $this->progresstracer;
653     }
655     /**
656      * Set a specific tracer for the task.
657      *
658      * @param   \progress_trace $trace
659      * @return  $this
660      */
661     public function set_progress(\progress_trace $trace) : expired_contexts_manager {
662         $this->progresstracer = $trace;
664         return $this;
665     }