MDL-63401 tool_dataprivacy: Rewrite expired deletion handling
authorAndrew Nicols <andrew@nicols.co.uk>
Thu, 20 Sep 2018 01:10:38 +0000 (09:10 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Mon, 8 Oct 2018 12:49:55 +0000 (20:49 +0800)
This change rewrites the way in which expiry is calculated and addresses
a number of closely related issues:

Users can customise, and add blocks with data to, their dashboard.  When
a user had done so, the user could be flagged for deletion, but the
blocks in their Dashboard were subject to the default block retention
policy. In addition there is no way to override the retention policy for
user blocks.

This change modifies the structure of the expiry mechanism:
- to consider any subcontext of the user context to be a part of the user
  context during calculation. User child contexts are not the property
  of the system, and should not be treated separately.
- the way in which the context expiry mechanism worked was to select
  use a multiple different managers based solely on the context level.
  Because of the use of user blocks, this proved to be unreliable as
  blocks has been attributed purely to courses.
  This has been changed to a single manager which is aware of hierarchy
  and deletions as a whole.
- to prepare for upcoming work relating to more detailed expiry
  mechanisms, a new expiry_info class is introduced and used to
  merge the expiry of child contexts into a working in-memory view.

This changeset includes extensive unit tests.

12 files changed:
admin/tool/dataprivacy/classes/api.php
admin/tool/dataprivacy/classes/data_registry.php
admin/tool/dataprivacy/classes/expired_context.php
admin/tool/dataprivacy/classes/expired_contexts_manager.php
admin/tool/dataprivacy/classes/expired_course_related_contexts.php [deleted file]
admin/tool/dataprivacy/classes/expired_user_contexts.php [deleted file]
admin/tool/dataprivacy/classes/expiry_info.php [new file with mode: 0644]
admin/tool/dataprivacy/classes/external.php
admin/tool/dataprivacy/classes/task/delete_expired_contexts.php
admin/tool/dataprivacy/classes/task/expired_retention_period.php
admin/tool/dataprivacy/tests/api_test.php
admin/tool/dataprivacy/tests/expired_contexts_test.php

index 3f7fb5e..ee22ee0 100644 (file)
@@ -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.
      *
index 7b46b4d..d10ab83 100644 (file)
@@ -189,14 +189,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) {
@@ -215,20 +215,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;
+                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);
                 }
-                // 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);
             }
+
+            // 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.
index 3010438..5ac228d 100644 (file)
@@ -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);
+    }
 }
index 539fc28..c660c01 100644 (file)
@@ -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;
 
+    /** @var progress_trace The log progress tracer */
+    protected $progresstracer = null;
+
+    /** @var manager The privacy manager */
+    protected $manager = null;
+
     /**
-     * Returns the list of expired context instances.
+     * Flag expired contexts as expired.
      *
-     * @return \stdClass[]
+     * @return  int[]   The number of contexts flagged as expired for courses, and users.
+     */
+    public function flag_expired_contexts() : array {
+        if (!$this->check_requirements()) {
+            return [0, 0];
+        }
+
+        // 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++;
+            }
+        }
+
+        $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.
      */
-    abstract protected function get_expired_contexts();
+    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();
+        }
+    }
 
     /**
-     * Specify with context levels this expired contexts manager is deleting.
+     * Get the full nested set of expiry data relating to all contexts.
      *
-     * @return int[]
+     * @param   string      $contextpath A contexpath to restrict results to
+     * @return  \stdClass[]
      */
-    abstract protected function get_context_levels();
+    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);
+    }
 
     /**
-     * Flag expired contexts as expired.
+     * Get the full nested set of expiry data relating to course-related contexts.
      *
-     * @return int The number of contexts flagged as expired.
+     * @param   string      $contextpath A contexpath to restrict results to
+     * @return  \stdClass[]
      */
