MDL-63496 tool_dataprivacy: Support for per-role retention
authorAndrew Nicols <andrew@nicols.co.uk>
Mon, 3 Sep 2018 06:31:38 +0000 (14:31 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 17 Oct 2018 05:12:18 +0000 (13:12 +0800)
This issue is a part of the MDL-62560 Epic.

admin/tool/dataprivacy/classes/expired_context.php
admin/tool/dataprivacy/classes/expired_contexts_manager.php
admin/tool/dataprivacy/classes/expiry_info.php
admin/tool/dataprivacy/classes/filtered_userlist.php [new file with mode: 0644]
admin/tool/dataprivacy/classes/purpose.php
admin/tool/dataprivacy/classes/purpose_override.php [new file with mode: 0644]
admin/tool/dataprivacy/db/install.xml
admin/tool/dataprivacy/db/upgrade.php
admin/tool/dataprivacy/tests/expired_contexts_test.php
admin/tool/dataprivacy/version.php

index 5ac228d..26143b5 100644 (file)
@@ -60,12 +60,27 @@ class expired_context extends \core\persistent {
      * @return array
      */
     protected static function define_properties() {
-        return array(
-            'contextid' => array(
+        return [
+            'contextid' => [
                 'type' => PARAM_INT,
                 'description' => 'The context id.',
-            ),
-            'status' => array(
+            ],
+            'defaultexpired' => [
+                'type' => PARAM_INT,
+                'description' => 'Whether to default retention period for the purpose has been reached',
+                'default' => 1,
+            ],
+            'expiredroles' => [
+                'type' => PARAM_TEXT,
+                'description' => 'This list of roles to include during deletion',
+                'default'  => '',
+            ],
+            'unexpiredroles' => [
+                'type' => PARAM_TEXT,
+                'description' => 'This list of roles to exclude during deletion',
+                'default'  => '',
+            ],
+            'status' => [
                 'choices' => [
                     self::STATUS_EXPIRED,
                     self::STATUS_APPROVED,
@@ -73,8 +88,8 @@ class expired_context extends \core\persistent {
                 ],
                 'type' => PARAM_INT,
                 'description' => 'The deletion status of the context.',
-            ),
-        );
+            ],
+        ];
     }
 
     /**
@@ -160,21 +175,130 @@ class expired_context extends \core\persistent {
         return $DB->count_records_sql($sql, $params);
     }
 
+    /**
+     * Set the list of role IDs for either expiredroles, or unexpiredroles.
+     *
+     * @param   string  $field
+     * @param   int[]   $roleids
+     * @return  expired_context
+     */
+    protected function set_roleids_for(string $field, array $roleids) : expired_context {
+        $roledata = json_encode($roleids);
+
+        $this->raw_set($field, $roledata);
+
+        return $this;
+    }
+
+    /**
+     * Get the list of role IDs for either expiredroles, or unexpiredroles.
+     *
+     * @param   string  $field
+     * @return  int[]
+     */
+    protected function get_roleids_for(string $field) {
+        $value = $this->raw_get($field);
+        if (empty($value)) {
+            return [];
+        }
+
+        return json_decode($value);
+    }
+
+    /**
+     * Set the list of unexpired role IDs.
+     *
+     * @param   int[]   $roleids
+     * @return  expired_context
+     */
+    protected function set_unexpiredroles(array $roleids) : expired_context {
+        $this->set_roleids_for('unexpiredroles', $roleids);
+
+        return $this;
+    }
+
+    /**
+     * Add a set of role IDs to the list of expired role IDs.
+     *
+     * @param   int[]   $roleids
+     * @return  expired_context
+     */
+    public function add_expiredroles(array $roleids) : expired_context {
+        $existing = $this->get('expiredroles');
+        $newvalue = array_merge($existing, $roleids);
+
+        $this->set('expiredroles', $newvalue);
+
+        return $this;
+    }
+
+    /**
+     * Add a set of role IDs to the list of unexpired role IDs.
+     *
+     * @param   int[]   $roleids
+     * @return  unexpired_context
+     */
+    public function add_unexpiredroles(array $roleids) : expired_context {
+        $existing = $this->get('unexpiredroles');
+        $newvalue = array_merge($existing, $roleids);
+
+        $this->set('unexpiredroles', $newvalue);
+
+        return $this;
+    }
+
+    /**
+     * Set the list of expired role IDs.
+     *
+     * @param   int[]   $roleids
+     * @return  expired_context
+     */
+    protected function set_expiredroles(array $roleids) : expired_context {
+        $this->set_roleids_for('expiredroles', $roleids);
+
+        return $this;
+    }
+
+    /**
+     * Get the list of expired role IDs.
+     *
+     * @return  int[]
+     */
+    protected function get_expiredroles() {
+        return $this->get_roleids_for('expiredroles');
+    }
+
+    /**
+     * Get the list of unexpired role IDs.
+     *
+     * @return  int[]
+     */
+    protected function get_unexpiredroles() {
+        return $this->get_roleids_for('unexpiredroles');
+    }
+
     /**
      * Create a new expired_context based on the context, and expiry_info object.
      *
      * @param   \context        $context
      * @param   expiry_info     $info
+     * @param   boolean         $save
      * @return  expired_context
      */
-    public static function create_from_expiry_info(\context $context, expiry_info $info) : expired_context {
+    public static function create_from_expiry_info(\context $context, expiry_info $info, bool $save = true) : expired_context {
         $record = (object) [
             'contextid' => $context->id,
             'status' => self::STATUS_EXPIRED,
+            'defaultexpired' => (int) $info->is_default_expired(),
         ];
 
         $expiredcontext = new static(0, $record);
-        $expiredcontext->save();
+        $expiredcontext->set('expiredroles', $info->get_expired_roles());
+        $expiredcontext->set('unexpiredroles', $info->get_unexpired_roles());
+
+        if ($save) {
+            $expiredcontext->save();
+        }
 
         return $expiredcontext;
     }
@@ -186,7 +310,42 @@ class expired_context extends \core\persistent {
      * @return  $this
      */
     public function update_from_expiry_info(expiry_info $info) : expired_context {
+        $save = false;
+
+        // Compare the expiredroles.
+        $thisexpired = $this->get('expiredroles');
+        $infoexpired = $info->get_expired_roles();
+
+        sort($thisexpired);
+        sort($infoexpired);
+        if ($infoexpired != $thisexpired) {
+            $this->set('expiredroles', $infoexpired);
+            $save = true;
+        }
+
+        // Compare the unexpiredroles.
+        $thisunexpired = $this->get('unexpiredroles');
+        $infounexpired = $info->get_unexpired_roles();
+
+        sort($thisunexpired);
+        sort($infounexpired);
+        if ($infounexpired != $thisunexpired) {
+            $this->set('unexpiredroles', $infounexpired);
+            $save = true;
+        }
+
+        if (empty($this->get('defaultexpired')) == $info->is_default_expired()) {
+            $this->set('defaultexpired', (int) $info->is_default_expired());
+            $save = true;
+        }
+
+        if ($save) {
+            $this->set('status', self::STATUS_EXPIRED);
+            $this->save();
+        }
+
         return $this;
+
     }
 
     /**
@@ -206,4 +365,14 @@ class expired_context extends \core\persistent {
     public function is_complete() : bool {
         return ($this->get('status') == self::STATUS_CLEANED);
     }
+
+    /**
+     * 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->get('defaultexpired') && empty($this->get('unexpiredroles'));
+    }
 }
index be8d731..3f27b80 100644 (file)
@@ -271,6 +271,7 @@ class expired_contexts_manager {
             } 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) {
@@ -279,6 +280,7 @@ class expired_contexts_manager {
 
                 $expiryinfo->merge_with_child($data->info);
             }
+
             $datalist[$context->path] = (object) [
                 'context' => $context,
                 'record' => $record,
@@ -425,11 +427,39 @@ class expired_contexts_manager {
         }
 
         $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);
+        if ($expiredctx->is_fully_expired()) {
+            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, and there are
+                // no remaining overrides.
+                $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;
+        }
+
+        // We need to find all users in the context, and delete just those who have expired.
+        $collection = $privacymanager->get_users_in_context($context);
+
+        // Apply the expired and unexpired filters to remove the users in these categories.
+        $userassignments = $this->get_role_users_for_expired_context($expiredctx, $context);
+        $approvedcollection = new \core_privacy\local\request\userlist_collection($context);
+        foreach ($collection as $pendinguserlist) {
+            $userlist = filtered_userlist::create_from_userlist($pendinguserlist);
+            $userlist->apply_expired_context_filters($userassignments->expired, $userassignments->unexpired);
+            if (count($userlist)) {
+                $approvedcollection->add_userlist($userlist);
+            }
+        }
+
+        if (count($approvedcollection)) {
+            // Perform the deletion with the newly approved collection.
+            $privacymanager->delete_data_for_users_in_context($approvedcollection);
         }
 
         // Mark the record as cleaned.
@@ -545,14 +575,40 @@ class expired_contexts_manager {
      * @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;
+        $overrides = $purpose->get_purpose_overrides();
+        $expiredroles = $unexpiredroles = [];
+        if (empty($overrides)) {
+            // There are no overrides for this purpose.
+            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, [], []);
         } else {
-            $defaultexpired = static::has_expired($purpose->get('retentionperiod'), $comparisondate);
-        }
+            foreach ($overrides as $override) {
+                if (static::has_expired($override->get('retentionperiod'), $comparisondate)) {
+                    // This role has expired.
+                    $expiredroles[] = $override->get('roleid');
+                } else {
+                    // This role has not yet expired.
+                    $unexpiredroles[] = $override->get('roleid');
+                }
+            }
+
+            $defaultexpired = false;
+            if (static::has_expired($purpose->get('retentionperiod'), $comparisondate)) {
+                $defaultexpired = true;
+            }
 
-        return new expiry_info($defaultexpired);
+            if ($defaultexpired) {
+                $expiredroles = [];
+            }
+
+            return new expiry_info($defaultexpired, $expiredroles, $unexpiredroles);
+        }
     }
 
     /**
@@ -565,7 +621,7 @@ class expired_contexts_manager {
      * @return  expired_context|null
      */
     protected function update_from_expiry_info(\stdClass $expiryrecord) {
-        if ($expiryrecord->info->is_any_expired()) {
+        if ($isanyexpired = $expiryrecord->info->is_any_expired()) {
             // The context is expired in some fashion.
             // Create or update as required.
             if ($expiryrecord->record->expiredctxid) {
@@ -579,6 +635,15 @@ class expired_contexts_manager {
                 $expiredcontext = expired_context::create_from_expiry_info($expiryrecord->context, $expiryrecord->info);
             }
 
+            if ($expiryrecord->context instanceof \context_user) {
+                $userassignments = $this->get_role_users_for_expired_context($expiredcontext, $expiryrecord->context);
+                if (!empty($userassignments->unexpired)) {
+                    $expiredcontext->delete();
+
+                    return null;
+                }
+            }
+
             return $expiredcontext;
         } else {
             // The context is not expired.
@@ -608,7 +673,6 @@ class expired_contexts_manager {
         // 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;
@@ -650,6 +714,65 @@ class expired_contexts_manager {
         return $expiredctx;
     }
 
+    /**
+     * Get the list of actual users for the combination of expired, and unexpired roles.
+     *
+     * @param   expired_context $expiredctx
+     * @param   \context        $context
+     * @return  \stdClass
+     */
+    protected function get_role_users_for_expired_context(expired_context $expiredctx, \context $context) : \stdClass {
+        $expiredroles = $expiredctx->get('expiredroles');
+        $expiredroleusers = [];
+        if (!empty($expiredroles)) {
+            // Find the list of expired role users.
+            $expiredroleuserassignments = get_role_users($expiredroles, $context, true, 'ra.id, u.id AS userid', 'ra.id');
+            $expiredroleusers = array_map(function($assignment) {
+                    return $assignment->userid;
+                }, $expiredroleuserassignments);
+        }
+        $expiredroleusers = array_unique($expiredroleusers);
+
+        $unexpiredroles = $expiredctx->get('unexpiredroles');
+        $unexpiredroleusers = [];
+        if (!empty($unexpiredroles)) {
+            // Find the list of unexpired role users.
+            $unexpiredroleuserassignments = get_role_users($unexpiredroles, $context, true, 'ra.id, u.id AS userid', 'ra.id');
+            $unexpiredroleusers = array_map(function($assignment) {
+                    return $assignment->userid;
+                }, $unexpiredroleuserassignments);
+        }
+        $unexpiredroleusers = array_unique($unexpiredroleusers);
+
+        if (!$expiredctx->get('defaultexpired')) {
+            $tofilter = get_users_roles($context, $expiredroleusers);
+            $tofilter = array_filter($tofilter, function($userroles) use ($expiredroles) {
+                // Each iteration contains the list of role assignment for a specific user.
+                // All roles that the user holds must match those in the list of expired roles.
+                if (count($userroles) === 1) {
+                    // Shortcut - only one role held which must be one of the expired roles.
+                    // TODO I think this is wrong.
+                    return false;
+                }
+
+                foreach ($userroles as $ra) {
+                    if (false === array_search($ra->roleid, $expiredroles)) {
+                        // This role was not found in the list of assignments.
+                        return true;
+                    }
+                }
+
+                return false;
+            });
+            $unexpiredroleusers = array_merge($unexpiredroleusers, array_keys($tofilter));
+        }
+
+        return (object) [
+            'expired' => $expiredroleusers,
+            'unexpired' => $unexpiredroleusers,
+        ];
+    }
+
     /**
      * Check whether the course has expired.
      *
index 508214f..04d4858 100644 (file)
@@ -36,15 +36,28 @@ defined('MOODLE_INTERNAL') || die();
 class expiry_info {
 
     /** @var bool Whether this context is fully expired */
-    protected $isexpired = false;
+    protected $fullyexpired = false;
+
+    /** @var bool Whether the default expiry value of this purpose has been reached */
+    protected $defaultexpiryreached = false;
+
+    /** @var int[] List of expires roles */
+    protected $expired = [];
+
+    /** @var int[] List of unexpires roles */
+    protected $unexpired = [];
 
     /**
      * Constructor for the expiry_info class.
      *
-     * @param   bool    $isexpired Whether the retention period for this context has expired yet.
+     * @param   bool    $default Whether the default expiry period for this context has been reached.
+     * @param   int[]   $expired A list of roles in this context which have explicitly expired.
+     * @param   int[]   $unexpired A list of roles in this context which have not yet expired.
      */
-    public function __construct(bool $isexpired) {
-        $this->isexpired = $isexpired;
+    public function __construct(bool $default, array $expired, array $unexpired) {
+        $this->defaultexpiryreached = $default;
+        $this->expired = $expired;
+        $this->unexpired = $unexpired;
     }
 
     /**
@@ -54,7 +67,7 @@ class expiry_info {
      * @return  bool
      */
     public function is_fully_expired() : bool {
-        return $this->isexpired;
+        return $this->defaultexpiryreached && empty($this->unexpired);
     }
 
     /**
@@ -67,9 +80,59 @@ class expiry_info {
             return true;
         }
 
+        if (!empty($this->get_expired_roles())) {
+            return true;
+        }
+
+        if ($this->is_default_expired()) {
+            return true;
+        }
+
         return false;
     }
 
+    /**
+     * Get the list of explicitly expired role IDs.
+     * Note: This does not list roles which have been expired via the default retention policy being reached.
+     *
+     * @return  int[]
+     */
+    public function get_expired_roles() : array {
+        if ($this->is_default_expired()) {
+            return [];
+        }
+        return $this->expired;
+    }
+
+    /**
+     * Check whether the specified role is explicitly expired.
+     * Note: This does not list roles which have been expired via the default retention policy being reached.
+     *
+     * @param   int $roleid
+     * @return  bool
+     */
+    public function is_role_expired(int $roleid) : bool {
+        return false !== array_search($roleid, $this->expired);
+    }
+
+    /**
+     * Whether the default retention policy has been reached.
+     *
+     * @return  bool
+     */
+    public function is_default_expired() : bool {
+        return $this->defaultexpiryreached;
+    }
+
+    /**
+     * Get the list of unexpired role IDs.
+     *
+     * @return  int[]
+     */
+    public function get_unexpired_roles() : array {
+        return $this->unexpired;
+    }
+
     /**
      * Merge this expiry_info object with another belonging to a child context in order to set the 'safest' heritage.
      *
@@ -86,7 +149,20 @@ class expiry_info {
         }
 
         // If the child is not fully expired, then none of the parents can be either.
-        $this->isexpired = false;
+        $this->fullyexpired = false;
+
+        // Remove any role in this node which is not expired in the child.
+        foreach ($this->expired as $key => $roleid) {
+            if (!$child->is_role_expired($roleid)) {
+                unset($this->expired[$key]);
+            }
+        }
+
+        array_merge($this->unexpired, $child->get_unexpired_roles());
+
+        if (!$child->is_default_expired()) {
+            $this->defaultexpiryreached = false;
+        }
 
         return $this;
     }
diff --git a/admin/tool/dataprivacy/classes/filtered_userlist.php b/admin/tool/dataprivacy/classes/filtered_userlist.php
new file mode 100644 (file)
index 0000000..523790c
--- /dev/null
@@ -0,0 +1,63 @@
+<?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/>.
+
+/**
+ * An implementation of a userlist which has been filtered and approved.
+ *
+ * @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;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * An implementation of a userlist which can be filtered by role.
+ *
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class filtered_userlist extends \core_privacy\local\request\approved_userlist {
+
+    /**
+     * Apply filters to only remove users in the expireduserids list, and to remove any who are in the unexpired list.
+     * The unexpired list wins where a user is in both lists.
+     *
+     * @param   int[]   $expireduserids The list of userids for users who should be expired.
+     * @param   int[]   $unexpireduserids The list of userids for those users who should not be expired.
+     * @return  $this
+     */
+    public function apply_expired_context_filters(array $expireduserids, array $unexpireduserids) : filtered_userlist {
+        // The current userlist content.
+        $userids = $this->get_userids();
+
+        if (!empty($expireduserids)) {
+            // Now remove any not on the list of expired users.
+            $userids = array_intersect($userids, $expireduserids);
+        }
+
+        if (!empty($unexpireduserids)) {
+            // Remove any on the list of unexpiredusers users.
+            $userids = array_diff($userids, $unexpireduserids);
+        }
+
+        $this->set_userids($userids);
+
+        return $this;
+    }
+}
index ca4fb17..0b97f07 100644 (file)
@@ -162,7 +162,6 @@ class purpose extends \core\persistent {
      * @return null
      */
     public function is_used() {
-
         if (\tool_dataprivacy\contextlevel::is_purpose_used($this->get('id')) ||
                 \tool_dataprivacy\context_instance::is_purpose_used($this->get('id'))) {
             return true;
@@ -180,4 +179,13 @@ class purpose extends \core\persistent {
 
         return false;
     }
+
+    /**
+     * Get a list of the role purpose overrides for this purpose.
+     *
+     * @return  array
+     */
+    public function get_purpose_overrides() : array {
+        return purpose_override::get_overrides_for_purpose($this);
+    }
 }
diff --git a/admin/tool/dataprivacy/classes/purpose_override.php b/admin/tool/dataprivacy/classes/purpose_override.php
new file mode 100644 (file)
index 0000000..b3ee418
--- /dev/null
@@ -0,0 +1,99 @@
+<?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/>.
+
+/**
+ * Class for loading/storing data purpose overrides from the DB.
+ *
+ * @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 stdClass;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot . '/' . $CFG->admin . '/tool/dataprivacy/lib.php');
+
+/**
+ * Class for loading/storing data purpose overrides from the DB.
+ *
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class purpose_override extends \core\persistent {
+
+    /**
+     * Database table.
+     */
+    const TABLE = 'tool_dataprivacy_purposerole';
+
+    /**
+     * Return the definition of the properties of this model.
+     *
+     * @return  array
+     */
+    protected static function define_properties() {
+        return array(
+            'purposeid' => array(
+                'type' => PARAM_INT,
+                'description' => 'The purpose that that this override relates to',
+            ),
+            'roleid' => array(
+                'type' => PARAM_INT,
+                'description' => 'The role that that this override relates to',
+            ),
+            'lawfulbases' => array(
+                'type' => PARAM_TEXT,
+                'description' => 'Comma-separated IDs matching records in tool_dataprivacy_lawfulbasis.',
+                'null' => NULL_ALLOWED,
+                'default' => null,
+            ),
+            'sensitivedatareasons' => array(
+                'type' => PARAM_TEXT,
+                'description' => 'Comma-separated IDs matching records in tool_dataprivacy_sensitive',
+                'null' => NULL_ALLOWED,
+                'default' => null,
+            ),
+            'retentionperiod' => array(
+                'type' => PARAM_ALPHANUM,
+                'description' => 'Retention period. ISO_8601 durations format (as in DateInterval format).',
+                'default' => '',
+            ),
+            'protected' => array(
+                'type' => PARAM_INT,
+                'description' => 'Data retention with higher precedent over user\'s request to be forgotten.',
+                'default' => '0',
+            ),
+        );
+    }
+
+    /**
+     * Get all role overrides for the purpose.
+     *
+     * @param   purpose $purpose
+     * @return  array
+     */
+    public static function get_overrides_for_purpose(purpose $purpose) : array {
+        $overrides = [];
+        foreach (self::get_records(['purposeid' => $purpose->get('id')]) as $override) {
+            $overrides[$override->get('roleid')] = $override;
+        }
+
+        return $overrides;
+    }
+}
index 98e852b..e340269 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="admin/tool/dataprivacy/db" VERSION="20180821" COMMENT="XMLDB file for Moodle tool/dataprivacy"
+<XMLDB PATH="admin/tool/dataprivacy/db" VERSION="20180904" COMMENT="XMLDB file for Moodle tool/dataprivacy"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../../lib/xmldb/xmldb.xsd"
 >
@@ -98,6 +98,9 @@
       <FIELDS>
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
         <FIELD NAME="contextid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="unexpiredroles" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Roles which have explicitly not expired yet."/>
+        <FIELD NAME="expiredroles" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Explicitly expires roles"/>
+        <FIELD NAME="defaultexpired" TYPE="int" LENGTH="1" NOTNULL="true" SEQUENCE="false" COMMENT="The default retention period has passed."/>
         <FIELD NAME="status" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="usermodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <KEY NAME="request_contextlist" TYPE="unique" FIELDS="requestid, contextlistid" COMMENT="Uniqueness constraint on request and contextlist"/>
       </KEYS>
     </TABLE>
+    <TABLE NAME="tool_dataprivacy_purposerole" COMMENT="Data purpose overrides for a specific role">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="purposeid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="roleid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="lawfulbases" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="sensitivedatareasons" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="retentionperiod" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="protected" TYPE="int" LENGTH="1" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="usermodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="purposepurposeid" TYPE="foreign" FIELDS="purposeid" REFTABLE="tool_dataprivacy_purpose" REFFIELDS="id"/>
+        <KEY NAME="puproseroleid" TYPE="foreign" FIELDS="roleid" REFTABLE="role" REFFIELDS="id"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="purposerole" UNIQUE="true" FIELDS="purposeid, roleid"/>
+      </INDEXES>
+    </TABLE>
   </TABLES>
 </XMLDB>
\ No newline at end of file
index 743185d..17d38b0 100644 (file)
@@ -184,5 +184,69 @@ function xmldb_tool_dataprivacy_upgrade($oldversion) {
         upgrade_plugin_savepoint(true, 2018082100, 'tool', 'dataprivacy');
     }
 
+    if ($oldversion < 2018100401) {
+        // Define table tool_dataprivacy_purposerole to be created.
+        $table = new xmldb_table('tool_dataprivacy_purposerole');
+
+        // Adding fields to table tool_dataprivacy_purposerole.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('purposeid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('roleid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('lawfulbases', XMLDB_TYPE_TEXT, null, null, null, null, null);
+        $table->add_field('sensitivedatareasons', XMLDB_TYPE_TEXT, null, null, null, null, null);
+        $table->add_field('retentionperiod', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('protected', XMLDB_TYPE_INTEGER, '1', null, null, null, null);
+        $table->add_field('usermodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+
+        // Adding keys to table tool_dataprivacy_purposerole.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+        $table->add_key('purposepurposeid', XMLDB_KEY_FOREIGN, ['purposeid'], 'tool_dataprivacy_purpose', ['id']);
+        $table->add_key('puproseroleid', XMLDB_KEY_FOREIGN, ['roleid'], 'role', ['id']);
+
+        // Adding indexes to table tool_dataprivacy_purposerole.
+        $table->add_index('purposerole', XMLDB_INDEX_UNIQUE, ['purposeid', 'roleid']);
+
+        // Conditionally launch create table for tool_dataprivacy_purposerole.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Update the ctxexpired table.
+        $table = new xmldb_table('tool_dataprivacy_ctxexpired');
+
+        // Add the unexpiredroles field.
+        $field = new xmldb_field('unexpiredroles', XMLDB_TYPE_TEXT, null, null, null, null, null, 'contextid');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+        $DB->set_field('tool_dataprivacy_ctxexpired', 'unexpiredroles', '');
+
+        // Add the expiredroles field.
+        $field = new xmldb_field('expiredroles', XMLDB_TYPE_TEXT, null, null, null, null, null, 'unexpiredroles');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+        $DB->set_field('tool_dataprivacy_ctxexpired', 'expiredroles', '');
+
+        // Add the defaultexpired field.
+        $field = new xmldb_field('defaultexpired', XMLDB_TYPE_INTEGER, '1', null, null, null, '1', 'expiredroles');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Change the default for the expired field to be empty.
+        $field = new xmldb_field('defaultexpired', XMLDB_TYPE_INTEGER, '1', null, null, null, null, 'expiredroles');
+        $dbman->change_field_default($table, $field);
+
+        // Prevent hte field from being nullable.
+        $field = new xmldb_field('defaultexpired', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, null, 'expiredroles');
+        $dbman->change_field_notnull($table, $field);
+
+        // Dataprivacy savepoint reached.
+        upgrade_plugin_savepoint(true, 2018100401, 'tool', 'dataprivacy');
+    }
+
     return true;
 }
index 37cb86b..f7e2586 100644 (file)
@@ -26,6 +26,7 @@ use tool_dataprivacy\api;
 use tool_dataprivacy\data_registry;
 use tool_dataprivacy\expired_context;
 use tool_dataprivacy\purpose;
+use tool_dataprivacy\purpose_override;
 use tool_dataprivacy\category;
 use tool_dataprivacy\contextlevel;
 
@@ -217,6 +218,38 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
         $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_update_existing() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'P5Y');
+
+        $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,
+                'defaultexpired' => 0,
+                'status' => expired_context::STATUS_EXPIRED,
+            ]);
+        $expiredcontext->save();
+        $this->assertEquals(0, $expiredcontext->get('defaultexpired'));
+
+        // 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);
+
+        // The user context will now have expired.
+        $updatedcontext = new expired_context($expiredcontext->get('id'));
+        $this->assertEquals(1, $updatedcontext->get('defaultexpired'));
+    }
+
     /**
      * Ensure that a user with a lastaccess in the past and expired enrolments.
      */
@@ -245,6 +278,41 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
         $this->assertEquals(0, $flaggedusers);
     }
 
+    /**
+     * Ensure that a user with a lastaccess in the past and expired enrolments.
+     */
+    public function test_flag_user_past_override_role() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+        $userpurpose = $purposes[1];
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $usercontext = \context_user::instance($user->id);
+        $systemcontext = \context_system::instance();
+
+        $role = $DB->get_record('role', ['shortname' => 'manager']);
+
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $userpurpose->get('id'),
+                'roleid' => $role->id,
+                'retentionperiod' => 'P5Y',
+            ]);
+        $override->save();
+        role_assign($role->id, $user->id, $systemcontext->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);
+
+        $expiredrecord = expired_context::get_record(['contextid' => $usercontext->id]);
+        $this->assertFalse($expiredrecord);
+    }
+
     /**
      * Ensure that a user with a lastaccess in the past and expired enrolments.
      */
@@ -604,6 +672,580 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
         $this->assertEquals(0, $flaggedusers);
     }
 
+    /**
+     * Ensure that a course with an end date in the distant past is flagged, taking into account any purpose override
+     */
+    public function test_flag_course_past_enddate_with_override_unexpired_role() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+        $coursepurpose = $purposes[2];
+
+        $role = $DB->get_record('role', ['shortname' => 'editingteacher']);
+
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $coursepurpose->get('id'),
+                'roleid' => $role->id,
+                'retentionperiod' => 'P5Y',
+            ]);
+        $override->save();
+
+        $course = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * DAYSECS),
+                'enddate' => time() - DAYSECS,
+            ]);
+        $coursecontext = \context_course::instance($course->id);
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(1, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+
+        $expiredrecord = expired_context::get_record(['contextid' => $coursecontext->id]);
+        $this->assertEmpty($expiredrecord->get('expiredroles'));
+
+        $unexpiredroles = $expiredrecord->get('unexpiredroles');
+        $this->assertCount(1, $unexpiredroles);
+        $this->assertContains($role->id, $unexpiredroles);
+    }
+
+    /**
+     * Ensure that a course with an end date in the distant past is flagged, and any expired role is ignored.
+     */
+    public function test_flag_course_past_enddate_with_override_expired_role() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+        $coursepurpose = $purposes[2];
+
+        $role = $DB->get_record('role', ['shortname' => 'student']);
+
+        // The role has a much shorter retention, but both should match.
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $coursepurpose->get('id'),
+                'roleid' => $role->id,
+                'retentionperiod' => 'PT1M',
+            ]);
+        $override->save();
+
+        $course = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * DAYSECS),
+                'enddate' => time() - DAYSECS,
+            ]);
+        $coursecontext = \context_course::instance($course->id);
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(1, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+
+        $expiredrecord = expired_context::get_record(['contextid' => $coursecontext->id]);
+        $this->assertEmpty($expiredrecord->get('expiredroles'));
+        $this->assertEmpty($expiredrecord->get('unexpiredroles'));
+        $this->assertTrue((bool) $expiredrecord->get('defaultexpired'));
+    }
+
+    /**
+     * Ensure that where a course has explicitly expired one role, but that role is explicitly not expired in a child
+     * context, does not have the parent context role expired.
+     */
+    public function test_flag_course_override_expiredwith_override_unexpired_on_child() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('P1Y', 'P1Y', 'P1Y');
+        $coursepurpose = $purposes[2];
+
+        $role = $DB->get_record('role', ['shortname' => 'editingteacher']);
+
+        (new purpose_override(0, (object) [
+                'purposeid' => $coursepurpose->get('id'),
+                'roleid' => $role->id,
+                'retentionperiod' => 'PT1S',
+            ]))->save();
+
+        $modpurpose = new purpose(0, (object) [
+            'name' => 'Module purpose',
+            'retentionperiod' => 'PT1S',
+            'lawfulbases' => 'gdpr_art_6_1_a',
+        ]);
+        $modpurpose->create();
+
+        (new purpose_override(0, (object) [
+                'purposeid' => $modpurpose->get('id'),
+                'roleid' => $role->id,
+                'retentionperiod' => 'P5Y',
+            ]))->save();
+
+        $course = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * DAYSECS),
+                'enddate' => time() - DAYSECS,
+            ]);
+        $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);
+
+        api::set_context_instance((object) [
+                'contextid' => $forumcontext->id,
+                'purposeid' => $modpurpose->get('id'),
+                'categoryid' => 0,
+            ]);
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(1, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+
+        // The course will not be expired as the default expiry has not passed, and the explicit role override has been
+        // removed due to the child non-expiry.
+        $expiredrecord = expired_context::get_record(['contextid' => $coursecontext->id]);
+        $this->assertFalse($expiredrecord);
+
+        // The forum will have an expiry for all _but_ the overridden role.
+        $expiredrecord = expired_context::get_record(['contextid' => $forumcontext->id]);
+        $this->assertEmpty($expiredrecord->get('expiredroles'));
+
+        // The teacher is not expired.
+        $unexpiredroles = $expiredrecord->get('unexpiredroles');
+        $this->assertCount(1, $unexpiredroles);
+        $this->assertContains($role->id, $unexpiredroles);
+        $this->assertTrue((bool) $expiredrecord->get('defaultexpired'));
+    }
+
+    /**
+     * Ensure that a user context previously flagged as approved is not removed if the user has any unexpired roles.
+     */
+    public function test_process_user_context_with_override_unexpired_role() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+        $userpurpose = $purposes[1];
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $usercontext = \context_user::instance($user->id);
+        $systemcontext = \context_system::instance();
+
+        $role = $DB->get_record('role', ['shortname' => 'manager']);
+
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $userpurpose->get('id'),
+                'roleid' => $role->id,
+                'retentionperiod' => 'P5Y',
+            ]);
+        $override->save();
+        role_assign($role->id, $user->id, $systemcontext->id);
+
+        // Create an existing expired_context.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => $usercontext->id,
+                'defaultexpired' => 1,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredcontext->add_unexpiredroles([$role->id]);
+        $expiredcontext->save();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_users_in_context',
+                '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');
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_users_in_context');
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        $manager->set_progress(new \null_progress_trace());
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
+
+        $this->assertEquals(0, $processedcourses);
+        $this->assertEquals(0, $processedusers);
+
+        $this->expectException('dml_missing_record_exception');
+        $updatedcontext = new expired_context($expiredcontext->get('id'));
+    }
+
+    /**
+     * Ensure that a module context previously flagged as approved is removed with appropriate unexpiredroles kept.
+     */
+    public function test_process_course_context_with_override_unexpired_role() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+        $coursepurpose = $purposes[2];
+
+        $role = $DB->get_record('role', ['shortname' => 'editingteacher']);
+
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $coursepurpose->get('id'),
+                'roleid' => $role->id,
+                'retentionperiod' => 'P5Y',
+            ]);
+        $override->save();
+
+        $course = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * YEARSECS),
+                'enddate' => time() - YEARSECS,
+            ]);
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+        $forumcontext = \context_module::instance($cm->id);
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
+
+        $student = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($student->id, $course->id, 'student');
+        $generator->create_discussion((object) [
+            'course' => $forum->course,
+            'forum' => $forum->id,
+            'userid' => $student->id,
+        ]);
+
+        $teacher = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($teacher->id, $course->id, 'editingteacher');
+        $generator->create_discussion((object) [
+            'course' => $forum->course,
+            'forum' => $forum->id,
+            'userid' => $teacher->id,
+        ]);
+
+        // Create an existing expired_context.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => $forumcontext->id,
+                'defaultexpired' => 1,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredcontext->add_unexpiredroles([$role->id]);
+        $expiredcontext->save();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_users_in_context',
+                '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');
+        $mockprivacymanager
+            ->expects($this->once())
+            ->method('delete_data_for_users_in_context')
+            ->with($this->callback(function($userlist) use ($student, $teacher) {
+                $forumlist = $userlist->get_userlist_for_component('mod_forum');
+                $userids = $forumlist->get_userids();
+                $this->assertCount(1, $userids);
+                $this->assertContains($student->id, $userids);
+                $this->assertNotContains($teacher->id, $userids);
+                return true;
+            }));
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        $manager->set_progress(new \null_progress_trace());
+        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 module context previously flagged as approved is removed with appropriate expiredroles kept.
+     */
+    public function test_process_course_context_with_override_expired_role() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'P5Y');
+        $coursepurpose = $purposes[2];
+
+        $role = $DB->get_record('role', ['shortname' => 'student']);
+
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $coursepurpose->get('id'),
+                'roleid' => $role->id,
+                'retentionperiod' => 'PT1M',
+            ]);
+        $override->save();
+
+        $course = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * YEARSECS),
+                'enddate' => time() - YEARSECS,
+            ]);
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+        $forumcontext = \context_module::instance($cm->id);
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
+
+        $student = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($student->id, $course->id, 'student');
+        $generator->create_discussion((object) [
+            'course' => $forum->course,
+            'forum' => $forum->id,
+            'userid' => $student->id,
+        ]);
+
+        $teacher = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($teacher->id, $course->id, 'editingteacher');
+        $generator->create_discussion((object) [
+            'course' => $forum->course,
+            'forum' => $forum->id,
+            'userid' => $teacher->id,
+        ]);
+
+        // Create an existing expired_context.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => $forumcontext->id,
+                'defaultexpired' => 0,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredcontext->add_expiredroles([$role->id]);
+        $expiredcontext->save();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_users_in_context',
+                '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');
+        $mockprivacymanager
+            ->expects($this->once())
+            ->method('delete_data_for_users_in_context')
+            ->with($this->callback(function($userlist) use ($student, $teacher) {
+                $forumlist = $userlist->get_userlist_for_component('mod_forum');
+                $userids = $forumlist->get_userids();
+                $this->assertCount(1, $userids);
+                $this->assertContains($student->id, $userids);
+                $this->assertNotContains($teacher->id, $userids);
+                return true;
+            }));
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        $manager->set_progress(new \null_progress_trace());
+        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 module context previously flagged as approved is removed with appropriate expiredroles kept.
+     */
+    public function test_process_course_context_with_user_in_both_lists() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'P5Y');
+        $coursepurpose = $purposes[2];
+
+        $role = $DB->get_record('role', ['shortname' => 'student']);
+
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $coursepurpose->get('id'),
+                'roleid' => $role->id,
+                'retentionperiod' => 'PT1M',
+            ]);
+        $override->save();
+
+        $course = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * YEARSECS),
+                'enddate' => time() - YEARSECS,
+            ]);
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+        $forumcontext = \context_module::instance($cm->id);
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
+
+        $teacher = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($teacher->id, $course->id, 'editingteacher');
+        $this->getDataGenerator()->enrol_user($teacher->id, $course->id, 'student');
+        $generator->create_discussion((object) [
+            'course' => $forum->course,
+            'forum' => $forum->id,
+            'userid' => $teacher->id,
+        ]);
+
+        $student = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($student->id, $course->id, 'student');
+        $generator->create_discussion((object) [
+            'course' => $forum->course,
+            'forum' => $forum->id,
+            'userid' => $student->id,
+        ]);
+
+        // Create an existing expired_context.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => $forumcontext->id,
+                'defaultexpired' => 0,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredcontext->add_expiredroles([$role->id]);
+        $expiredcontext->save();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_users_in_context',
+                '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');
+        $mockprivacymanager
+            ->expects($this->once())
+            ->method('delete_data_for_users_in_context')
+            ->with($this->callback(function($userlist) use ($student, $teacher) {
+                $forumlist = $userlist->get_userlist_for_component('mod_forum');
+                $userids = $forumlist->get_userids();
+                $this->assertCount(1, $userids);
+                $this->assertContains($student->id, $userids);
+                $this->assertNotContains($teacher->id, $userids);
+                return true;
+            }));
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        $manager->set_progress(new \null_progress_trace());
+        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 module context previously flagged as approved is removed with appropriate expiredroles kept.
+     */
+    public function test_process_course_context_with_user_in_both_lists_expired() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'P5Y');
+        $coursepurpose = $purposes[2];
+
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $coursepurpose->get('id'),
+                'roleid' => $studentrole->id,
+                'retentionperiod' => 'PT1M',
+            ]);
+        $override->save();
+
+        $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']);
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $coursepurpose->get('id'),
+                'roleid' => $teacherrole->id,
+                'retentionperiod' => 'PT1M',
+            ]);
+        $override->save();
+
+        $course = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * YEARSECS),
+                'enddate' => time() - YEARSECS,
+            ]);
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+        $forumcontext = \context_module::instance($cm->id);
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
+
+        $teacher = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($teacher->id, $course->id, 'editingteacher');
+        $this->getDataGenerator()->enrol_user($teacher->id, $course->id, 'student');
+        $generator->create_discussion((object) [
+            'course' => $forum->course,
+            'forum' => $forum->id,
+            'userid' => $teacher->id,
+        ]);
+
+        $student = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($student->id, $course->id, 'student');
+        $generator->create_discussion((object) [
+            'course' => $forum->course,
+            'forum' => $forum->id,
+            'userid' => $student->id,
+        ]);
+
+        // Create an existing expired_context.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => $forumcontext->id,
+                'defaultexpired' => 0,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredcontext->add_expiredroles([$studentrole->id, $teacherrole->id]);
+        $expiredcontext->save();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_users_in_context',
+                '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');
+        $mockprivacymanager
+            ->expects($this->once())
+            ->method('delete_data_for_users_in_context')
+            ->with($this->callback(function($userlist) use ($student, $teacher) {
+                $forumlist = $userlist->get_userlist_for_component('mod_forum');
+                $userids = $forumlist->get_userids();
+                $this->assertCount(2, $userids);
+                $this->assertContains($student->id, $userids);
+                $this->assertContains($teacher->id, $userids);
+                return true;
+            }));
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        $manager->set_progress(new \null_progress_trace());
+        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 site not setup will not process anything.
      */
