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.
231 * Only contexts which have expired will be included.
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[]
237 protected static function get_nested_expiry_info_from_sql(string $sql, array $params) : array {
240 $fulllist = $DB->get_recordset_sql($sql, $params);
242 $expiredcontents = [];
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
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();
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);
272 $expiryinfo = static::get_expiry_info($purpose, $record->expirydate);
274 foreach ($datalist as $path => $data) {
275 // Merge with already-processed children.
276 if (strpos($path, $context->path) !== 0) {
280 $expiryinfo->merge_with_child($data->info);
282 $datalist[$context->path] = (object) [
283 'context' => $context,
285 'purpose' => $purpose,
286 'info' => $expiryinfo,
295 * Check whether the supplied context would be elible for deletion.
297 * @param array $pathstoskip A set of paths which should be skipped
298 * @param \context $context
301 protected static function is_eligible_for_deletion(array &$pathstoskip, \context $context) : bool {
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
308 return false !== (strpos($context->path, $path));
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.
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.
331 // Course end dates are not required. The subsequent checks are pointless at this time so just
336 if ($course->enddate >= time()) {
337 // This course is still in the future.
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.
353 // Add this to the list of contexts to skip for parentage checks.
354 $pathstoskip[] = $context->path;
361 * Deletes the expired contexts.
363 * @return int[] The number of deleted contexts.
365 public function process_approved_deletions() : array {
366 if (!$this->check_requirements()) {
370 $expiredcontexts = expired_context::get_records(['status' => expired_context::STATUS_APPROVED]);
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();
383 if ($this->delete_expired_context($expiredctx)) {
384 if ($context instanceof \context_user) {
391 if ($totalprocessed >= $this->get_delete_limit()) {
397 return [$coursecount, $usercount];
401 * Deletes user data from the provided context.
403 * @param expired_context $expiredctx
404 * @return \context|false
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);
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);
427 $privacymanager = $this->get_privacy_manager();
428 if ($context instanceof \context_user) {
429 $this->delete_expired_user_context($expiredctx);
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);
435 // Mark the record as cleaned.
436 $expiredctx->set('status', expired_context::STATUS_CLEANED);
443 * Deletes user data from the provided user context.
445 * @param expired_context $expiredctx
447 protected function delete_expired_user_context(expired_context $expiredctx) {
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', "'/%'");
460 'contextlevel' => CONTEXT_USER,
461 'contextid' => $expiredctx->get('contextid'),
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);
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) {
486 $approvedlistcollection->add_contextlist(new \core_privacy\local\request\approved_contextlist(
488 $contextlist->get_component(),
489 $contextlist->get_contextids()
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.
503 * Whether end dates are required on all courses in order for a user to be expired from them.
507 protected static function require_all_end_dates_for_user_deletion() : bool {
508 $requireenddate = get_config('tool_dataprivacy', 'requireallenddatesforuserdeletion');
510 return !empty($requireenddate);
514 * Check that the requirements to start deleting contexts are satisified.
518 protected function check_requirements() {
519 if (!data_registry::defaults_set()) {
526 * Check whether a date is beyond the specified period.
528 * @param string $period The Expiry Period
529 * @param int $comparisondate The date for comparison
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());
541 * Get the expiry info object for the specified purpose and comparison date.
543 * @param purpose $purpose The purpose of this context
544 * @param int $comparisondate The date for comparison
545 * @return expiry_info
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;
552 $defaultexpired = static::has_expired($purpose->get('retentionperiod'), $comparisondate);
555 return new expiry_info($defaultexpired);
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.
562 * If the context is expired in any way, then an expired_context will be returned, otherwise null will be returned.
564 * @param \stdClass $expiryrecord
565 * @return expired_context|null
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()) {
579 $expiredcontext = expired_context::create_from_expiry_info($expiryrecord->context, $expiryrecord->info);
582 return $expiredcontext;
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();
597 * Update the expired context record.
599 * Note: You should use the return value as the provided value will be used to fetch data only.
601 * @param expired_context $expiredctx The record to update
602 * @return expired_context|null
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();
617 // Refresh the record.
618 // Note: Use the returned expiredctx.
619 $expiredctx = $this->update_from_expiry_info($expiryrecords[$context->path]);
620 if (empty($expiredctx)) {
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.
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.
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()) {
654 * Check whether the course has expired.
656 * @param \stdClass $course
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();
667 * Create a new instance of the privacy manager.
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());
677 return $this->manager;
681 * Fetch the limit for the maximum number of contexts to delete in one session.
685 protected function get_delete_limit() : int {
686 return self::DELETE_LIMIT;
690 * Get the progress tracer.
692 * @return \progress_trace
694 protected function get_progress() : \progress_trace {
695 if (null === $this->progresstracer) {
696 $this->set_progress(new \text_progress_trace());
699 return $this->progresstracer;
703 * Set a specific tracer for the task.
705 * @param \progress_trace $trace
708 public function set_progress(\progress_trace $trace) : expired_contexts_manager {
709 $this->progresstracer = $trace;