-    public function flag_expired() {
+    protected static function get_nested_expiry_info_for_courses($contextpath = '') : array {
+        global $DB;
 
-        if (!$this->check_requirements()) {
-            return 0;
-        }
+        $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 = '';
 
-        $contexts = $this->get_expired_contexts();
-        foreach ($contexts as $context) {
-            api::create_expired_context($context->id);
+        if (!empty($contextpath)) {
+            $where = "AND (ctx.path = :pathmatchexact OR ctx.path LIKE :pathmatchchildren)";
+            $params['pathmatchexact'] = $contextpath;
+            $params['pathmatchchildren'] = "{$contextpath}/%";
         }
 
-        return count($contexts);
+        $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);
     }
 
     /**
-     * Deletes the expired contexts.
+     * Get the full nested set of expiry data.
      *
-     * @return int The number of deleted contexts.
+     * @param   string      $contextpath A contexpath to restrict results to
+     * @return  \stdClass[]
      */
-    public function delete() {
+    protected static function get_nested_expiry_info_for_user($contextpath = '') : array {
+        global $DB;
 
-        $numprocessed = 0;
+        $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]);
 
-        if (!$this->check_requirements()) {
-            return $numprocessed;
+        // 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;
         }
 
-        $privacymanager = new manager();
-        $privacymanager->set_observer(new \tool_dataprivacy\manager_observer());
+        $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";
 
-        foreach ($this->get_context_levels() as $level) {
+        return self::get_nested_expiry_info_from_sql($sql, $params);
+    }
 
-            $expiredcontexts = expired_context::get_records_by_contextlevel($level, expired_context::STATUS_APPROVED);
+    /**
+     * 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;
+            }
 
-            foreach ($expiredcontexts as $expiredctx) {
+            $purposevalue = $record->purposeid !== null ? $record->purposeid : context_instance::NOTSET;
+            $purpose = api::get_effective_context_purpose($context, $purposevalue);
 
-                if (!$this->delete_expired_context($privacymanager, $expiredctx)) {
+            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;
                 }
 
-                $numprocessed += 1;
-                if ($numprocessed == self::DELETE_LIMIT) {
-                    // Close the recordset.
-                    $expiredcontexts->close();
-                    break 2;
+                $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.
+     */
+    public function process_approved_deletions() : array {
+        if (!$this->check_requirements()) {
+            return [0, 0];
+        }
+
+        $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;
+            }
+
+            if ($this->delete_expired_context($expiredctx)) {
+                if ($context instanceof \context_user) {
+                    $usercount++;
+                } else {
+                    $coursecount++;
+                }
+
+                $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'));
+
+        $this->get_progress()->output("Deleting context {$context->id} - " . $context->get_context_name(true, true));
 
-        $context = \context::instance_by_id($expiredctx->get('contextid'), IGNORE_MISSING);
-        if (!$context) {
-            api::delete_expired_context($expiredctx->get('contextid'));
+        // 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 (file)
index f878edf..0000000
+++ /dev/null
@@ -1,135 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * 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 (file)
index 924d565..0000000
+++ /dev/null
@@ -1,150 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * 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 (file)
index 0000000..508214f
--- /dev/null
@@ -0,0 +1,93 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Expiry Data.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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;
+    }
+}
index f99ed34..f1ecec1 100644 (file)
@@ -1024,7 +1024,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) {
@@ -1079,8 +1078,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();
@@ -1232,24 +1232,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;
+                        }
                     }
                 }
 
index 55deeec..8a4f96b 100644 (file)
@@ -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");
     }
 }
index 1123067..f00b51a 100644 (file)
@@ -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");
     }
 }
index 1864a03..0f45846 100644 (file)
@@ -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.
      *
index d480e57..fffe475 100644 (file)
@@ -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);
-
-        $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'));
+        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');
+        }
+
+        return $purpose;
+    }
+
+    /**
+     * Ensure that a user with no lastaccess is not flagged for deletion.
+     */
+    public function test_flag_not_setup() {
+        $this->resetAfterTest();
+
+        $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 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();
 
-        // 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]));
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
 
-        // 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]));
+        $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]);
 
