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; } }