2 // This file is part of Moodle - http://moodle.org/
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.
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.
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/>.
18 * Expired contexts manager.
20 * @package tool_dataprivacy
21 * @copyright 2018 David Monllao
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 namespace tool_dataprivacy;
26 use core_privacy\manager;
27 use tool_dataprivacy\expired_context;
29 defined('MOODLE_INTERNAL') || die();
32 * Expired contexts manager.
34 * @copyright 2018 David Monllao
35 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37 class expired_contexts_manager {
40 * Number of deleted contexts for each scheduled task run.
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;
51 * Flag expired contexts as expired.
53 * @return int[] The number of contexts flagged as expired for courses, and users.
55 public function flag_expired_contexts() : array {
56 if (!$this->check_requirements()) {
60 // Clear old and stale records first.
61 static::clear_old_records();
63 $data = static::get_nested_expiry_info_for_courses();
65 foreach ($data as $expiryrecord) {
66 if ($this->update_from_expiry_info($expiryrecord)) {
71 $data = static::get_nested_expiry_info_for_user();
73 foreach ($data as $expiryrecord) {
74 if ($this->update_from_expiry_info($expiryrecord)) {
79 return [$coursecount, $usercount];
83 * Clear old and stale records.
85 protected static function clear_old_records() {
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();
99 // Delete any child of a user context.
100 $parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
102 'contextuser' => CONTEXT_USER,
105 $sql = "SELECT dpctx.*
106 FROM {tool_dataprivacy_ctxexpired} dpctx
107 WHERE dpctx.contextid IN (
109 FROM {context} ctxuser
110 JOIN {context} ctx ON ctx.path LIKE {$parentpath}
111 WHERE ctxuser.contextlevel = :contextuser
113 $userchildren = $DB->get_recordset_sql($sql, $params);
114 foreach ($userchildren as $child) {
115 $expiredcontext = new expired_context(0, $child);
116 $expiredcontext->delete();
121 * Get the full nested set of expiry data relating to all contexts.
123 * @param string $contextpath A contexpath to restrict results to
124 * @return \stdClass[]
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);
134 * Get the full nested set of expiry data relating to course-related contexts.
136 * @param string $contextpath A contexpath to restrict results to
137 * @return \stdClass[]
139 protected static function get_nested_expiry_info_for_courses($contextpath = '') : array {
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.
155 'contextlevel' => CONTEXT_COURSE,
159 if (!empty($contextpath)) {
160 $where = "AND (ctx.path = :pathmatchexact OR ctx.path LIKE :pathmatchchildren)";
161 $params['pathmatchexact'] = $contextpath;
162 $params['pathmatchchildren'] = "{$contextpath}/%";
165 $sql = "SELECT $fields
168 SELECT c.enddate AS expirydate, subctx.path
169 FROM {context} subctx
171 ON subctx.contextlevel = :contextlevel
172 AND subctx.instanceid = c.id
173 AND c.format != 'site'
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
181 ORDER BY ctx.path DESC";
183 return self::get_nested_expiry_info_from_sql($sql, $params);
187 * Get the full nested set of expiry data.
189 * @param string $contextpath A contexpath to restrict results to
190 * @return \stdClass[]
192 protected static function get_nested_expiry_info_for_user($contextpath = '') : array {
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.
207 'contextlevel' => CONTEXT_USER,
211 if (!empty($contextpath)) {
212 $where = "AND ctx.path = :pathmatchexact";
213 $params['pathmatchexact'] = $contextpath;
216 $sql = "SELECT $fields, u.deleted AS userdeleted
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);
230 * Get the full nested set of expiry data given appropriate SQL.
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[]
236 protected static function get_nested_expiry_info_from_sql(string $sql, array $params) : array {
239 $fulllist = $DB->get_recordset_sql($sql, $params);
241 $expiredcontents = [];
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
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();
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);
271 $expiryinfo = static::get_expiry_info($purpose, $record->expirydate);
273 foreach ($datalist as $path => $data) {
274 // Merge with already-processed children.
275 if (strpos($path, $context->path) !== 0) {
279 $expiryinfo->merge_with_child($data->info);
281 $datalist[$context->path] = (object) [
282 'context' => $context,
284 'purpose' => $purpose,
285 'info' => $expiryinfo,
294 * Check whether the supplied context would be elible for deletion.
296 * @param array $pathstoskip A set of paths which should be skipped
297 * @param \context $context
300 protected static function is_eligible_for_deletion(array &$pathstoskip, \context $context) : bool {
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
307 return false !== (strpos($context->path, $path));
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.
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.
330 // Add this to the list of contexts to skip for parentage checks.
331 $pathstoskip[] = $context->path;
338 * Deletes the expired contexts.
340 * @return int[] The number of deleted contexts.
342 public function process_approved_deletions() : array {
343 if (!$this->check_requirements()) {
347 $expiredcontexts = expired_context::get_records(['status' => expired_context::STATUS_APPROVED]);
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();
360 if ($this->delete_expired_context($expiredctx)) {
361 if ($context instanceof \context_user) {
368 if ($totalprocessed >= $this->get_delete_limit()) {
374 return [$coursecount, $usercount];
378 * Deletes user data from the provided context.
380 * @param expired_context $expiredctx
381 * @return \context|false
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);
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);
404 $privacymanager = $this->get_privacy_manager();
405 if ($context instanceof \context_user) {
406 $this->delete_expired_user_context($expiredctx);
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);
412 // Mark the record as cleaned.
413 $expiredctx->set('status', expired_context::STATUS_CLEANED);
420 * Deletes user data from the provided user context.
422 * @param expired_context $expiredctx
424 protected function delete_expired_user_context(expired_context $expiredctx) {
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', "'/%'");
437 'contextlevel' => CONTEXT_USER,
438 'contextid' => $expiredctx->get('contextid'),
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);
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) {
463 $approvedlistcollection->add_contextlist(new \core_privacy\local\request\approved_contextlist(
465 $contextlist->get_component(),
466 $contextlist->get_contextids()
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.
480 * Check that the requirements to start deleting contexts are satisified.
484 protected function check_requirements() {
485 if (!data_registry::defaults_set()) {
492 * Check whether a date is beyond the specified period.
494 * @param string $period The Expiry Period
495 * @param int $comparisondate The date for comparison
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());
507 * Get the expiry info object for the specified purpose and comparison date.
509 * @param purpose $purpose The purpose of this context
510 * @param int $comparisondate The date for comparison
511 * @return expiry_info
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;
518 $defaultexpired = static::has_expired($purpose->get('retentionperiod'), $comparisondate);
521 return new expiry_info($defaultexpired);
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.
528 * If the context is expired in any way, then an expired_context will be returned, otherwise null will be returned.
530 * @param \stdClass $expiryrecord
531 * @return expired_context|null
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()) {
545 $expiredcontext = expired_context::create_from_expiry_info($expiryrecord->context, $expiryrecord->info);
548 return $expiredcontext;
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();
563 * Update the expired context record.
565 * Note: You should use the return value as the provided value will be used to fetch data only.
567 * @param expired_context $expiredctx The record to update
568 * @return expired_context|null
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();
583 // Refresh the record.
584 // Note: Use the returned expiredctx.
585 $expiredctx = $this->update_from_expiry_info($expiryrecords[$context->path]);
586 if (empty($expiredctx)) {
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.
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.
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()) {
620 * Create a new instance of the privacy manager.
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());
630 return $this->manager;
634 * Fetch the limit for the maximum number of contexts to delete in one session.
638 protected function get_delete_limit() : int {
639 return self::DELETE_LIMIT;
643 * Get the progress tracer.
645 * @return \progress_trace
647 protected function get_progress() : \progress_trace {
648 if (null === $this->progresstracer) {
649 $this->set_progress(new \text_progress_trace());
652 return $this->progresstracer;
656 * Set a specific tracer for the task.
658 * @param \progress_trace $trace
661 public function set_progress(\progress_trace $trace) : expired_contexts_manager {
662 $this->progresstracer = $trace;