@@ -1354,6 +1996,52 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
         ];
     }
 
+    /**
+     * Test that the is_fully_expired function returns expected results.
+     *
+     * @dataProvider        is_fully_expired_provider
+     * @param       array   $record
+     * @param       bool    $expected
+     */
+    public function test_is_fully_expired($record, $expected) {
+        $purpose = new expired_context(0, (object) $record);
+
+        $this->assertEquals($expected, $purpose->is_fully_expired());
+    }
+
+    /**
+     * Data provider for the is_fully_expired tests.
+     *
+     * @return  array
+     */
+    public function is_fully_expired_provider() : array {
+        return [
+            'Fully expired' => [
+                [
+                    'status' => expired_context::STATUS_APPROVED,
+                    'defaultexpired' => 1,
+                ],
+                true,
+            ],
+            'Unexpired roles present' => [
+                [
+                    'status' => expired_context::STATUS_APPROVED,
+                    'defaultexpired' => 1,
+                    'unexpiredroles' => json_encode([1]),
+                ],
+                false,
+            ],
+            'Only some expired roles present' => [
+                [
+                    'status' => expired_context::STATUS_APPROVED,
+                    'defaultexpired' => 0,
+                    'expiredroles' => json_encode([1]),
+                ],
+                false,
+            ],
+        ];
+    }
+
     /**
      * Ensure that any orphaned records are removed once the context has been removed.
      */
index ad971e5..bb98241 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die;
 
-$plugin->version   = 2018092500;
+$plugin->version   = 2018100401;
 $plugin->requires  = 2018050800;        // Moodle 3.5dev (Build 2018031600) and upwards.
 $plugin->component = 'tool_dataprivacy';