From 3e90c7df3334cb5eb783c0f4fefb8bb8866e010f Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Thu, 20 Sep 2018 09:10:38 +0800 Subject: [PATCH] MDL-63401 tool_dataprivacy: Rewrite expired deletion handling This change rewrites the way in which expiry is calculated and addresses a number of closely related issues: Users can customise, and add blocks with data to, their dashboard. When a user had done so, the user could be flagged for deletion, but the blocks in their Dashboard were subject to the default block retention policy. In addition there is no way to override the retention policy for user blocks. This change modifies the structure of the expiry mechanism: - to consider any subcontext of the user context to be a part of the user context during calculation. User child contexts are not the property of the system, and should not be treated separately. - the way in which the context expiry mechanism worked was to select use a multiple different managers based solely on the context level. Because of the use of user blocks, this proved to be unreliable as blocks has been attributed purely to courses. This has been changed to a single manager which is aware of hierarchy and deletions as a whole. - to prepare for upcoming work relating to more detailed expiry mechanisms, a new expiry_info class is introduced and used to merge the expiry of child contexts into a working in-memory view. This changeset includes extensive unit tests. --- admin/tool/dataprivacy/classes/api.php | 30 +- .../dataprivacy/classes/data_registry.php | 39 +- .../dataprivacy/classes/expired_context.php | 47 + .../classes/expired_contexts_manager.php | 622 ++++++- .../expired_course_related_contexts.php | 135 -- .../classes/expired_user_contexts.php | 150 -- .../tool/dataprivacy/classes/expiry_info.php | 93 + admin/tool/dataprivacy/classes/external.php | 43 +- .../classes/task/delete_expired_contexts.php | 14 +- .../classes/task/expired_retention_period.php | 13 +- admin/tool/dataprivacy/tests/api_test.php | 47 - .../tests/expired_contexts_test.php | 1492 +++++++++++++++-- 12 files changed, 2092 insertions(+), 633 deletions(-) delete mode 100644 admin/tool/dataprivacy/classes/expired_course_related_contexts.php delete mode 100644 admin/tool/dataprivacy/classes/expired_user_contexts.php create mode 100644 admin/tool/dataprivacy/classes/expiry_info.php diff --git a/admin/tool/dataprivacy/classes/api.php b/admin/tool/dataprivacy/classes/api.php index 3f7fb5e825e..ee22ee0a56e 100644 --- a/admin/tool/dataprivacy/classes/api.php +++ b/admin/tool/dataprivacy/classes/api.php @@ -929,7 +929,7 @@ class api { * @param int $forcedvalue Use this purposeid value as if this was this context instance purpose. * @return purpose|false */ - public static function get_effective_context_purpose(\context $context, $forcedvalue=false) { + public static function get_effective_context_purpose(\context $context, $forcedvalue = false) { if (!data_registry::defaults_set()) { return false; } @@ -967,34 +967,6 @@ class api { return data_registry::get_effective_contextlevel_value($contextlevel, 'purpose', $forcedvalue); } - /** - * Creates an expired context record for the provided context id. - * - * @param int $contextid - * @return \tool_dataprivacy\expired_context - */ - public static function create_expired_context($contextid) { - $record = (object)[ - 'contextid' => $contextid, - 'status' => expired_context::STATUS_EXPIRED, - ]; - $expiredctx = new expired_context(0, $record); - $expiredctx->save(); - - return $expiredctx; - } - - /** - * Deletes an expired context record. - * - * @param int $id The tool_dataprivacy_ctxexpire id. - * @return bool True on success. - */ - public static function delete_expired_context($id) { - $expiredcontext = new expired_context($id); - return $expiredcontext->delete(); - } - /** * Updates the status of an expired context. * diff --git a/admin/tool/dataprivacy/classes/data_registry.php b/admin/tool/dataprivacy/classes/data_registry.php index e6af0424b40..7b444978c46 100644 --- a/admin/tool/dataprivacy/classes/data_registry.php +++ b/admin/tool/dataprivacy/classes/data_registry.php @@ -191,14 +191,14 @@ class data_registry { * @param int|false $forcedvalue Use this value as if this was this context instance value. * @return persistent|false It return a 'purpose' instance or a 'category' instance, depending on $element */ - public static function get_effective_context_value(\context $context, $element, $forcedvalue=false) { + public static function get_effective_context_value(\context $context, $element, $forcedvalue = false) { if ($element !== 'purpose' && $element !== 'category') { throw new coding_exception('Only \'purpose\' and \'category\' are supported.'); } $fieldname = $element . 'id'; - if ($forcedvalue === false) { + if (empty($forcedvalue)) { $instance = context_instance::get_record_by_contextid($context->id, false); if (!$instance) { @@ -217,20 +217,29 @@ class data_registry { // The effective value varies depending on the context level. if ($context->contextlevel == CONTEXT_USER) { // Use the context level value as we don't allow people to set specific instances values. - return self::get_effective_contextlevel_value($context->contextlevel, $element); - } else { - // Check if we need to pass the plugin name of an activity. - $forplugin = ''; - if ($context->contextlevel == CONTEXT_MODULE) { - list($course, $cm) = get_course_and_cm_from_cmid($context->instanceid); - $forplugin = $cm->modname; - } - // Use the default context level value. - list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category( - $context->contextlevel, false, false, $forplugin - ); - return self::get_element_instance($element, $$fieldname); + return self::get_effective_contextlevel_value(CONTEXT_USER, $element); } + + $parents = $context->get_parent_contexts(true); + foreach ($parents as $parent) { + if ($parent->contextlevel == CONTEXT_USER) { + // Use the context level value as we don't allow people to set specific instances values. + return self::get_effective_contextlevel_value(CONTEXT_USER, $element); + } + } + + // Check if we need to pass the plugin name of an activity. + $forplugin = ''; + if ($context->contextlevel == CONTEXT_MODULE) { + list($course, $cm) = get_course_and_cm_from_cmid($context->instanceid); + $forplugin = $cm->modname; + } + // Use the default context level value. + list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category( + $context->contextlevel, false, false, $forplugin + ); + + return self::get_element_instance($element, $$fieldname); } // Specific value for this context instance. diff --git a/admin/tool/dataprivacy/classes/expired_context.php b/admin/tool/dataprivacy/classes/expired_context.php index 30104388ba5..5ac228deb1b 100644 --- a/admin/tool/dataprivacy/classes/expired_context.php +++ b/admin/tool/dataprivacy/classes/expired_context.php @@ -159,4 +159,51 @@ class expired_context extends \core\persistent { return $DB->count_records_sql($sql, $params); } + + /** + * Create a new expired_context based on the context, and expiry_info object. + * + * @param \context $context + * @param expiry_info $info + * @return expired_context + */ + public static function create_from_expiry_info(\context $context, expiry_info $info) : expired_context { + $record = (object) [ + 'contextid' => $context->id, + 'status' => self::STATUS_EXPIRED, + ]; + + $expiredcontext = new static(0, $record); + $expiredcontext->save(); + + return $expiredcontext; + } + + /** + * Update the expired_context from an expiry_info object which relates to this context. + * + * @param expiry_info $info + * @return $this + */ + public function update_from_expiry_info(expiry_info $info) : expired_context { + return $this; + } + + /** + * Check whether this expired_context record is in a state ready for deletion to actually take place. + * + * @return bool + */ + public function can_process_deletion() : bool { + return ($this->get('status') == self::STATUS_APPROVED); + } + + /** + * Check whether this expired_context record has already been cleaned. + * + * @return bool + */ + public function is_complete() : bool { + return ($this->get('status') == self::STATUS_CLEANED); + } } diff --git a/admin/tool/dataprivacy/classes/expired_contexts_manager.php b/admin/tool/dataprivacy/classes/expired_contexts_manager.php index 539fc289a48..c660c0144f8 100644 --- a/admin/tool/dataprivacy/classes/expired_contexts_manager.php +++ b/admin/tool/dataprivacy/classes/expired_contexts_manager.php @@ -34,121 +34,633 @@ defined('MOODLE_INTERNAL') || die(); * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -abstract class expired_contexts_manager { +class expired_contexts_manager { /** * Number of deleted contexts for each scheduled task run. */ const DELETE_LIMIT = 200; - /** - * Returns the list of expired context instances. - * - * @return \stdClass[] - */ - abstract protected function get_expired_contexts(); + /** @var progress_trace The log progress tracer */ + protected $progresstracer = null; - /** - * Specify with context levels this expired contexts manager is deleting. - * - * @return int[] - */ - abstract protected function get_context_levels(); + /** @var manager The privacy manager */ + protected $manager = null; /** * Flag expired contexts as expired. * - * @return int The number of contexts flagged as expired. + * @return int[] The number of contexts flagged as expired for courses, and users. */ - public function flag_expired() { - + public function flag_expired_contexts() : array { if (!$this->check_requirements()) { - return 0; + return [0, 0]; } - $contexts = $this->get_expired_contexts(); - foreach ($contexts as $context) { - api::create_expired_context($context->id); + // Clear old and stale records first. + static::clear_old_records(); + + $data = static::get_nested_expiry_info_for_courses(); + $coursecount = 0; + foreach ($data as $expiryrecord) { + if ($this->update_from_expiry_info($expiryrecord)) { + $coursecount++; + } } - return count($contexts); + $data = static::get_nested_expiry_info_for_user(); + $usercount = 0; + foreach ($data as $expiryrecord) { + if ($this->update_from_expiry_info($expiryrecord)) { + $usercount++; + } + } + + return [$coursecount, $usercount]; + } + + /** + * Clear old and stale records. + */ + protected static function clear_old_records() { + global $DB; + + $sql = "SELECT dpctx.* + FROM {tool_dataprivacy_ctxexpired} dpctx + LEFT JOIN {context} ctx ON ctx.id = dpctx.contextid + WHERE ctx.id IS NULL"; + + $orphaned = $DB->get_recordset_sql($sql); + foreach ($orphaned as $orphan) { + $expiredcontext = new expired_context(0, $orphan); + $expiredcontext->delete(); + } + + // Delete any child of a user context. + $parentpath = $DB->sql_concat('ctxuser.path', "'/%'"); + $params = [ + 'contextuser' => CONTEXT_USER, + ]; + + $sql = "SELECT dpctx.* + FROM {tool_dataprivacy_ctxexpired} dpctx + WHERE dpctx.contextid IN ( + SELECT ctx.id + FROM {context} ctxuser + JOIN {context} ctx ON ctx.path LIKE {$parentpath} + WHERE ctxuser.contextlevel = :contextuser + )"; + $userchildren = $DB->get_recordset_sql($sql, $params); + foreach ($userchildren as $child) { + $expiredcontext = new expired_context(0, $child); + $expiredcontext->delete(); + } + } + + /** + * Get the full nested set of expiry data relating to all contexts. + * + * @param string $contextpath A contexpath to restrict results to + * @return \stdClass[] + */ + protected static function get_nested_expiry_info($contextpath = '') : array { + $coursepaths = self::get_nested_expiry_info_for_courses($contextpath); + $userpaths = self::get_nested_expiry_info_for_user($contextpath); + + return array_merge($coursepaths, $userpaths); + } + + /** + * Get the full nested set of expiry data relating to course-related contexts. + * + * @param string $contextpath A contexpath to restrict results to + * @return \stdClass[] + */ + protected static function get_nested_expiry_info_for_courses($contextpath = '') : array { + global $DB; + + $contextfields = \context_helper::get_preload_record_columns_sql('ctx'); + $expiredfields = expired_context::get_sql_fields('expiredctx', 'expiredctx'); + $purposefields = 'dpctx.purposeid'; + $coursefields = 'ctxcourse.expirydate AS expirydate'; + $fields = implode(', ', ['ctx.id', $contextfields, $expiredfields, $coursefields, $purposefields]); + + // We want all contexts at course-dependant levels. + $parentpath = $DB->sql_concat('ctxcourse.path', "'/%'"); + + // This SQL query returns all course-dependant contexts (including the course context) + // which course end date already passed. + // This is ordered by the context path in reverse order, which will give the child nodes before any parent node. + $params = [ + 'contextlevel' => CONTEXT_COURSE, + ]; + $where = ''; + + if (!empty($contextpath)) { + $where = "AND (ctx.path = :pathmatchexact OR ctx.path LIKE :pathmatchchildren)"; + $params['pathmatchexact'] = $contextpath; + $params['pathmatchchildren'] = "{$contextpath}/%"; + } + + $sql = "SELECT $fields + FROM {context} ctx + JOIN ( + SELECT c.enddate AS expirydate, subctx.path + FROM {context} subctx + JOIN {course} c + ON subctx.contextlevel = :contextlevel + AND subctx.instanceid = c.id + AND c.format != 'site' + ) ctxcourse + ON ctx.path LIKE {$parentpath} OR ctx.path = ctxcourse.path + LEFT JOIN {tool_dataprivacy_ctxinstance} dpctx + ON dpctx.contextid = ctx.id + LEFT JOIN {tool_dataprivacy_ctxexpired} expiredctx + ON ctx.id = expiredctx.contextid + WHERE 1 = 1 {$where} + ORDER BY ctx.path DESC"; + + return self::get_nested_expiry_info_from_sql($sql, $params); + } + + /** + * Get the full nested set of expiry data. + * + * @param string $contextpath A contexpath to restrict results to + * @return \stdClass[] + */ + protected static function get_nested_expiry_info_for_user($contextpath = '') : array { + global $DB; + + $contextfields = \context_helper::get_preload_record_columns_sql('ctx'); + $expiredfields = expired_context::get_sql_fields('expiredctx', 'expiredctx'); + $purposefields = 'dpctx.purposeid'; + $userfields = 'u.lastaccess AS expirydate'; + $fields = implode(', ', ['ctx.id', $contextfields, $expiredfields, $userfields, $purposefields]); + + // We want all contexts at user-dependant levels. + $parentpath = $DB->sql_concat('ctxuser.path', "'/%'"); + + // This SQL query returns all user-dependant contexts (including the user context) + // This is ordered by the context path in reverse order, which will give the child nodes before any parent node. + $params = [ + 'contextlevel' => CONTEXT_USER, + ]; + $where = ''; + + if (!empty($contextpath)) { + $where = "AND ctx.path = :pathmatchexact"; + $params['pathmatchexact'] = $contextpath; + } + + $sql = "SELECT $fields, u.deleted AS userdeleted + FROM {context} ctx + JOIN {user} u ON ctx.instanceid = u.id + LEFT JOIN {tool_dataprivacy_ctxinstance} dpctx + ON dpctx.contextid = ctx.id + LEFT JOIN {tool_dataprivacy_ctxexpired} expiredctx + ON ctx.id = expiredctx.contextid + WHERE ctx.contextlevel = :contextlevel {$where} + ORDER BY ctx.path DESC"; + + return self::get_nested_expiry_info_from_sql($sql, $params); + } + + /** + * Get the full nested set of expiry data given appropriate SQL. + * + * @param string $sql The SQL used to select the nested information. + * @param array $params The params required by the SQL. + * @return \stdClass[] + */ + protected static function get_nested_expiry_info_from_sql(string $sql, array $params) : array { + global $DB; + + $fulllist = $DB->get_recordset_sql($sql, $params); + $datalist = []; + $expiredcontents = []; + $pathstoskip = []; + foreach ($fulllist as $record) { + \context_helper::preload_from_record($record); + $context = \context::instance_by_id($record->id, false); + + if (!self::is_eligible_for_deletion($pathstoskip, $context)) { + // We should skip this context, and therefore all of it's children. + $datalist = array_filter($datalist, function($data, $path) use ($context) { + // Remove any child of this context. + // Technically this should never be fulfilled because the query is ordered in path DESC, but is kept + // in to be certain. + return (false === strpos($path, "{$context->path}/")); + }, ARRAY_FILTER_USE_BOTH); + + if ($record->expiredctxid) { + // There was previously an expired context record. + // Delete it to be on the safe side. + $expiredcontext = new expired_context(null, expired_context::extract_record($record, 'expiredctx')); + $expiredcontext->delete(); + } + continue; + } + + $purposevalue = $record->purposeid !== null ? $record->purposeid : context_instance::NOTSET; + $purpose = api::get_effective_context_purpose($context, $purposevalue); + + if ($context instanceof \context_user && !empty($record->userdeleted)) { + $expiryinfo = static::get_expiry_info($purpose, $record->userdeleted); + } else { + $expiryinfo = static::get_expiry_info($purpose, $record->expirydate); + } + foreach ($datalist as $path => $data) { + // Merge with already-processed children. + if (strpos($path, $context->path) !== 0) { + continue; + } + + $expiryinfo->merge_with_child($data->info); + } + $datalist[$context->path] = (object) [ + 'context' => $context, + 'record' => $record, + 'purpose' => $purpose, + 'info' => $expiryinfo, + ]; + } + $fulllist->close(); + + return $datalist; + } + + /** + * Check whether the supplied context would be elible for deletion. + * + * @param array $pathstoskip A set of paths which should be skipped + * @param \context $context + * @return bool + */ + protected static function is_eligible_for_deletion(array &$pathstoskip, \context $context) : bool { + $shouldskip = false; + // Check whether any of the child contexts are ineligble. + $shouldskip = !empty(array_filter($pathstoskip, function($path) use ($context) { + // If any child context has already been skipped then it will appear in this list. + // Since paths include parents, test if the context under test appears as the haystack in the skipped + // context's needle. + return false !== (strpos($context->path, $path)); + })); + + if (!$shouldskip && $context instanceof \context_user) { + // The context instanceid is the user's ID. + if (isguestuser($context->instanceid) || is_siteadmin($context->instanceid)) { + // This is an admin, or the guest and cannot be deleted. + $shouldskip = true; + } + + if (!$shouldskip) { + $courses = enrol_get_users_courses($context->instanceid, false, ['enddate']); + foreach ($courses as $course) { + if (empty($course->enddate) || $course->enddate >= time()) { + // This user still has an active enrolment. + $shouldskip = true; + break; + } + } + } + } + + if ($shouldskip) { + // Add this to the list of contexts to skip for parentage checks. + $pathstoskip[] = $context->path; + } + + return !$shouldskip; } /** * Deletes the expired contexts. * - * @return int The number of deleted contexts. + * @return int[] The number of deleted contexts. */ - public function delete() { - - $numprocessed = 0; - + public function process_approved_deletions() : array { if (!$this->check_requirements()) { - return $numprocessed; + return [0, 0]; } - $privacymanager = new manager(); - $privacymanager->set_observer(new \tool_dataprivacy\manager_observer()); + $expiredcontexts = expired_context::get_records(['status' => expired_context::STATUS_APPROVED]); + $totalprocessed = 0; + $usercount = 0; + $coursecount = 0; + foreach ($expiredcontexts as $expiredctx) { + $context = \context::instance_by_id($expiredctx->get('contextid'), IGNORE_MISSING); + if (empty($context)) { + // Unable to process this request further. + // We have no context to delete. + $expiredctx->delete(); + continue; + } - foreach ($this->get_context_levels() as $level) { - - $expiredcontexts = expired_context::get_records_by_contextlevel($level, expired_context::STATUS_APPROVED); - - foreach ($expiredcontexts as $expiredctx) { - - if (!$this->delete_expired_context($privacymanager, $expiredctx)) { - continue; + if ($this->delete_expired_context($expiredctx)) { + if ($context instanceof \context_user) { + $usercount++; + } else { + $coursecount++; } - $numprocessed += 1; - if ($numprocessed == self::DELETE_LIMIT) { - // Close the recordset. - $expiredcontexts->close(); - break 2; + $totalprocessed++; + if ($totalprocessed >= $this->get_delete_limit()) { + break; } } } - return $numprocessed; + return [$coursecount, $usercount]; } /** * Deletes user data from the provided context. * - * @param manager $privacymanager * @param expired_context $expiredctx * @return \context|false */ - protected function delete_expired_context(manager $privacymanager, expired_context $expiredctx) { + protected function delete_expired_context(expired_context $expiredctx) { + $context = \context::instance_by_id($expiredctx->get('contextid')); - $context = \context::instance_by_id($expiredctx->get('contextid'), IGNORE_MISSING); - if (!$context) { - api::delete_expired_context($expiredctx->get('contextid')); + $this->get_progress()->output("Deleting context {$context->id} - " . $context->get_context_name(true, true)); + + // Update the expired_context and verify that it is still ready for deletion. + $expiredctx = $this->update_expired_context($expiredctx); + if (empty($expiredctx)) { + $this->get_progress()->output("Context has changed since approval and is no longer pending approval. Skipping", 1); return false; } - if (!PHPUNIT_TEST) { - mtrace('Deleting context ' . $context->id . ' - ' . - shorten_text($context->get_context_name(true, true))); + if (!$expiredctx->can_process_deletion()) { + // This only happens if the record was updated after being first fetched. + $this->get_progress()->output("Context has changed since approval and must be re-approved. Skipping", 1); + $expiredctx->set('status', expired_context::STATUS_EXPIRED); + $expiredctx->save(); + + return false; } - $privacymanager->delete_data_for_all_users_in_context($context); - api::set_expired_context_status($expiredctx, expired_context::STATUS_CLEANED); + $privacymanager = $this->get_privacy_manager(); + if ($context instanceof \context_user) { + $this->delete_expired_user_context($expiredctx); + } else { + // This context is fully expired - that is that the default retention period has been reached. + $privacymanager->delete_data_for_all_users_in_context($context); + } + + // Mark the record as cleaned. + $expiredctx->set('status', expired_context::STATUS_CLEANED); + $expiredctx->save(); return $context; } + /** + * Deletes user data from the provided user context. + * + * @param expired_context $expiredctx + */ + protected function delete_expired_user_context(expired_context $expiredctx) { + global $DB; + + $contextid = $expiredctx->get('contextid'); + $context = \context::instance_by_id($contextid); + $user = \core_user::get_user($context->instanceid, '*', MUST_EXIST); + + $privacymanager = $this->get_privacy_manager(); + + // Delete all child contexts of the user context. + $parentpath = $DB->sql_concat('ctxuser.path', "'/%'"); + + $params = [ + 'contextlevel' => CONTEXT_USER, + 'contextid' => $expiredctx->get('contextid'), + ]; + + $fields = \context_helper::get_preload_record_columns_sql('ctx'); + $sql = "SELECT ctx.id, $fields + FROM {context} ctxuser + JOIN {context} ctx ON ctx.path LIKE {$parentpath} + WHERE ctxuser.contextlevel = :contextlevel AND ctxuser.id = :contextid + ORDER BY ctx.path DESC"; + + $children = $DB->get_recordset_sql($sql, $params); + foreach ($children as $child) { + \context_helper::preload_from_record($child); + $context = \context::instance_by_id($child->id); + + $privacymanager->delete_data_for_all_users_in_context($context); + } + $children->close(); + + // Delete all unprotected data that the user holds. + $approvedlistcollection = new \core_privacy\local\request\contextlist_collection($user->id); + $contextlistcollection = $privacymanager->get_contexts_for_userid($user->id); + + foreach ($contextlistcollection as $contextlist) { + $contextids = []; + $approvedlistcollection->add_contextlist(new \core_privacy\local\request\approved_contextlist( + $user, + $contextlist->get_component(), + $contextlist->get_contextids() + )); + } + $privacymanager->delete_data_for_user($approvedlistcollection, $this->get_progress()); + + // Delete the user context. + $context = \context::instance_by_id($expiredctx->get('contextid')); + $privacymanager->delete_data_for_all_users_in_context($context); + + // This user is now fully expired - finish by deleting the user. + delete_user($user); + } + /** * Check that the requirements to start deleting contexts are satisified. * * @return bool */ protected function check_requirements() { - api::check_can_manage_data_registry(\context_system::instance()->id); - if (!data_registry::defaults_set()) { return false; } return true; } + + /** + * Check whether a date is beyond the specified period. + * + * @param string $period The Expiry Period + * @param int $comparisondate The date for comparison + * @return bool + */ + protected static function has_expired(string $period, int $comparisondate) : bool { + $dt = new \DateTime(); + $dt->setTimestamp($comparisondate); + $dt->add(new \DateInterval($period)); + + return (time() >= $dt->getTimestamp()); + } + + /** + * Get the expiry info object for the specified purpose and comparison date. + * + * @param purpose $purpose The purpose of this context + * @param int $comparisondate The date for comparison + * @return expiry_info + */ + protected static function get_expiry_info(purpose $purpose, int $comparisondate = 0) : expiry_info { + if (empty($comparisondate)) { + // The date is empty, therefore this context cannot be considered for automatic expiry. + $defaultexpired = false; + } else { + $defaultexpired = static::has_expired($purpose->get('retentionperiod'), $comparisondate); + } + + return new expiry_info($defaultexpired); + } + + /** + * Update or delete the expired_context from the expiry_info object. + * This function depends upon the data structure returned from get_nested_expiry_info. + * + * If the context is expired in any way, then an expired_context will be returned, otherwise null will be returned. + * + * @param \stdClass $expiryrecord + * @return expired_context|null + */ + protected function update_from_expiry_info(\stdClass $expiryrecord) { + if ($expiryrecord->info->is_any_expired()) { + // The context is expired in some fashion. + // Create or update as required. + if ($expiryrecord->record->expiredctxid) { + $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx')); + $expiredcontext->update_from_expiry_info($expiryrecord->info); + + if ($expiredcontext->is_complete()) { + return null; + } + } else { + $expiredcontext = expired_context::create_from_expiry_info($expiryrecord->context, $expiryrecord->info); + } + + return $expiredcontext; + } else { + // The context is not expired. + if ($expiryrecord->record->expiredctxid) { + // There was previously an expired context record, but it is no longer relevant. + // Delete it to be on the safe side. + $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx')); + $expiredcontext->delete(); + } + + return null; + } + } + + /** + * Update the expired context record. + * + * Note: You should use the return value as the provided value will be used to fetch data only. + * + * @param expired_context $expiredctx The record to update + * @return expired_context|null + */ + protected function update_expired_context(expired_context $expiredctx) { + // Fetch the context from the expired_context record. + $context = \context::instance_by_id($expiredctx->get('contextid')); + + // Fetch the current nested expiry data. + $expiryrecords = self::get_nested_expiry_info($context->path); + + // Find the current record. + if (empty($expiryrecords[$context->path])) { + $expiredctx->delete(); + return null; + } + + // Refresh the record. + // Note: Use the returned expiredctx. + $expiredctx = $this->update_from_expiry_info($expiryrecords[$context->path]); + if (empty($expiredctx)) { + return null; + } + + if (!$context instanceof \context_user) { + // Where the target context is not a user, we check all children of the context. + // The expiryrecords array only contains children, fetched from the get_nested_expiry_info call above. + // No need to check that these _are_ children. + foreach ($expiryrecords as $expiryrecord) { + if ($expiryrecord->context->id === $context->id) { + // This is record for the context being tested that we checked earlier. + continue; + } + + if (empty($expiryrecord->record->expiredctxid)) { + // There is no expired context record for this context. + // If there is no record, then this context cannot have been approved for removal. + return null; + } + + // Fetch the expired_context object for this record. + // This needs to be updated from the expiry_info data too as there may be child changes to consider. + $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx')); + $expiredcontext->update_from_expiry_info($expiryrecord->info); + if (!$expiredcontext->is_complete()) { + return null; + } + } + } + + return $expiredctx; + } + + /** + * Create a new instance of the privacy manager. + * + * @return manager + */ + protected function get_privacy_manager() : manager { + if (null === $this->manager) { + $this->manager = new manager(); + $this->manager->set_observer(new \tool_dataprivacy\manager_observer()); + } + + return $this->manager; + } + + /** + * Fetch the limit for the maximum number of contexts to delete in one session. + * + * @return int + */ + protected function get_delete_limit() : int { + return self::DELETE_LIMIT; + } + + /** + * Get the progress tracer. + * + * @return \progress_trace + */ + protected function get_progress() : \progress_trace { + if (null === $this->progresstracer) { + $this->set_progress(new \text_progress_trace()); + } + + return $this->progresstracer; + } + + /** + * Set a specific tracer for the task. + * + * @param \progress_trace $trace + * @return $this + */ + public function set_progress(\progress_trace $trace) : expired_contexts_manager { + $this->progresstracer = $trace; + + return $this; + } } diff --git a/admin/tool/dataprivacy/classes/expired_course_related_contexts.php b/admin/tool/dataprivacy/classes/expired_course_related_contexts.php deleted file mode 100644 index f878edff093..00000000000 --- a/admin/tool/dataprivacy/classes/expired_course_related_contexts.php +++ /dev/null @@ -1,135 +0,0 @@ -. - -/** - * Expired contexts manager for CONTEXT_COURSE, CONTEXT_MODULE and CONTEXT_BLOCK. - * - * @package tool_dataprivacy - * @copyright 2018 David Monllao - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -namespace tool_dataprivacy; - -use tool_dataprivacy\purpose; - -defined('MOODLE_INTERNAL') || die(); - -/** - * Expired contexts manager for CONTEXT_COURSE, CONTEXT_MODULE and CONTEXT_BLOCK. - * - * @copyright 2018 David Monllao - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class expired_course_related_contexts extends \tool_dataprivacy\expired_contexts_manager { - - /** - * Course-related context levels. - * - * @return int[] - */ - protected function get_context_levels() { - return [CONTEXT_MODULE, CONTEXT_BLOCK, CONTEXT_COURSE]; - } - - /** - * Returns a recordset with user context instances that are possibly expired (to be confirmed by get_recordset_callback). - * - * @return \stdClass[] - */ - protected function get_expired_contexts() { - global $DB; - - // Including context info + course end date + purposeid (this last one only if defined). - $fields = 'ctx.id AS id, ctxcourse.enddate AS courseenddate, dpctx.purposeid AS purposeid, ' . - \context_helper::get_preload_record_columns_sql('ctx'); - - // We want all contexts at course-dependant levels. - $parentpath = $DB->sql_concat('ctxcourse.path', "'/%'"); - - // This SQL query returns all course-dependant contexts (including the course context) - // which course end date already passed. - $sql = "SELECT $fields - FROM {context} ctx - JOIN ( - SELECT c.enddate, subctx.path - FROM {context} subctx - JOIN {course} c - ON subctx.contextlevel = ? AND subctx.instanceid = c.id - WHERE c.enddate < ? AND c.enddate > 0 - ) ctxcourse - ON ctx.path LIKE {$parentpath} OR ctx.path = ctxcourse.path - LEFT JOIN {tool_dataprivacy_ctxinstance} dpctx - ON dpctx.contextid = ctx.id - LEFT JOIN {tool_dataprivacy_ctxexpired} expiredctx - ON ctx.id = expiredctx.contextid - WHERE expiredctx.id IS NULL - ORDER BY ctx.contextlevel DESC, ctx.path"; - $possiblyexpired = $DB->get_recordset_sql($sql, [CONTEXT_COURSE, time()]); - - $expiredcontexts = []; - $excludedcontextids = []; - foreach ($possiblyexpired as $record) { - - \context_helper::preload_from_record($record); - - // No strict checking as the context may already be deleted (e.g. we just deleted a course, - // module contexts below it will not exist). - $context = \context::instance_by_id($record->id, false); - if (!$context) { - continue; - } - - // We pass the value we just got from SQL so get_effective_context_purpose don't need to query - // the db again to retrieve it. If there is no tool_dataprovider_ctxinstance record - // $record->purposeid will be null which is ok as it would force get_effective_context_purpose - // to return the default purpose for the context context level (no db queries involved). - $purposevalue = $record->purposeid !== null ? $record->purposeid : context_instance::NOTSET; - - // It should be cheap as system purposes and context level purposes will be retrieved from a cache most of the time. - $purpose = api::get_effective_context_purpose($context, $purposevalue); - - $dt = new \DateTime(); - $dt->setTimestamp($record->courseenddate); - $di = new \DateInterval($purpose->get('retentionperiod')); - $dt->add($di); - - if (time() < $dt->getTimestamp()) { - // Exclude this context ID as it has not reached the retention period yet. - $excludedcontextids[] = $context->id; - continue; - } - - // Check if this context has children that have not yet expired. - $hasunexpiredchildren = false; - $children = $context->get_child_contexts(); - foreach ($children as $child) { - if (in_array($child->id, $excludedcontextids)) { - $hasunexpiredchildren = true; - break; - } - } - if ($hasunexpiredchildren) { - // Exclude this context ID as it has children that have not yet expired. - $excludedcontextids[] = $context->id; - continue; - } - - $expiredcontexts[$context->id] = $context; - } - - return $expiredcontexts; - } -} diff --git a/admin/tool/dataprivacy/classes/expired_user_contexts.php b/admin/tool/dataprivacy/classes/expired_user_contexts.php deleted file mode 100644 index 924d5650132..00000000000 --- a/admin/tool/dataprivacy/classes/expired_user_contexts.php +++ /dev/null @@ -1,150 +0,0 @@ -. - -/** - * Expired contexts manager for CONTEXT_USER. - * - * @package tool_dataprivacy - * @copyright 2018 David Monllao - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -namespace tool_dataprivacy; - -use core_privacy\manager; - -defined('MOODLE_INTERNAL') || die(); - -/** - * Expired contexts manager for CONTEXT_USER. - * - * @copyright 2018 David Monllao - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class expired_user_contexts extends \tool_dataprivacy\expired_contexts_manager { - - /** - * Only user level. - * - * @return int[] - */ - protected function get_context_levels() { - return [CONTEXT_USER]; - } - - /** - * Returns the user context instances that are expired. - * - * @return \stdClass[] - */ - protected function get_expired_contexts() { - global $DB; - - // Including context info + last login timestamp. - $fields = 'ctx.id AS id, ' . \context_helper::get_preload_record_columns_sql('ctx'); - - $purpose = api::get_effective_contextlevel_purpose(CONTEXT_USER); - - // Calculate what is considered expired according to the context level effective purpose (= now + retention period). - $expiredtime = new \DateTime(); - $retention = new \DateInterval($purpose->get('retentionperiod')); - $expiredtime->sub($retention); - - $sql = "SELECT $fields FROM {context} ctx - JOIN {user} u ON ctx.contextlevel = ? AND ctx.instanceid = u.id - LEFT JOIN {tool_dataprivacy_ctxexpired} expiredctx ON ctx.id = expiredctx.contextid - WHERE u.lastaccess <= ? AND u.lastaccess > 0 AND expiredctx.id IS NULL - ORDER BY ctx.path, ctx.contextlevel ASC"; - $possiblyexpired = $DB->get_recordset_sql($sql, [CONTEXT_USER, $expiredtime->getTimestamp()]); - - $expiredcontexts = []; - foreach ($possiblyexpired as $record) { - - \context_helper::preload_from_record($record); - - // No strict checking as the context may already be deleted (e.g. we just deleted a course, - // module contexts below it will not exist). - $context = \context::instance_by_id($record->id, false); - if (!$context) { - continue; - } - - if (is_siteadmin($context->instanceid)) { - continue; - } - - $courses = enrol_get_users_courses($context->instanceid, false, ['enddate']); - foreach ($courses as $course) { - if (!$course->enddate) { - // We can not know it what is going on here, so we prefer to be conservative. - continue 2; - } - - if ($course->enddate >= time()) { - // Future or ongoing course. - continue 2; - } - } - - $expiredcontexts[$context->id] = $context; - } - - return $expiredcontexts; - } - - /** - * Deletes user data from the provided context. - * - * Overwritten to delete the user. - * - * @param manager $privacymanager - * @param expired_context $expiredctx - * @return \context|false - */ - protected function delete_expired_context(manager $privacymanager, expired_context $expiredctx) { - $context = \context::instance_by_id($expiredctx->get('contextid'), IGNORE_MISSING); - if (!$context) { - api::delete_expired_context($expiredctx->get('contextid')); - return false; - } - - if (!PHPUNIT_TEST) { - mtrace('Deleting context ' . $context->id . ' - ' . - shorten_text($context->get_context_name(true, true))); - } - - // To ensure that all user data is deleted, instead of deleting by context, we run through and collect any stray - // contexts for the user that may still exist and call delete_data_for_user(). - $user = \core_user::get_user($context->instanceid, '*', MUST_EXIST); - $approvedlistcollection = new \core_privacy\local\request\contextlist_collection($user->id); - $contextlistcollection = $privacymanager->get_contexts_for_userid($user->id); - - foreach ($contextlistcollection as $contextlist) { - $approvedlistcollection->add_contextlist(new \core_privacy\local\request\approved_contextlist( - $user, - $contextlist->get_component(), - $contextlist->get_contextids() - )); - } - - $privacymanager->delete_data_for_user($approvedlistcollection); - api::set_expired_context_status($expiredctx, expired_context::STATUS_CLEANED); - - // Delete the user. - delete_user($user); - - return $context; - } -} diff --git a/admin/tool/dataprivacy/classes/expiry_info.php b/admin/tool/dataprivacy/classes/expiry_info.php new file mode 100644 index 00000000000..508214f29b0 --- /dev/null +++ b/admin/tool/dataprivacy/classes/expiry_info.php @@ -0,0 +1,93 @@ +. + +/** + * Expiry Data. + * + * @package tool_dataprivacy + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_dataprivacy; + +use core_privacy\manager; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Expiry Data. + * + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class expiry_info { + + /** @var bool Whether this context is fully expired */ + protected $isexpired = false; + + /** + * Constructor for the expiry_info class. + * + * @param bool $isexpired Whether the retention period for this context has expired yet. + */ + public function __construct(bool $isexpired) { + $this->isexpired = $isexpired; + } + + /** + * Whether this context has 'fully' expired. + * That is to say that the default retention period has been reached, and that there are no unexpired roles. + * + * @return bool + */ + public function is_fully_expired() : bool { + return $this->isexpired; + } + + /** + * Whether any part of this context has expired. + * + * @return bool + */ + public function is_any_expired() : bool { + if ($this->is_fully_expired()) { + return true; + } + + return false; + } + + /** + * Merge this expiry_info object with another belonging to a child context in order to set the 'safest' heritage. + * + * It is not possible to delete any part of a context that is not deleted by a parent. + * So if a course's retention policy has been reached, then only parts where the children have also expired can be + * deleted. + * + * @param expiry_info $child The child record to merge with. + * @return $this + */ + public function merge_with_child(expiry_info $child) : expiry_info { + if ($child->is_fully_expired()) { + return $this; + } + + // If the child is not fully expired, then none of the parents can be either. + $this->isexpired = false; + + return $this; + } +} diff --git a/admin/tool/dataprivacy/classes/external.php b/admin/tool/dataprivacy/classes/external.php index 78d4a82525f..178f645d419 100644 --- a/admin/tool/dataprivacy/classes/external.php +++ b/admin/tool/dataprivacy/classes/external.php @@ -989,7 +989,6 @@ class external extends external_api { $contextlevel = api::set_contextlevel($validateddata); } else if ($errors = $mform->is_validated()) { $warnings[] = json_encode($errors); - throw new moodle_exception('generalerror'); } if ($contextlevel) { @@ -1041,8 +1040,9 @@ class external extends external_api { 'jsonformdata' => $jsonformdata ]); - // Extra permission checkings are delegated to api::set_context_instance. + // Validate context and access to manage the registry. self::validate_context(\context_system::instance()); + api::check_can_manage_data_registry(); $serialiseddata = json_decode($params['jsonformdata']); $data = array(); @@ -1188,24 +1188,27 @@ class external extends external_api { $expiredcontext = new expired_context($id); $targetcontext = context_helper::instance_by_id($expiredcontext->get('contextid')); - // Fetch this context's child contexts. Make sure that all of the child contexts are flagged for deletion. - $childcontexts = $targetcontext->get_child_contexts(); - foreach ($childcontexts as $child) { - if ($expiredchildcontext = expired_context::get_record(['contextid' => $child->id])) { - // Add this child context to the list for approval. - $expiredcontextstoapprove[] = $expiredchildcontext; - } else { - // This context has not yet been flagged for deletion. - $result = false; - $message = get_string('errorcontexthasunexpiredchildren', 'tool_dataprivacy', - $targetcontext->get_context_name(false)); - $warnings[] = [ - 'item' => 'tool_dataprivacy_ctxexpired', - 'warningcode' => 'errorcontexthasunexpiredchildren', - 'message' => $message - ]; - // Exit the process. - break 2; + if (!$targetcontext instanceof \context_user) { + // Fetch this context's child contexts. Make sure that all of the child contexts are flagged for deletion. + // User context children do not need to be considered. + $childcontexts = $targetcontext->get_child_contexts(); + foreach ($childcontexts as $child) { + if ($expiredchildcontext = expired_context::get_record(['contextid' => $child->id])) { + // Add this child context to the list for approval. + $expiredcontextstoapprove[] = $expiredchildcontext; + } else { + // This context has not yet been flagged for deletion. + $result = false; + $message = get_string('errorcontexthasunexpiredchildren', 'tool_dataprivacy', + $targetcontext->get_context_name(false)); + $warnings[] = [ + 'item' => 'tool_dataprivacy_ctxexpired', + 'warningcode' => 'errorcontexthasunexpiredchildren', + 'message' => $message + ]; + // Exit the process. + break 2; + } } } diff --git a/admin/tool/dataprivacy/classes/task/delete_expired_contexts.php b/admin/tool/dataprivacy/classes/task/delete_expired_contexts.php index 55deeecad1f..8a4f96b5148 100644 --- a/admin/tool/dataprivacy/classes/task/delete_expired_contexts.php +++ b/admin/tool/dataprivacy/classes/task/delete_expired_contexts.php @@ -52,18 +52,10 @@ class delete_expired_contexts extends scheduled_task { /** * Run the task to delete context instances based on their retention periods. - * */ public function execute() { - $manager = new \tool_dataprivacy\expired_course_related_contexts(); - $deleted = $manager->delete(); - if ($deleted > 0) { - mtrace($deleted . ' course-related contexts have been deleted'); - } - $manager = new \tool_dataprivacy\expired_user_contexts(); - $deleted = $manager->delete(); - if ($deleted > 0) { - mtrace($deleted . ' user contexts have been deleted'); - } + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($courses, $users) = $manager->process_approved_deletions(); + mtrace("Processed deletions for {$courses} course contexts, and {$users} user contexts as expired"); } } diff --git a/admin/tool/dataprivacy/classes/task/expired_retention_period.php b/admin/tool/dataprivacy/classes/task/expired_retention_period.php index 11230676adb..f00b51a3519 100644 --- a/admin/tool/dataprivacy/classes/task/expired_retention_period.php +++ b/admin/tool/dataprivacy/classes/task/expired_retention_period.php @@ -54,15 +54,8 @@ class expired_retention_period extends scheduled_task { * Run the task to flag context instances as expired. */ public function execute() { - $manager = new \tool_dataprivacy\expired_course_related_contexts(); - $flagged = $manager->flag_expired(); - if ($flagged > 0) { - mtrace($flagged . ' course-related contexts have been flagged as expired'); - } - $manager = new \tool_dataprivacy\expired_user_contexts(); - $flagged = $manager->flag_expired(); - if ($flagged > 0) { - mtrace($flagged . ' user contexts have been flagged as expired'); - } + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($courses, $users) = $manager->flag_expired_contexts(); + mtrace("Flagged {$courses} course contexts, and {$users} user contexts as expired"); } } diff --git a/admin/tool/dataprivacy/tests/api_test.php b/admin/tool/dataprivacy/tests/api_test.php index 1864a03b260..0f458465b41 100644 --- a/admin/tool/dataprivacy/tests/api_test.php +++ b/admin/tool/dataprivacy/tests/api_test.php @@ -1320,53 +1320,6 @@ class tool_dataprivacy_api_testcase extends advanced_testcase { $this->assertEquals($categories[1]->get('id'), $category->get('id')); } - /** - * Tests the deletion of expired contexts. - * - * @return null - */ - public function test_expired_context_deletion() { - global $DB; - - $this->setAdminUser(); - - list($purposes, $categories, $courses, $modules) = $this->add_purposes_and_categories(); - - $course0context = \context_course::instance($courses[0]->id); - $course1context = \context_course::instance($courses[1]->id); - - $expiredcontext0 = api::create_expired_context($course0context->id); - $this->assertEquals(1, $DB->count_records('tool_dataprivacy_ctxexpired')); - $expiredcontext1 = api::create_expired_context($course1context->id); - $this->assertEquals(2, $DB->count_records('tool_dataprivacy_ctxexpired')); - - api::delete_expired_context($expiredcontext0->get('id')); - $this->assertEquals(1, $DB->count_records('tool_dataprivacy_ctxexpired')); - } - - /** - * Tests the status of expired contexts. - * - * @return null - */ - public function test_expired_context_status() { - global $DB; - - $this->setAdminUser(); - - list($purposes, $categories, $courses, $modules) = $this->add_purposes_and_categories(); - - $course0context = \context_course::instance($courses[0]->id); - - $expiredcontext = api::create_expired_context($course0context->id); - - // Default status. - $this->assertEquals(expired_context::STATUS_EXPIRED, $expiredcontext->get('status')); - - api::set_expired_context_status($expiredcontext, expired_context::STATUS_APPROVED); - $this->assertEquals(expired_context::STATUS_APPROVED, $expiredcontext->get('status')); - } - /** * Creates test purposes and categories. * diff --git a/admin/tool/dataprivacy/tests/expired_contexts_test.php b/admin/tool/dataprivacy/tests/expired_contexts_test.php index d480e5749f6..fffe47509e1 100644 --- a/admin/tool/dataprivacy/tests/expired_contexts_test.php +++ b/admin/tool/dataprivacy/tests/expired_contexts_test.php @@ -25,6 +25,9 @@ use tool_dataprivacy\api; use tool_dataprivacy\data_registry; use tool_dataprivacy\expired_context; +use tool_dataprivacy\purpose; +use tool_dataprivacy\category; +use tool_dataprivacy\contextlevel; defined('MOODLE_INTERNAL') || die(); global $CFG; @@ -39,198 +42,1365 @@ global $CFG; class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { /** - * setUp. + * Setup the basics with the specified retention period. + * + * @param string $system Retention policy for the system. + * @param string $user Retention policy for users. + * @param string $course Retention policy for courses. + * @param string $activity Retention policy for activities. */ - public function setUp() { + protected function setup_basics(string $system, string $user, string $course, string $activity = null) : array { $this->resetAfterTest(); - $this->setAdminUser(); + + $purposes = []; + $purposes[] = $this->create_and_set_purpose_for_contextlevel($system, CONTEXT_SYSTEM); + $purposes[] = $this->create_and_set_purpose_for_contextlevel($user, CONTEXT_USER); + $purposes[] = $this->create_and_set_purpose_for_contextlevel($course, CONTEXT_COURSE); + if (null !== $activity) { + $purposes[] = $this->create_and_set_purpose_for_contextlevel($activity, CONTEXT_MODULE); + } + + return $purposes; } /** - * Test expired users flagging and deletion. + * Create a retention period and set it for the specified context level. * - * @return null + * @param string $retention + * @param int $contextlevel + * @return purpose */ - public function test_expired_users() { - global $DB; + protected function create_and_set_purpose_for_contextlevel(string $retention, int $contextlevel) : purpose { + $purpose = new purpose(0, (object) [ + 'name' => 'Test purpose ' . rand(1, 1000), + 'retentionperiod' => $retention, + 'lawfulbases' => 'gdpr_art_6_1_a', + ]); + $purpose->create(); - $purpose = api::create_purpose((object)['name' => 'p1', 'retentionperiod' => 'PT1H', 'lawfulbases' => 'gdpr_art_6_1_a']); - $cat = api::create_category((object)['name' => 'a']); + $cat = new category(0, (object) ['name' => 'Test category']); + $cat->create(); - $record = (object)[ - 'purposeid' => $purpose->get('id'), - 'categoryid' => $cat->get('id'), - 'contextlevel' => CONTEXT_SYSTEM, - ]; - api::set_contextlevel($record); - $record->contextlevel = CONTEXT_USER; - api::set_contextlevel($record); + if ($contextlevel <= CONTEXT_USER) { + $record = (object) [ + 'purposeid' => $purpose->get('id'), + 'categoryid' => $cat->get('id'), + 'contextlevel' => $contextlevel, + ]; + api::set_contextlevel($record); + } else { + list($purposevar, ) = data_registry::var_names_from_context( + \context_helper::get_class_for_level(CONTEXT_COURSE) + ); + set_config($purposevar, $purpose->get('id'), 'tool_dataprivacy'); + } - $userdata = ['lastaccess' => '123']; - $user1 = $this->getDataGenerator()->create_user($userdata); - $user2 = $this->getDataGenerator()->create_user($userdata); - $user3 = $this->getDataGenerator()->create_user($userdata); - $user4 = $this->getDataGenerator()->create_user($userdata); - $user5 = $this->getDataGenerator()->create_user(); - - $course1 = $this->getDataGenerator()->create_course(); - // Old course. - $course2 = $this->getDataGenerator()->create_course(['startdate' => '1', 'enddate' => '2']); - // Ongoing course. - $course3 = $this->getDataGenerator()->create_course(['startdate' => '1', 'enddate' => time() + YEARSECS]); - - $this->getDataGenerator()->enrol_user($user1->id, $course1->id, 'student'); - $this->getDataGenerator()->enrol_user($user2->id, $course2->id, 'student'); - $this->getDataGenerator()->enrol_user($user3->id, $course2->id, 'student'); - $this->getDataGenerator()->enrol_user($user4->id, $course3->id, 'student'); - - // Add an activity and some data for user 2. - $assignmod = $this->getDataGenerator()->create_module('assign', ['course' => $course2->id]); - $data = (object) [ - 'assignment' => $assignmod->id, - 'userid' => $user2->id, - 'timecreated' => time(), - 'timemodified' => time(), - 'status' => 'new', - 'groupid' => 0, - 'attemptnumber' => 0, - 'latest' => 1, - ]; - $DB->insert_record('assign_submission', $data); - // We should have one record in the assign submission table. - $this->assertEquals(1, $DB->count_records('assign_submission')); - - // Users without lastaccess are skipped as well as users enroled in courses with no end date. - $expired = new \tool_dataprivacy\expired_user_contexts(); - $numexpired = $expired->flag_expired(); - $this->assertEquals(2, $numexpired); - $this->assertEquals(2, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_EXPIRED])); - - // Approve user2 to be deleted. - $user2ctx = \context_user::instance($user2->id); - $expiredctx = expired_context::get_record(['contextid' => $user2ctx->id]); - api::set_expired_context_status($expiredctx, expired_context::STATUS_APPROVED); - $this->assertEquals(1, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_APPROVED])); - - // Delete expired contexts. - $deleted = $expired->delete(); - $this->assertEquals(1, $deleted); - $this->assertEquals(1, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_EXPIRED])); - $this->assertEquals(1, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_CLEANED])); - - // No new records are generated. - $numexpired = $expired->flag_expired(); - $this->assertEquals(0, $numexpired); - $this->assertEquals(2, $DB->count_records('tool_dataprivacy_ctxexpired')); - $deleted = $expired->delete(); - $this->assertEquals(0, $deleted); - - // No user data left in mod_assign. - $this->assertEquals(0, $DB->count_records('assign_submission')); - - // The user is deleted. - $deleteduser = \core_user::get_user($user2->id, 'id, deleted', IGNORE_MISSING); - $this->assertEquals(1, $deleteduser->deleted); + return $purpose; } /** - * Test expired course and course stuff flagging and deletion. - * - * @return null + * Ensure that a user with no lastaccess is not flagged for deletion. */ - public function test_expired_course_related_contexts() { - global $DB; + public function test_flag_not_setup() { + $this->resetAfterTest(); - $purpose1 = api::create_purpose((object)['name' => 'p1', 'retentionperiod' => 'PT1H', 'lawfulbases' => 'gdpr_art_6_1_a']); - $purpose2 = api::create_purpose((object)['name' => 'p1', 'retentionperiod' => 'P1Y', 'lawfulbases' => 'gdpr_art_6_1_b']); - $cat = api::create_category((object)['name' => 'a']); + $user = $this->getDataGenerator()->create_user(); - $record = (object)[ - 'purposeid' => $purpose1->get('id'), - 'categoryid' => $cat->get('id'), - 'contextlevel' => CONTEXT_SYSTEM, + $this->setUser($user); + $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN); + $context = \context_block::instance($block->instance->id); + $this->setUser(); + + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + $this->assertEquals(0, $flaggedcourses); + $this->assertEquals(0, $flaggedusers); + } + + /** + * Ensure that a user with no lastaccess is not flagged for deletion. + */ + public function test_flag_user_no_lastaccess() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + + $user = $this->getDataGenerator()->create_user(); + + $this->setUser($user); + $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN); + $context = \context_block::instance($block->instance->id); + $this->setUser(); + + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + $this->assertEquals(0, $flaggedcourses); + $this->assertEquals(0, $flaggedusers); + } + + /** + * Ensure that a user with a recent lastaccess is not flagged for deletion. + */ + public function test_flag_user_recent_lastaccess() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time()]); + + $this->setUser($user); + $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN); + $context = \context_block::instance($block->instance->id); + $this->setUser(); + + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + $this->assertEquals(0, $flaggedcourses); + $this->assertEquals(0, $flaggedusers); + } + + /** + * Ensure that a user with a lastaccess in the past is flagged for deletion. + */ + public function test_flag_user_past_lastaccess() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + + $this->setUser($user); + $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN); + $context = \context_block::instance($block->instance->id); + $this->setUser(); + + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + // Although there is a block in the user context, everything in the user context is regarded as one. + $this->assertEquals(0, $flaggedcourses); + $this->assertEquals(1, $flaggedusers); + } + + /** + * Ensure that a user with a lastaccess in the past but active enrolments is not flagged for deletion. + */ + public function test_flag_user_past_lastaccess_still_enrolled() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + $course = $this->getDataGenerator()->create_course(['startdate' => time(), 'enddate' => time() + YEARSECS]); + $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student'); + + $otheruser = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($otheruser->id, $course->id, 'student'); + + $this->setUser($user); + $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN); + $context = \context_block::instance($block->instance->id); + $this->setUser(); + + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + $this->assertEquals(0, $flaggedcourses); + $this->assertEquals(0, $flaggedusers); + } + + /** + * Ensure that a user with a lastaccess in the past and no active enrolments is flagged for deletion. + */ + public function test_flag_user_past_lastaccess_enrol_expired() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'P5Y'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + $course = $this->getDataGenerator()->create_course(['startdate' => time() - (YEARSECS * 2), 'enddate' => time() - DAYSECS]); + $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student'); + + $this->setUser($user); + $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN); + $context = \context_block::instance($block->instance->id); + $this->setUser(); + + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + // Although there is a block in the user context, everything in the user context is regarded as one. + $this->assertEquals(0, $flaggedcourses); + $this->assertEquals(1, $flaggedusers); + } + + /** + * Ensure that a user with a recent lastaccess is not flagged for deletion. + */ + public function test_flag_user_recent_lastaccess_existing_record() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time()]); + $usercontext = \context_user::instance($user->id); + + // Create an existing expired_context. + $expiredcontext = new expired_context(0, (object) [ + 'contextid' => $usercontext->id, + 'status' => expired_context::STATUS_EXPIRED, + ]); + $expiredcontext->save(); + + $this->setUser($user); + $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN); + $context = \context_block::instance($block->instance->id); + $this->setUser(); + + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + $this->assertEquals(0, $flaggedcourses); + $this->assertEquals(0, $flaggedusers); + + $this->expectException('dml_missing_record_exception'); + new expired_context($expiredcontext->get('id')); + } + + /** + * Ensure that a user with a recent lastaccess is not flagged for deletion. + */ + public function test_flag_user_retention_changed() { + $this->resetAfterTest(); + + list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]); + $usercontext = \context_user::instance($user->id); + + $this->setUser($user); + $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN); + $context = \context_block::instance($block->instance->id); + $this->setUser(); + + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + $this->assertEquals(0, $flaggedcourses); + $this->assertEquals(1, $flaggedusers); + + $expiredcontext = expired_context::get_record(['contextid' => $usercontext->id]); + $this->assertNotFalse($expiredcontext); + + // Increase the retention period to 5 years. + $userpurpose->set('retentionperiod', 'P5Y'); + $userpurpose->save(); + + // Re-run the expiry job - the previously flagged user will be removed because the retention period has been increased. + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + $this->assertEquals(0, $flaggedcourses); + $this->assertEquals(0, $flaggedusers); + + // The expiry record will now have been removed. + $this->expectException('dml_missing_record_exception'); + new expired_context($expiredcontext->get('id')); + } + + /** + * Ensure that a user with a historically expired expired block record child is cleaned up. + */ + public function test_flag_user_historic_block_unapproved() { + $this->resetAfterTest(); + + list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]); + $usercontext = \context_user::instance($user->id); + + $this->setUser($user); + $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN); + $blockcontext = \context_block::instance($block->instance->id); + $this->setUser(); + + // Create an existing expired_context which has not been approved for the block. + $expiredcontext = new expired_context(0, (object) [ + 'contextid' => $blockcontext->id, + 'status' => expired_context::STATUS_EXPIRED, + ]); + $expiredcontext->save(); + + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + $this->assertEquals(0, $flaggedcourses); + $this->assertEquals(1, $flaggedusers); + + $expiredblockcontext = expired_context::get_record(['contextid' => $blockcontext->id]); + $this->assertFalse($expiredblockcontext); + + $expiredusercontext = expired_context::get_record(['contextid' => $usercontext->id]); + $this->assertNotFalse($expiredusercontext); + } + + /** + * Ensure that a user with a block which has a default retention period which has not expired, is still expired. + */ + public function test_flag_user_historic_unexpired_child() { + $this->resetAfterTest(); + + list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + $blockpurpose = $this->create_and_set_purpose_for_contextlevel('P5Y', CONTEXT_BLOCK); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]); + $usercontext = \context_user::instance($user->id); + + $this->setUser($user); + $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN); + $blockcontext = \context_block::instance($block->instance->id); + $this->setUser(); + + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + $this->assertEquals(0, $flaggedcourses); + $this->assertEquals(1, $flaggedusers); + + $expiredcontext = expired_context::get_record(['contextid' => $usercontext->id]); + $this->assertNotFalse($expiredcontext); + } + + /** + * Ensure that a course with no end date is not flagged. + */ + public function test_flag_course_no_enddate() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H'); + + $course = $this->getDataGenerator()->create_course(); + $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]); + + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + $this->assertEquals(0, $flaggedcourses); + $this->assertEquals(0, $flaggedusers); + } + + /** + * Ensure that a course with an end date in the distant past, but a child which is unexpired is not flagged. + */ + public function test_flag_course_past_enddate_future_child() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'P5Y'); + + $course = $this->getDataGenerator()->create_course([ + 'startdate' => time() - (2 * YEARSECS), + 'enddate' => time() - YEARSECS, + ]); + $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]); + + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + $this->assertEquals(0, $flaggedcourses); + $this->assertEquals(0, $flaggedusers); + } + + /** + * Ensure that a course with an end date in the distant past is flagged. + */ + public function test_flag_course_past_enddate() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H'); + + $course = $this->getDataGenerator()->create_course([ + 'startdate' => time() - (2 * YEARSECS), + 'enddate' => time() - YEARSECS, + ]); + $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]); + + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + $this->assertEquals(2, $flaggedcourses); + $this->assertEquals(0, $flaggedusers); + } + + /** + * Ensure that a course with an end date in the distant past is flagged. + */ + public function test_flag_course_past_enddate_multiple() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H'); + + $course1 = $this->getDataGenerator()->create_course([ + 'startdate' => time() - (2 * YEARSECS), + 'enddate' => time() - YEARSECS, + ]); + $forum1 = $this->getDataGenerator()->create_module('forum', ['course' => $course1->id]); + + $course2 = $this->getDataGenerator()->create_course([ + 'startdate' => time() - (2 * YEARSECS), + 'enddate' => time() - YEARSECS, + ]); + $forum2 = $this->getDataGenerator()->create_module('forum', ['course' => $course2->id]); + + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + $this->assertEquals(4, $flaggedcourses); + $this->assertEquals(0, $flaggedusers); + } + + /** + * Ensure that a course with an end date in the future is not flagged. + */ + public function test_flag_course_future_enddate() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H'); + + $course = $this->getDataGenerator()->create_course(['enddate' => time() + YEARSECS]); + $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]); + + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + $this->assertEquals(0, $flaggedcourses); + $this->assertEquals(0, $flaggedusers); + } + + /** + * Ensure that a course with an end date in the future is not flagged. + */ + public function test_flag_course_recent_unexpired_enddate() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H'); + + $course = $this->getDataGenerator()->create_course(['enddate' => time() - 1]); + + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + $this->assertEquals(0, $flaggedcourses); + $this->assertEquals(0, $flaggedusers); + } + + /** + * Ensure that a site not setup will not process anything. + */ + public function test_process_not_setup() { + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + $usercontext = \context_user::instance($user->id); + + // Create an existing expired_context. + $expiredcontext = new expired_context(0, (object) [ + 'contextid' => $usercontext->id, + 'status' => expired_context::STATUS_EXPIRED, + ]); + $expiredcontext->save(); + + $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class) + ->setMethods([ + 'delete_data_for_user', + 'delete_data_for_all_users_in_context', + ]) + ->getMock(); + $mockprivacymanager->expects($this->never())->method('delete_data_for_user'); + $mockprivacymanager->expects($this->never())->method('delete_data_for_all_users_in_context'); + + $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class) + ->setMethods(['get_privacy_manager']) + ->getMock(); + $manager->set_progress(new \null_progress_trace()); + + $manager->method('get_privacy_manager')->willReturn($mockprivacymanager); + list($processedcourses, $processedusers) = $manager->process_approved_deletions(); + + $this->assertEquals(0, $processedcourses); + $this->assertEquals(0, $processedusers); + } + + /** + * Ensure that a user with no lastaccess is not flagged for deletion. + */ + public function test_process_none_approved() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + $usercontext = \context_user::instance($user->id); + + // Create an existing expired_context. + $expiredcontext = new expired_context(0, (object) [ + 'contextid' => $usercontext->id, + 'status' => expired_context::STATUS_EXPIRED, + ]); + $expiredcontext->save(); + + $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class) + ->setMethods([ + 'delete_data_for_user', + 'delete_data_for_all_users_in_context', + ]) + ->getMock(); + $mockprivacymanager->expects($this->never())->method('delete_data_for_user'); + $mockprivacymanager->expects($this->never())->method('delete_data_for_all_users_in_context'); + + $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class) + ->setMethods(['get_privacy_manager']) + ->getMock(); + $manager->set_progress(new \null_progress_trace()); + + $manager->method('get_privacy_manager')->willReturn($mockprivacymanager); + list($processedcourses, $processedusers) = $manager->process_approved_deletions(); + + $this->assertEquals(0, $processedcourses); + $this->assertEquals(0, $processedusers); + } + + /** + * Ensure that a user with no lastaccess is not flagged for deletion. + */ + public function test_process_no_context() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H'); + + // Create an existing expired_context. + $expiredcontext = new expired_context(0, (object) [ + 'contextid' => -1, + 'status' => expired_context::STATUS_APPROVED, + ]); + $expiredcontext->save(); + + $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class) + ->setMethods([ + 'delete_data_for_user', + 'delete_data_for_all_users_in_context', + ]) + ->getMock(); + $mockprivacymanager->expects($this->never())->method('delete_data_for_user'); + $mockprivacymanager->expects($this->never())->method('delete_data_for_all_users_in_context'); + + $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class) + ->setMethods(['get_privacy_manager']) + ->getMock(); + $manager->set_progress(new \null_progress_trace()); + + $manager->method('get_privacy_manager')->willReturn($mockprivacymanager); + list($processedcourses, $processedusers) = $manager->process_approved_deletions(); + + $this->assertEquals(0, $processedcourses); + $this->assertEquals(0, $processedusers); + + $this->expectException('dml_missing_record_exception'); + new expired_context($expiredcontext->get('id')); + } + + /** + * Ensure that a user context previously flagged as approved is removed. + */ + public function test_process_user_context() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + $usercontext = \context_user::instance($user->id); + + $this->setUser($user); + $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN); + $blockcontext = \context_block::instance($block->instance->id); + $this->setUser(); + + // Create an existing expired_context. + $expiredcontext = new expired_context(0, (object) [ + 'contextid' => $usercontext->id, + 'status' => expired_context::STATUS_APPROVED, + ]); + $expiredcontext->save(); + + $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class) + ->setMethods([ + 'delete_data_for_user', + 'delete_data_for_all_users_in_context', + ]) + ->getMock(); + $mockprivacymanager->expects($this->atLeastOnce())->method('delete_data_for_user'); + $mockprivacymanager->expects($this->exactly(2)) + ->method('delete_data_for_all_users_in_context') + ->withConsecutive( + [$blockcontext], + [$usercontext] + ); + + $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class) + ->setMethods(['get_privacy_manager']) + ->getMock(); + $manager->set_progress(new \null_progress_trace()); + + $manager->method('get_privacy_manager')->willReturn($mockprivacymanager); + list($processedcourses, $processedusers) = $manager->process_approved_deletions(); + + $this->assertEquals(0, $processedcourses); + $this->assertEquals(1, $processedusers); + + $updatedcontext = new expired_context($expiredcontext->get('id')); + $this->assertEquals(expired_context::STATUS_CLEANED, $updatedcontext->get('status')); + + // Flag all expired contexts again. + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + + $this->assertEquals(0, $flaggedcourses); + $this->assertEquals(0, $flaggedusers); + + // Ensure that the deleted context record is still present. + $updatedcontext = new expired_context($expiredcontext->get('id')); + $this->assertEquals(expired_context::STATUS_CLEANED, $updatedcontext->get('status')); + } + + /** + * Ensure that a course context previously flagged as approved is removed. + */ + public function test_process_course_context() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H'); + + $course = $this->getDataGenerator()->create_course([ + 'startdate' => time() - (2 * YEARSECS), + 'enddate' => time() - YEARSECS, + ]); + $coursecontext = \context_course::instance($course->id); + + // Create an existing expired_context. + $expiredcontext = new expired_context(0, (object) [ + 'contextid' => $coursecontext->id, + 'status' => expired_context::STATUS_APPROVED, + ]); + $expiredcontext->save(); + + $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class) + ->setMethods([ + 'delete_data_for_user', + 'delete_data_for_all_users_in_context', + ]) + ->getMock(); + $mockprivacymanager->expects($this->never())->method('delete_data_for_user'); + $mockprivacymanager->expects($this->once())->method('delete_data_for_all_users_in_context'); + + $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class) + ->setMethods(['get_privacy_manager']) + ->getMock(); + $manager->set_progress(new \null_progress_trace()); + + $manager->method('get_privacy_manager')->willReturn($mockprivacymanager); + list($processedcourses, $processedusers) = $manager->process_approved_deletions(); + + $this->assertEquals(1, $processedcourses); + $this->assertEquals(0, $processedusers); + + $updatedcontext = new expired_context($expiredcontext->get('id')); + $this->assertEquals(expired_context::STATUS_CLEANED, $updatedcontext->get('status')); + } + + /** + * Ensure that a user context previously flagged as approved is not removed if the user then logs in. + */ + public function test_process_user_context_logged_in_after_approval() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + $usercontext = \context_user::instance($user->id); + + $this->setUser($user); + $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN); + $context = \context_block::instance($block->instance->id); + $this->setUser(); + + // Create an existing expired_context. + $expiredcontext = new expired_context(0, (object) [ + 'contextid' => $usercontext->id, + 'status' => expired_context::STATUS_APPROVED, + ]); + $expiredcontext->save(); + + // Now bump the user's last login time. + $this->setUser($user); + user_accesstime_log(); + $this->setUser(); + + $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class) + ->setMethods([ + 'delete_data_for_user', + 'delete_data_for_all_users_in_context', + ]) + ->getMock(); + $mockprivacymanager->expects($this->never())->method('delete_data_for_user'); + $mockprivacymanager->expects($this->never())->method('delete_data_for_all_users_in_context'); + + $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class) + ->setMethods(['get_privacy_manager']) + ->getMock(); + $manager->set_progress(new \null_progress_trace()); + + $manager->method('get_privacy_manager')->willReturn($mockprivacymanager); + list($processedcourses, $processedusers) = $manager->process_approved_deletions(); + + $this->assertEquals(0, $processedcourses); + $this->assertEquals(0, $processedusers); + + $this->expectException('dml_missing_record_exception'); + new expired_context($expiredcontext->get('id')); + } + + /** + * Ensure that a user context previously flagged as approved is not removed if the purpose has changed. + */ + public function test_process_user_context_changed_after_approved() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + $usercontext = \context_user::instance($user->id); + + $this->setUser($user); + $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN); + $context = \context_block::instance($block->instance->id); + $this->setUser(); + + // Create an existing expired_context. + $expiredcontext = new expired_context(0, (object) [ + 'contextid' => $usercontext->id, + 'status' => expired_context::STATUS_APPROVED, + ]); + $expiredcontext->save(); + + // Now make the user a site admin. + $admins = explode(',', get_config('moodle', 'siteadmins')); + $admins[] = $user->id; + set_config('siteadmins', implode(',', $admins)); + + $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class) + ->setMethods([ + 'delete_data_for_user', + 'delete_data_for_all_users_in_context', + ]) + ->getMock(); + $mockprivacymanager->expects($this->never())->method('delete_data_for_user'); + $mockprivacymanager->expects($this->never())->method('delete_data_for_all_users_in_context'); + + $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class) + ->setMethods(['get_privacy_manager']) + ->getMock(); + $manager->set_progress(new \null_progress_trace()); + + $manager->method('get_privacy_manager')->willReturn($mockprivacymanager); + list($processedcourses, $processedusers) = $manager->process_approved_deletions(); + + $this->assertEquals(0, $processedcourses); + $this->assertEquals(0, $processedusers); + + $this->expectException('dml_missing_record_exception'); + new expired_context($expiredcontext->get('id')); + } + + /** + * Ensure that a user with a historically expired expired block record child is cleaned up. + */ + public function test_process_user_historic_block_unapproved() { + $this->resetAfterTest(); + + list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]); + $usercontext = \context_user::instance($user->id); + + $this->setUser($user); + $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN); + $blockcontext = \context_block::instance($block->instance->id); + $this->setUser(); + + // Create an expired_context for the user. + $expiredusercontext = new expired_context(0, (object) [ + 'contextid' => $usercontext->id, + 'status' => expired_context::STATUS_APPROVED, + ]); + $expiredusercontext->save(); + + // Create an existing expired_context which has not been approved for the block. + $expiredblockcontext = new expired_context(0, (object) [ + 'contextid' => $blockcontext->id, + 'status' => expired_context::STATUS_EXPIRED, + ]); + $expiredblockcontext->save(); + + $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class) + ->setMethods([ + 'delete_data_for_user', + 'delete_data_for_all_users_in_context', + ]) + ->getMock(); + $mockprivacymanager->expects($this->atLeastOnce())->method('delete_data_for_user'); + $mockprivacymanager->expects($this->exactly(2)) + ->method('delete_data_for_all_users_in_context') + ->withConsecutive( + [$blockcontext], + [$usercontext] + ); + + $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class) + ->setMethods(['get_privacy_manager']) + ->getMock(); + $manager->set_progress(new \null_progress_trace()); + + $manager->method('get_privacy_manager')->willReturn($mockprivacymanager); + list($processedcourses, $processedusers) = $manager->process_approved_deletions(); + + $this->assertEquals(0, $processedcourses); + $this->assertEquals(1, $processedusers); + + $updatedcontext = new expired_context($expiredusercontext->get('id')); + $this->assertEquals(expired_context::STATUS_CLEANED, $updatedcontext->get('status')); + } + + /** + * Ensure that a user with a block which has a default retention period which has not expired, is still expired. + */ + public function test_process_user_historic_unexpired_child() { + $this->resetAfterTest(); + + list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + $blockpurpose = $this->create_and_set_purpose_for_contextlevel('P5Y', CONTEXT_BLOCK); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]); + $usercontext = \context_user::instance($user->id); + + $this->setUser($user); + $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN); + $blockcontext = \context_block::instance($block->instance->id); + $this->setUser(); + + // Create an expired_context for the user. + $expiredusercontext = new expired_context(0, (object) [ + 'contextid' => $usercontext->id, + 'status' => expired_context::STATUS_APPROVED, + ]); + $expiredusercontext->save(); + + $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class) + ->setMethods([ + 'delete_data_for_user', + 'delete_data_for_all_users_in_context', + ]) + ->getMock(); + $mockprivacymanager->expects($this->atLeastOnce())->method('delete_data_for_user'); + $mockprivacymanager->expects($this->exactly(2)) + ->method('delete_data_for_all_users_in_context') + ->withConsecutive( + [$blockcontext], + [$usercontext] + ); + + $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class) + ->setMethods(['get_privacy_manager']) + ->getMock(); + $manager->set_progress(new \null_progress_trace()); + + $manager->method('get_privacy_manager')->willReturn($mockprivacymanager); + list($processedcourses, $processedusers) = $manager->process_approved_deletions(); + + $this->assertEquals(0, $processedcourses); + $this->assertEquals(1, $processedusers); + + $updatedcontext = new expired_context($expiredusercontext->get('id')); + $this->assertEquals(expired_context::STATUS_CLEANED, $updatedcontext->get('status')); + } + + /** + * Ensure that a course context previously flagged as approved for deletion which now has an unflagged child, is + * updated. + */ + public function test_process_course_context_updated() { + $this->resetAfterTest(); + + $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H'); + + $course = $this->getDataGenerator()->create_course([ + 'startdate' => time() - (2 * YEARSECS), + 'enddate' => time() - YEARSECS, + ]); + $coursecontext = \context_course::instance($course->id); + $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]); + + // Create an existing expired_context. + $expiredcontext = new expired_context(0, (object) [ + 'contextid' => $coursecontext->id, + 'status' => expired_context::STATUS_APPROVED, + ]); + $expiredcontext->save(); + + $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class) + ->setMethods([ + 'delete_data_for_user', + 'delete_data_for_all_users_in_context', + ]) + ->getMock(); + $mockprivacymanager->expects($this->never())->method('delete_data_for_user'); + $mockprivacymanager->expects($this->never())->method('delete_data_for_all_users_in_context'); + + $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class) + ->setMethods(['get_privacy_manager']) + ->getMock(); + $manager->set_progress(new \null_progress_trace()); + $manager->method('get_privacy_manager')->willReturn($mockprivacymanager); + + $coursepurpose = $purposes[2]; + $coursepurpose->set('retentionperiod', 'P5Y'); + $coursepurpose->save(); + + list($processedcourses, $processedusers) = $manager->process_approved_deletions(); + + $this->assertEquals(0, $processedcourses); + $this->assertEquals(0, $processedusers); + + $updatedcontext = new expired_context($expiredcontext->get('id')); + + // No change - we just can't process it until the children have finished. + $this->assertEquals(expired_context::STATUS_APPROVED, $updatedcontext->get('status')); + } + + /** + * Ensure that a course context previously flagged as approved for deletion which now has an unflagged child, is + * updated. + */ + public function test_process_course_context_outstanding_children() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H'); + + $course = $this->getDataGenerator()->create_course([ + 'startdate' => time() - (2 * YEARSECS), + 'enddate' => time() - YEARSECS, + ]); + $coursecontext = \context_course::instance($course->id); + $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]); + + // Create an existing expired_context. + $expiredcontext = new expired_context(0, (object) [ + 'contextid' => $coursecontext->id, + 'status' => expired_context::STATUS_APPROVED, + ]); + $expiredcontext->save(); + + $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class) + ->setMethods([ + 'delete_data_for_user', + 'delete_data_for_all_users_in_context', + ]) + ->getMock(); + $mockprivacymanager->expects($this->never())->method('delete_data_for_user'); + $mockprivacymanager->expects($this->never())->method('delete_data_for_all_users_in_context'); + + $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class) + ->setMethods(['get_privacy_manager']) + ->getMock(); + $manager->set_progress(new \null_progress_trace()); + + $manager->method('get_privacy_manager')->willReturn($mockprivacymanager); + list($processedcourses, $processedusers) = $manager->process_approved_deletions(); + + $this->assertEquals(0, $processedcourses); + $this->assertEquals(0, $processedusers); + + $updatedcontext = new expired_context($expiredcontext->get('id')); + + // No change - we just can't process it until the children have finished. + $this->assertEquals(expired_context::STATUS_APPROVED, $updatedcontext->get('status')); + } + + /** + * Ensure that a course context previously flagged as approved for deletion which now has an unflagged child, is + * updated. + */ + public function test_process_course_context_pending_children() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H'); + + $course = $this->getDataGenerator()->create_course([ + 'startdate' => time() - (2 * YEARSECS), + 'enddate' => time() - YEARSECS, + ]); + $coursecontext = \context_course::instance($course->id); + $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]); + $cm = get_coursemodule_from_instance('forum', $forum->id); + $forumcontext = \context_module::instance($cm->id); + + // Create an existing expired_context for the course. + $expiredcoursecontext = new expired_context(0, (object) [ + 'contextid' => $coursecontext->id, + 'status' => expired_context::STATUS_APPROVED, + ]); + $expiredcoursecontext->save(); + + // And for the forum. + $expiredforumcontext = new expired_context(0, (object) [ + 'contextid' => $forumcontext->id, + 'status' => expired_context::STATUS_EXPIRED, + ]); + $expiredforumcontext->save(); + + $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class) + ->setMethods([ + 'delete_data_for_user', + 'delete_data_for_all_users_in_context', + ]) + ->getMock(); + $mockprivacymanager->expects($this->never())->method('delete_data_for_user'); + $mockprivacymanager->expects($this->never())->method('delete_data_for_all_users_in_context'); + + $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class) + ->setMethods(['get_privacy_manager']) + ->getMock(); + $manager->set_progress(new \null_progress_trace()); + + $manager->method('get_privacy_manager')->willReturn($mockprivacymanager); + list($processedcourses, $processedusers) = $manager->process_approved_deletions(); + + $this->assertEquals(0, $processedcourses); + $this->assertEquals(0, $processedusers); + + $updatedcontext = new expired_context($expiredcoursecontext->get('id')); + + // No change - we just can't process it until the children have finished. + $this->assertEquals(expired_context::STATUS_APPROVED, $updatedcontext->get('status')); + } + + /** + * Ensure that a course context previously flagged as approved for deletion which now has an unflagged child, is + * updated. + */ + public function test_process_course_context_approved_children() { + $this->resetAfterTest(); + + $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H'); + + $course = $this->getDataGenerator()->create_course([ + 'startdate' => time() - (2 * YEARSECS), + 'enddate' => time() - YEARSECS, + ]); + $coursecontext = \context_course::instance($course->id); + $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]); + $cm = get_coursemodule_from_instance('forum', $forum->id); + $forumcontext = \context_module::instance($cm->id); + + // Create an existing expired_context for the course. + $expiredcoursecontext = new expired_context(0, (object) [ + 'contextid' => $coursecontext->id, + 'status' => expired_context::STATUS_APPROVED, + ]); + $expiredcoursecontext->save(); + + // And for the forum. + $expiredforumcontext = new expired_context(0, (object) [ + 'contextid' => $forumcontext->id, + 'status' => expired_context::STATUS_APPROVED, + ]); + $expiredforumcontext->save(); + + $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class) + ->setMethods([ + 'delete_data_for_user', + 'delete_data_for_all_users_in_context', + ]) + ->getMock(); + $mockprivacymanager->expects($this->never())->method('delete_data_for_user'); + $mockprivacymanager->expects($this->exactly(2)) + ->method('delete_data_for_all_users_in_context') + ->withConsecutive( + [$forumcontext], + [$coursecontext] + ); + + $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class) + ->setMethods(['get_privacy_manager']) + ->getMock(); + $manager->set_progress(new \null_progress_trace()); + + $manager->method('get_privacy_manager')->willReturn($mockprivacymanager); + + // Initially only the forum will be processed. + list($processedcourses, $processedusers) = $manager->process_approved_deletions(); + + $this->assertEquals(1, $processedcourses); + $this->assertEquals(0, $processedusers); + + $updatedcontext = new expired_context($expiredforumcontext->get('id')); + $this->assertEquals(expired_context::STATUS_CLEANED, $updatedcontext->get('status')); + + // The course won't have been processed yet. + $updatedcontext = new expired_context($expiredcoursecontext->get('id')); + $this->assertEquals(expired_context::STATUS_APPROVED, $updatedcontext->get('status')); + + // A subsequent run will cause the course to processed as it is no longer dependent upon the child contexts. + list($processedcourses, $processedusers) = $manager->process_approved_deletions(); + + $this->assertEquals(1, $processedcourses); + $this->assertEquals(0, $processedusers); + $updatedcontext = new expired_context($expiredcoursecontext->get('id')); + $this->assertEquals(expired_context::STATUS_CLEANED, $updatedcontext->get('status')); + } + + /** + * Test that the can_process_deletion function returns expected results. + * + * @dataProvider can_process_deletion_provider + * @param int $status + * @param bool $expected + */ + public function test_can_process_deletion($status, $expected) { + $purpose = new expired_context(0, (object) [ + 'status' => $status, + + 'contextid' => \context_system::instance()->id, + ]); + + $this->assertEquals($expected, $purpose->can_process_deletion()); + } + + /** + * Data provider for the can_process_deletion tests. + * + * @return array + */ + public function can_process_deletion_provider() : array { + return [ + 'Pending' => [ + expired_context::STATUS_EXPIRED, + false, + ], + 'Approved' => [ + expired_context::STATUS_APPROVED, + true, + ], + 'Complete' => [ + expired_context::STATUS_CLEANED, + false, + ], ]; - api::set_contextlevel($record); + } - list($purposevar, $categoryvar) = data_registry::var_names_from_context( - \context_helper::get_class_for_level(CONTEXT_COURSE) - ); - set_config($purposevar, $purpose1->get('id'), 'tool_dataprivacy'); + /** + * Test that the is_complete function returns expected results. + * + * @dataProvider is_complete_provider + * @param int $status + * @param bool $expected + */ + public function test_is_complete($status, $expected) { + $purpose = new expired_context(0, (object) [ + 'status' => $status, + 'contextid' => \context_system::instance()->id, + ]); - // A lot more time for modules. - list($purposevar, $categoryvar) = data_registry::var_names_from_context( - \context_helper::get_class_for_level(CONTEXT_MODULE) - ); - set_config($purposevar, $purpose2->get('id'), 'tool_dataprivacy'); + $this->assertEquals($expected, $purpose->is_complete()); + } - $course1 = $this->getDataGenerator()->create_course(); - - // Course finished last week (so purpose1 retention period does delete stuff but purpose2 retention period does not). - $dt = new \DateTime(); - $di = new \DateInterval('P7D'); - $dt->sub($di); - - $course2 = $this->getDataGenerator()->create_course(['startdate' => '1', 'enddate' => $dt->getTimestamp()]); - $forum1 = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id)); - $forum2 = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id)); - - // We want to override this last module instance purpose so we can test that modules are also - // returned as expired. - $forum2ctx = \context_module::instance($forum2->cmid); - $record = (object)[ - 'purposeid' => $purpose1->get('id'), - 'categoryid' => $cat->get('id'), - 'contextid' => $forum2ctx->id, + /** + * Data provider for the is_complete tests. + * + * @return array + */ + public function is_complete_provider() : array { + return [ + 'Pending' => [ + expired_context::STATUS_EXPIRED, + false, + ], + 'Approved' => [ + expired_context::STATUS_APPROVED, + false, + ], + 'Complete' => [ + expired_context::STATUS_CLEANED, + true, + ], ]; - api::set_context_instance($record); + } - // Ongoing course. - $course3 = $this->getDataGenerator()->create_course(['startdate' => '1', 'enddate' => time()]); - $forum3 = $this->getDataGenerator()->create_module('forum', array('course' => $course3->id)); + /** + * Ensure that any orphaned records are removed once the context has been removed. + */ + public function test_orphaned_records_are_cleared() { + $this->resetAfterTest(); - $expired = new \tool_dataprivacy\expired_course_related_contexts(); - $numexpired = $expired->flag_expired(); + $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H'); - // Only 1 module has expired. - $this->assertEquals(1, $numexpired); - $this->assertEquals(1, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_EXPIRED])); + $course = $this->getDataGenerator()->create_course([ + 'startdate' => time() - (2 * YEARSECS), + 'enddate' => time() - YEARSECS, + ]); + $context = \context_course::instance($course->id); - // Add a forum1 override to 1h retention period so both forum1 and course2 are also expired. - $forum1ctx = \context_module::instance($forum1->cmid); - $record->purposeid = $purpose1->get('id'); - $record->contextid = $forum1ctx->id; - api::set_context_instance($record); - $numexpired = $expired->flag_expired(); - $this->assertEquals(2, $numexpired); - $this->assertEquals(3, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_EXPIRED])); + // Flag all expired contexts. + $manager = new \tool_dataprivacy\expired_contexts_manager(); + $manager->set_progress(new \null_progress_trace()); + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); - // Approve forum1 to be deleted. - $expiredctx = expired_context::get_record(['contextid' => $forum1ctx->id]); - api::set_expired_context_status($expiredctx, expired_context::STATUS_APPROVED); + $this->assertEquals(1, $flaggedcourses); + $this->assertEquals(0, $flaggedusers); - // Delete expired contexts. - $deleted = $expired->delete(); - $this->assertEquals(1, $deleted); - $this->assertEquals(1, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_CLEANED])); + // Ensure that the record currently exists. + $expiredcontext = expired_context::get_record(['contextid' => $context->id]); + $this->assertNotFalse($expiredcontext); - $expiredctx = expired_context::get_record(['contextid' => $forum2ctx->id]); - api::set_expired_context_status($expiredctx, expired_context::STATUS_APPROVED); + // Approve it. + $expiredcontext->set('status', expired_context::STATUS_APPROVED)->save(); - $course2ctx = \context_course::instance($course2->id); - $expiredctx = expired_context::get_record(['contextid' => $course2ctx->id]); - api::set_expired_context_status($expiredctx, expired_context::STATUS_APPROVED); + // Process deletions + list($processedcourses, $processedusers) = $manager->process_approved_deletions(); - // Delete expired contexts. - $deleted = $expired->delete(); - $this->assertEquals(2, $deleted); - $this->assertEquals(3, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_CLEANED])); + $this->assertEquals(1, $processedcourses); + $this->assertEquals(0, $processedusers); - // No new records are generated. - $numexpired = $expired->flag_expired(); - $this->assertEquals(0, $numexpired); - $this->assertEquals(3, $DB->count_records('tool_dataprivacy_ctxexpired')); - $deleted = $expired->delete(); - $this->assertEquals(0, $deleted); + // Ensure that the record still exists. + $expiredcontext = expired_context::get_record(['contextid' => $context->id]); + $this->assertNotFalse($expiredcontext); + // Remove the actual course. + delete_course($course->id, false); + + // The record will still exist until we flag it again. + $expiredcontext = expired_context::get_record(['contextid' => $context->id]); + $this->assertNotFalse($expiredcontext); + + list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); + $expiredcontext = expired_context::get_record(['contextid' => $context->id]); + $this->assertFalse($expiredcontext); + } + + /** + * Ensure that the progres tracer works as expected out of the box. + */ + public function test_progress_tracer_default() { + $manager = new \tool_dataprivacy\expired_contexts_manager(); + + $rc = new \ReflectionClass(\tool_dataprivacy\expired_contexts_manager::class); + $rcm = $rc->getMethod('get_progress'); + + $rcm->setAccessible(true); + $this->assertInstanceOf(\text_progress_trace::class, $rcm->invoke($manager)); + } + + /** + * Ensure that the progres tracer works as expected when given a specific traer. + */ + public function test_progress_tracer_set() { + $manager = new \tool_dataprivacy\expired_contexts_manager(); + $mytrace = new \null_progress_trace(); + $manager->set_progress($mytrace); + + $rc = new \ReflectionClass(\tool_dataprivacy\expired_contexts_manager::class); + $rcm = $rc->getMethod('get_progress'); + + $rcm->setAccessible(true); + $this->assertSame($mytrace, $rcm->invoke($manager)); + } + + /** + * Creates an HTML block on a user. + * + * @param string $title + * @param string $body + * @param string $format + * @return \block_instance + */ + protected function create_user_block($title, $body, $format) { + global $USER; + + $configdata = (object) [ + 'title' => $title, + 'text' => [ + 'itemid' => 19, + 'text' => $body, + 'format' => $format, + ], + ]; + + $this->create_block($this->construct_user_page($USER)); + $block = $this->get_last_block_on_page($this->construct_user_page($USER)); + $block = block_instance('html', $block->instance); + $block->instance_config_save((object) $configdata); + + return $block; + } + + /** + * Creates an HTML block on a page. + * + * @param \page $page Page + */ + protected function create_block($page) { + $page->blocks->add_block_at_end_of_default_region('html'); + } + + /** + * Constructs a Page object for the User Dashboard. + * + * @param \stdClass $user User to create Dashboard for. + * @return \moodle_page + */ + protected function construct_user_page(\stdClass $user) { + $page = new \moodle_page(); + $page->set_context(\context_user::instance($user->id)); + $page->set_pagelayout('mydashboard'); + $page->set_pagetype('my-index'); + $page->blocks->load_blocks(); + return $page; + } + + /** + * Get the last block on the page. + * + * @param \page $page Page + * @return \block_html Block instance object + */ + protected function get_last_block_on_page($page) { + $blocks = $page->blocks->get_blocks_for_region($page->blocks->get_default_region()); + $block = end($blocks); + + return $block; } }