-        // 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]));
+        // Create an existing expired_context.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => $coursecontext->id,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredcontext->save();
 
-        // 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);
+        $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');
 
-        // No user data left in mod_assign.
-        $this->assertEquals(0, $DB->count_records('assign_submission'));
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+        $manager->set_progress(new \null_progress_trace());
 
-        // The user is deleted.
-        $deleteduser = \core_user::get_user($user2->id, 'id, deleted', IGNORE_MISSING);
-        $this->assertEquals(1, $deleteduser->deleted);
+        $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'));
     }
 
     /**
-     * Test expired course and course stuff flagging and deletion.
+     * 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.
      *
-     * @return null
+     * @dataProvider    can_process_deletion_provider
+     * @param       int     $status
+     * @param       bool    $expected
      */
-    public function test_expired_course_related_contexts() {
-        global $DB;
+    public function test_can_process_deletion($status, $expected) {
+        $purpose = new expired_context(0, (object) [
+            'status' => $status,
+
+            'contextid' => \context_system::instance()->id,
+        ]);
 
-        $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']);
+        $this->assertEquals($expected, $purpose->can_process_deletion());
+    }
 
-        $record = (object)[
-            'purposeid' => $purpose1->get('id'),
-            'categoryid' => $cat->get('id'),
-            'contextlevel' => CONTEXT_SYSTEM,
+    /**
+     * 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');
-
-        // 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');
-
-        $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,
+    }
+
+    /**
+     * 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,
+        ]);
+
+        $this->assertEquals($expected, $purpose->is_complete());
+    }
+
+    /**
+     * 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));
-
-        $expired = new \tool_dataprivacy\expired_course_related_contexts();
-        $numexpired = $expired->flag_expired();
-
-        // Only 1 module has expired.
-        $this->assertEquals(1, $numexpired);
-        $this->assertEquals(1, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_EXPIRED]));
-
-        // 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]));
-
-        // Approve forum1 to be deleted.
-        $expiredctx = expired_context::get_record(['contextid' => $forum1ctx->id]);
-        api::set_expired_context_status($expiredctx, 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_CLEANED]));
-
-        $expiredctx = expired_context::get_record(['contextid' => $forum2ctx->id]);
-        api::set_expired_context_status($expiredctx, expired_context::STATUS_APPROVED);
-
-        $course2ctx = \context_course::instance($course2->id);
-        $expiredctx = expired_context::get_record(['contextid' => $course2ctx->id]);
-        api::set_expired_context_status($expiredctx, expired_context::STATUS_APPROVED);
-
-        // Delete expired contexts.
-        $deleted = $expired->delete();
-        $this->assertEquals(2, $deleted);
-        $this->assertEquals(3, $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(3, $DB->count_records('tool_dataprivacy_ctxexpired'));
-        $deleted = $expired->delete();
-        $this->assertEquals(0, $deleted);
+    }
+
+    /**
+     * Ensure that any orphaned records are removed once the context has been removed.
+     */
+    public function test_orphaned_records_are_cleared() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
+
+        $course = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * YEARSECS),
+                'enddate' => time() - YEARSECS,
+            ]);
+        $context = \context_course::instance($course->id);
+
+        // 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();
+
+        $this->assertEquals(1, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+
+        // Ensure that the record currently exists.
+        $expiredcontext =  expired_context::get_record(['contextid' => $context->id]);
+        $this->assertNotFalse($expiredcontext);
+
+        // Approve it.
+        $expiredcontext->set('status', expired_context::STATUS_APPROVED)->save();
+
+        // Process deletions
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
+
+        $this->assertEquals(1, $processedcourses);
+        $this->assertEquals(0, $processedusers);
+
+        // 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;
     }
 }