Merge branch 'install_master' of https://git.in.moodle.com/amosbot/moodle-install
authorDavid Monllao <davidm@moodle.com>
Tue, 23 Oct 2018 14:42:12 +0000 (16:42 +0200)
committerDavid Monllao <davidm@moodle.com>
Tue, 23 Oct 2018 14:42:12 +0000 (16:42 +0200)
238 files changed:
admin/cli/upgrade.php
admin/tool/dataprivacy/classes/api.php
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/external/purpose_exporter.php
admin/tool/dataprivacy/classes/filtered_userlist.php [new file with mode: 0644]
admin/tool/dataprivacy/classes/form/purpose.php
admin/tool/dataprivacy/classes/output/expired_contexts_table.php
admin/tool/dataprivacy/classes/privacy/provider.php
admin/tool/dataprivacy/classes/purpose.php
admin/tool/dataprivacy/classes/purpose_override.php [new file with mode: 0644]
admin/tool/dataprivacy/classes/task/delete_expired_contexts.php
admin/tool/dataprivacy/classes/task/expired_retention_period.php
admin/tool/dataprivacy/db/caches.php
admin/tool/dataprivacy/db/install.xml
admin/tool/dataprivacy/db/upgrade.php
admin/tool/dataprivacy/editpurpose.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/dataprivacy/templates/purposes.mustache
admin/tool/dataprivacy/tests/api_test.php
admin/tool/dataprivacy/tests/behat/manage_purposes.feature
admin/tool/dataprivacy/tests/expired_contexts_test.php
admin/tool/dataprivacy/tests/filtered_userlist_test.php [new file with mode: 0644]
admin/tool/dataprivacy/version.php
admin/tool/mobile/classes/privacy/provider.php
admin/tool/mobile/tests/privacy_provider_test.php
admin/tool/monitor/classes/privacy/provider.php
admin/tool/monitor/tests/privacy_test.php
admin/tool/policy/accept.php
admin/tool/policy/amd/build/acceptmodal.min.js
admin/tool/policy/amd/src/acceptmodal.js
admin/tool/policy/classes/acceptances_table.php
admin/tool/policy/classes/api.php
admin/tool/policy/classes/form/accept_policy.php
admin/tool/policy/classes/form/policydoc.php
admin/tool/policy/classes/output/acceptances.php
admin/tool/policy/classes/output/acceptances_filter.php
admin/tool/policy/classes/output/page_agreedocs.php
admin/tool/policy/classes/output/page_managedocs_list.php
admin/tool/policy/classes/output/page_nopermission.php
admin/tool/policy/classes/output/page_viewdoc.php
admin/tool/policy/classes/output/user_agreement.php
admin/tool/policy/classes/policy_version.php
admin/tool/policy/classes/privacy/local/sitepolicy/handler.php
admin/tool/policy/classes/privacy/provider.php
admin/tool/policy/classes/test/helper.php [new file with mode: 0644]
admin/tool/policy/db/caches.php [new file with mode: 0644]
admin/tool/policy/db/install.xml
admin/tool/policy/db/upgrade.php
admin/tool/policy/index.php
admin/tool/policy/lang/en/tool_policy.php
admin/tool/policy/lib.php
admin/tool/policy/pix/agreed.png [moved from admin/tool/policy/pix/agreedyes.png with 100% similarity]
admin/tool/policy/pix/agreed.svg [moved from admin/tool/policy/pix/agreedyes.svg with 100% similarity]
admin/tool/policy/pix/agreedyesonbehalf.png [deleted file]
admin/tool/policy/pix/agreedyesonbehalf.svg [deleted file]
admin/tool/policy/pix/declined.png [moved from admin/tool/policy/pix/agreedno.png with 100% similarity]
admin/tool/policy/pix/declined.svg [moved from admin/tool/policy/pix/agreedno.svg with 100% similarity]
admin/tool/policy/pix/partial.png [new file with mode: 0644]
admin/tool/policy/pix/partial.svg [new file with mode: 0644]
admin/tool/policy/pix/pending.png [new file with mode: 0644]
admin/tool/policy/pix/pending.svg [new file with mode: 0644]
admin/tool/policy/templates/acceptances.mustache
admin/tool/policy/templates/page_agreedocs.mustache
admin/tool/policy/templates/page_managedocs_list.mustache
admin/tool/policy/templates/page_viewdoc.mustache
admin/tool/policy/templates/user_agreement.mustache
admin/tool/policy/tests/api_test.php
admin/tool/policy/tests/behat/acceptances.feature
admin/tool/policy/tests/behat/behat_tool_policy.php
admin/tool/policy/tests/behat/consent.feature
admin/tool/policy/tests/behat/managepolicies.feature
admin/tool/policy/tests/behat/optional.feature [new file with mode: 0644]
admin/tool/policy/tests/privacy_provider_test.php
admin/tool/policy/tests/sitepolicy_handler_test.php [new file with mode: 0644]
admin/tool/policy/version.php
auth/mnet/classes/privacy/provider.php
auth/mnet/tests/privacy_provider_test.php
auth/oauth2/classes/privacy/provider.php
auth/oauth2/tests/privacy_provider_test.php
blocks/comments/classes/privacy/provider.php
blocks/comments/tests/privacy_provider_test.php
blocks/community/classes/privacy/provider.php
blocks/community/tests/privacy_test.php
blocks/html/classes/privacy/provider.php
blocks/html/tests/privacy_provider_test.php
blocks/recent_activity/classes/privacy/provider.php
blocks/rss_client/classes/privacy/provider.php
blocks/rss_client/tests/privacy_test.php
blocks/tag_flickr/classes/privacy/provider.php
cache/stores/memcached/classes/privacy/provider.php
cache/stores/mongodb/classes/privacy/provider.php
cache/stores/redis/classes/privacy/provider.php
cache/stores/session/classes/privacy/provider.php
cohort/classes/privacy/provider.php
cohort/tests/privacy_test.php
comment/classes/privacy/provider.php
comment/tests/privacy_test.php
completion/classes/privacy/provider.php
completion/tests/privacy_test.php
course/classes/privacy/provider.php
course/tests/privacy_test.php
enrol/classes/privacy/provider.php
enrol/cohort/classes/privacy/provider.php
enrol/cohort/tests/privacy_test.php
enrol/flatfile/classes/privacy/provider.php
enrol/flatfile/tests/privacy_provider_test.php
enrol/lti/classes/privacy/provider.php
enrol/lti/tests/privacy_provider_test.php
enrol/meta/classes/privacy/provider.php
enrol/meta/tests/privacy_test.php
enrol/tests/privacy_test.php
files/classes/privacy/provider.php
files/converter/classes/privacy/provider.php
files/tests/privacy_test.php [new file with mode: 0644]
group/classes/privacy/provider.php
group/tests/privacy_provider_test.php
lang/en/admin.php
lang/en/moodle.php
lang/en/privacy.php
lang/en/role.php
lib/adminlib.php
lib/amd/build/sortable_list.min.js
lib/amd/src/sortable_list.js
lib/classes/privacy/provider.php
lib/db/access.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/installlib.php
lib/messagelib.php
lib/outputcomponents.php
lib/outputrenderers.php
lib/tests/user_menu_test.php
lib/upgrade.txt
lib/upgradelib.php
lib/userkey/classes/privacy/provider.php
message/classes/api.php
message/classes/task/migrate_message_data.php
message/externallib.php
message/output/airnotifier/classes/privacy/provider.php
message/output/airnotifier/tests/privacy_test.php
message/output/email/classes/privacy/provider.php
message/output/jabber/classes/privacy/provider.php
message/tests/api_test.php
message/tests/externallib_test.php
message/tests/messagelib_test.php
message/tests/privacy_provider_test.php
message/upgrade.txt
mod/assign/feedback/editpdf/classes/document_services.php
mod/assign/submission/onlinetext/locallib.php
mod/assignment/classes/privacy/provider.php
mod/assignment/tests/privacy_test.php
mod/chat/classes/privacy/provider.php
mod/chat/tests/privacy_test.php
mod/data/classes/privacy/provider.php
mod/data/tests/privacy_provider_test.php
mod/feedback/classes/privacy/provider.php
mod/feedback/tests/privacy_test.php
mod/forum/classes/privacy/provider.php
mod/forum/lang/en/forum.php
mod/forum/lib.php
mod/forum/post.php
mod/forum/styles.css
mod/forum/tests/privacy_provider_test.php
mod/glossary/classes/privacy/provider.php
mod/glossary/tests/privacy_provider_test.php
mod/lesson/classes/privacy/provider.php
mod/lesson/tests/privacy_test.php
mod/lti/classes/privacy/provider.php
mod/lti/tests/privacy_provider_test.php
mod/scorm/classes/privacy/provider.php
mod/scorm/tests/privacy_test.php
mod/survey/classes/privacy/provider.php
mod/survey/tests/privacy_test.php
mod/wiki/classes/privacy/provider.php
mod/wiki/tests/privacy_test.php
plagiarism/classes/privacy/plagiarims_user_provider.php [new file with mode: 0644]
plagiarism/classes/privacy/provider.php
portfolio/classes/privacy/provider.php
portfolio/tests/privacy_provider_test.php
privacy/classes/local/request/approved_userlist.php [new file with mode: 0644]
privacy/classes/local/request/core_userlist_provider.php [new file with mode: 0644]
privacy/classes/local/request/helper.php
privacy/classes/local/request/userlist.php [new file with mode: 0644]
privacy/classes/local/request/userlist_base.php [new file with mode: 0644]
privacy/classes/local/request/userlist_collection.php [new file with mode: 0644]
privacy/classes/manager.php
privacy/tests/approved_userlist_test.php [new file with mode: 0644]
privacy/tests/userlist_base_test.php [new file with mode: 0644]
privacy/tests/userlist_collection.php [new file with mode: 0644]
privacy/tests/userlist_test.php [new file with mode: 0644]
rating/classes/privacy/provider.php
repository/boxnet/classes/privacy/provider.php
repository/classes/privacy/provider.php
repository/dropbox/classes/privacy/provider.php
repository/flickr/classes/privacy/provider.php
repository/flickr_public/classes/privacy/provider.php
repository/googledocs/classes/privacy/provider.php
repository/merlot/classes/privacy/provider.php
repository/onedrive/classes/privacy/provider.php
repository/onedrive/tests/privacy_test.php
repository/picasa/classes/privacy/provider.php
repository/tests/privacy_test.php
repository/wikimedia/classes/privacy/provider.php
repository/youtube/classes/privacy/provider.php
rss/classes/privacy/provider.php
rss/tests/privacy_test.php
theme/boost/amd/build/aria.min.js [new file with mode: 0644]
theme/boost/amd/build/loader.min.js
theme/boost/amd/src/aria.js [new file with mode: 0644]
theme/boost/amd/src/loader.js
theme/boost/templates/core/action_menu.mustache
theme/boost/templates/core/action_menu_trigger.mustache
theme/boost/templates/core/auth_verify_age_location_page.mustache
theme/boost/templates/core/custom_menu_item.mustache
theme/boost/templates/core/loginform.mustache
theme/boost/templates/core/notification_error.mustache
theme/boost/templates/core/notification_info.mustache
theme/boost/templates/core/notification_success.mustache
theme/boost/templates/core/notification_warning.mustache
theme/boost/tests/behat/behat_theme_boost_behat_action_menu.php
user/classes/privacy/provider.php
user/profile/field/checkbox/classes/privacy/provider.php
user/profile/field/checkbox/tests/privacy_test.php
user/profile/field/datetime/classes/privacy/provider.php
user/profile/field/datetime/tests/privacy_test.php
user/profile/field/menu/classes/privacy/provider.php
user/profile/field/menu/tests/privacy_test.php
user/profile/field/text/classes/privacy/provider.php
user/profile/field/text/tests/privacy_test.php
user/profile/field/textarea/classes/privacy/provider.php
user/profile/field/textarea/tests/privacy_test.php
user/tests/privacy_test.php
version.php
webservice/classes/privacy/provider.php
webservice/tests/privacy_test.php

index 5915e24..8e439c5 100644 (file)
@@ -51,7 +51,8 @@ list($options, $unrecognized) = cli_get_params(
         'non-interactive'   => false,
         'allow-unstable'    => false,
         'help'              => false,
-        'lang'              => $lang
+        'lang'              => $lang,
+        'verbose-settings'  => false
     ),
     array(
         'h' => 'help'
@@ -84,6 +85,9 @@ Options:
                       site language if not set. Defaults to 'en' if the lang
                       parameter is invalid or if the language pack is not
                       installed.
+--verbose-settings    Show new settings values. By default only the name of
+                      new core or plugin settings are displayed. This option
+                      outputs the new values as well as the setting name.
 -h, --help            Print out this help
 
 Example:
@@ -184,9 +188,24 @@ upgrade_noncore(true);
 // log in as admin - we need doanything permission when applying defaults
 \core\session\manager::set_user(get_admin());
 
-// apply all default settings, just in case do it twice to fill all defaults
-admin_apply_default_settings(NULL, false);
-admin_apply_default_settings(NULL, false);
+// Apply default settings and output those that have changed.
+cli_heading(get_string('cliupgradedefaultheading', 'admin'));
+$settingsoutput = admin_apply_default_settings(null, false);
+
+foreach ($settingsoutput as $setting => $value) {
+
+    if ($options['verbose-settings']) {
+        $stringvlaues = array(
+                'name' => $setting,
+                'defaultsetting' => var_export($value, true) // Expand objects.
+        );
+        echo get_string('cliupgradedefaultverbose', 'admin', $stringvlaues) . PHP_EOL;
+
+    } else {
+        echo get_string('cliupgradedefault', 'admin', $setting) . PHP_EOL;
+
+    }
+}
 
 // This needs to happen at the end to ensure it occurs after all caches
 // have been purged for the last time.
index f626371..aef98de 100644 (file)
@@ -988,6 +988,7 @@ class api {
      */
     public static function add_request_contexts_with_status(contextlist_collection $clcollection, int $requestid, int $status) {
         $request = new data_request($requestid);
+        $user = \core_user::get_user($request->get('userid'));
         foreach ($clcollection as $contextlist) {
             // Convert the \core_privacy\local\request\contextlist into a contextlist persistent and store it.
             $clp = \tool_dataprivacy\contextlist::from_contextlist($contextlist);
@@ -998,10 +999,14 @@ class api {
             foreach ($contextlist->get_contextids() as $contextid) {
                 if ($request->get('type') == static::DATAREQUEST_TYPE_DELETE) {
                     $context = \context::instance_by_id($contextid);
-                    if (($purpose = static::get_effective_context_purpose($context)) && !empty($purpose->get('protected'))) {
+                    $purpose = static::get_effective_context_purpose($context);
+
+                    // Data can only be deleted from it if the context is either expired, or unprotected.
+                    if (!expired_contexts_manager::is_context_expired_or_unprotected_for_user($context, $user)) {
                         continue;
                     }
                 }
+
                 $context = new contextlist_context();
                 $context->set('contextid', $contextid)
                     ->set('contextlistid', $contextlistid)
@@ -1099,6 +1104,15 @@ class api {
                 $contexts = [];
             }
 
+            if ($request->get('type') == static::DATAREQUEST_TYPE_DELETE) {
+                $context = \context::instance_by_id($record->contextid);
+                $purpose = static::get_effective_context_purpose($context);
+                // Data can only be deleted from it if the context is either expired, or unprotected.
+                if (!expired_contexts_manager::is_context_expired_or_unprotected_for_user($context, $foruser)) {
+                    continue;
+                }
+            }
+
             $contexts[] = $record->contextid;
             $lastcomponent = $record->component;
         }
@@ -1196,4 +1210,25 @@ class api {
 
         return true;
     }
+
+    /**
+     * Format the supplied date interval as a retention period.
+     *
+     * @param   \DateInterval   $interval
+     * @return  string
+     */
+    public static function format_retention_period(\DateInterval $interval) : string {
+        // It is one or another.
+        if ($interval->y) {
+            $formattedtime = get_string('numyears', 'moodle', $interval->format('%y'));
+        } else if ($interval->m) {
+            $formattedtime = get_string('nummonths', 'moodle', $interval->format('%m'));
+        } else if ($interval->d) {
+            $formattedtime = get_string('numdays', 'moodle', $interval->format('%d'));
+        } else {
+            $formattedtime = get_string('retentionperiodzero', 'tool_dataprivacy');
+        }
+
+        return $formattedtime;
+    }
 }
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..c2dc169 100644 (file)
@@ -47,34 +47,62 @@ class expired_contexts_manager {
     /** @var manager The privacy manager */
     protected $manager = null;
 
+    /** @var \progress_trace Trace tool for logging */
+    protected $trace = null;
+
+    /**
+     * Constructor for the expired_contexts_manager.
+     *
+     * @param   \progress_trace $trace
+     */
+    public function __construct(\progress_trace $trace = null) {
+        if (null === $trace) {
+            $trace = new \null_progress_trace();
+        }
+
+        $this->trace = $trace;
+    }
+
     /**
      * Flag expired contexts as expired.
      *
      * @return  int[]   The number of contexts flagged as expired for courses, and users.
      */
     public function flag_expired_contexts() : array {
+        $this->trace->output('Checking requirements');
         if (!$this->check_requirements()) {
+            $this->trace->output('Requirements not met. Cannot process expired retentions.', 1);
             return [0, 0];
         }
 
         // Clear old and stale records first.
+        $this->trace->output('Clearing obselete records.', 0);
         static::clear_old_records();
+        $this->trace->output('Done.', 1);
 
+        $this->trace->output('Calculating potential course expiries.', 0);
         $data = static::get_nested_expiry_info_for_courses();
+
         $coursecount = 0;
+        $this->trace->output('Updating course expiry data.', 0);
         foreach ($data as $expiryrecord) {
             if ($this->update_from_expiry_info($expiryrecord)) {
                 $coursecount++;
             }
         }
+        $this->trace->output('Done.', 1);
 
+        $this->trace->output('Calculating potential user expiries.', 0);
         $data = static::get_nested_expiry_info_for_user();
+
         $usercount = 0;
+        $this->trace->output('Updating user expiry data.', 0);
         foreach ($data as $expiryrecord) {
             if ($this->update_from_expiry_info($expiryrecord)) {
                 $usercount++;
             }
         }
+        $this->trace->output('Done.', 1);
 
         return [$coursecount, $usercount];
     }
@@ -241,6 +269,8 @@ class expired_contexts_manager {
         $datalist = [];
         $expiredcontents = [];
         $pathstoskip = [];
+
+        $userpurpose = data_registry::get_effective_contextlevel_value(CONTEXT_USER, 'purpose');
         foreach ($fulllist as $record) {
             \context_helper::preload_from_record($record);
             $context = \context::instance_by_id($record->id, false);
@@ -263,14 +293,19 @@ class expired_contexts_manager {
                 continue;
             }
 
-            $purposevalue = $record->purposeid !== null ? $record->purposeid : context_instance::NOTSET;
-            $purpose = api::get_effective_context_purpose($context, $purposevalue);
+            if ($context instanceof \context_user) {
+                $purpose = $userpurpose;
+            } else {
+                $purposevalue = $record->purposeid !== null ? $record->purposeid : context_instance::NOTSET;
+                $purpose = api::get_effective_context_purpose($context, $purposevalue);
+            }
 
             if ($context instanceof \context_user && !empty($record->userdeleted)) {
                 $expiryinfo = static::get_expiry_info($purpose, $record->userdeleted);
             } else {
                 $expiryinfo = static::get_expiry_info($purpose, $record->expirydate);
             }
+
             foreach ($datalist as $path => $data) {
                 // Merge with already-processed children.
                 if (strpos($path, $context->path) !== 0) {
@@ -279,6 +314,7 @@ class expired_contexts_manager {
 
                 $expiryinfo->merge_with_child($data->info);
             }
+
             $datalist[$context->path] = (object) [
                 'context' => $context,
                 'record' => $record,
@@ -309,44 +345,7 @@ class expired_contexts_manager {
         }));
 
         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']);
-                $requireenddate = self::require_all_end_dates_for_user_deletion();
-
-                foreach ($courses as $course) {
-                    if (empty($course->enddate)) {
-                        // This course has no end date.
-                        if ($requireenddate) {
-                            // Course end dates are required, and this course has no end date.
-                            $shouldskip = true;
-                            break;
-                        }
-
-                        // Course end dates are not required. The subsequent checks are pointless at this time so just
-                        // skip them.
-                        continue;
-                    }
-
-                    if ($course->enddate >= time()) {
-                        // This course is still in the future.
-                        $shouldskip = true;
-                        break;
-                    }
-
-                    // This course has an end date which is in the past.
-                    if (!self::is_course_expired($course)) {
-                        // This course has not expired yet.
-                        $shouldskip = true;
-                        break;
-                    }
-                }
-            }
+            $shouldskip = !self::are_user_context_dependencies_expired($context);
         }
 
         if ($shouldskip) {
@@ -363,16 +362,21 @@ class expired_contexts_manager {
      * @return  int[]       The number of deleted contexts.
      */
     public function process_approved_deletions() : array {
+        $this->trace->output('Checking requirements');
         if (!$this->check_requirements()) {
+            $this->trace->output('Requirements not met. Cannot process expired retentions.', 1);
             return [0, 0];
         }
 
+        $this->trace->output('Fetching all approved and expired contexts for deletion.');
         $expiredcontexts = expired_context::get_records(['status' => expired_context::STATUS_APPROVED]);
+        $this->trace->output('Done.', 1);
         $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.
@@ -380,7 +384,9 @@ class expired_contexts_manager {
                 continue;
             }
 
+            $this->trace->output("Deleting data for " . $context->get_context_name(), 2);
             if ($this->delete_expired_context($expiredctx)) {
+                $this->trace->output("Done.", 3);
                 if ($context instanceof \context_user) {
                     $usercount++;
                 } else {
@@ -425,11 +431,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 +579,45 @@ 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, $purpose->get('protected'), [], [], []);
         } else {
-            $defaultexpired = static::has_expired($purpose->get('retentionperiod'), $comparisondate);
-        }
+            $protectedroles = [];
+            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');
 
-        return new expiry_info($defaultexpired);
+                    if ($override->get('protected')) {
+                        $protectedroles[$override->get('roleid')] = true;
+                    }
+                }
+            }
+
+            $defaultexpired = false;
+            if (static::has_expired($purpose->get('retentionperiod'), $comparisondate)) {
+                $defaultexpired = true;
+            }
+
+            if ($defaultexpired) {
+                $expiredroles = [];
+            }
+
+            return new expiry_info($defaultexpired, $purpose->get('protected'), $expiredroles, $unexpiredroles, $protectedroles);
+        }
     }
 
     /**
@@ -565,7 +630,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 +644,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 +682,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 +723,80 @@ 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.
+                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,
+        ];
+    }
+
+    /**
+     * Determine whether the supplied context has expired.
+     *
+     * @param   \context    $context
+     * @return  bool
+     */
+    public static function is_context_expired(\context $context) : bool {
+        $parents = $context->get_parent_contexts(true);
+        foreach ($parents as $parent) {
+            if ($parent instanceof \context_course) {
+                return self::is_course_context_expired($context);
+            }
+
+            if ($parent instanceof \context_user) {
+                return self::are_user_context_dependencies_expired($context);
+            }
+        }
+
+        return false;
+    }
+
     /**
      * Check whether the course has expired.
      *
@@ -658,11 +805,149 @@ class expired_contexts_manager {
      */
     protected static function is_course_expired(\stdClass $course) : bool {
         $context = \context_course::instance($course->id);
+
+        return self::is_course_context_expired($context);
+    }
+
+    /**
+     * Determine whether the supplied course context has expired.
+     *
+     * @param   \context_course $context
+     * @return  bool
+     */
+    protected static function is_course_context_expired(\context_course $context) : bool {
         $expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
 
         return !empty($expiryrecords[$context->path]) && $expiryrecords[$context->path]->info->is_fully_expired();
     }
 
+    /**
+     * Determine whether the supplied user context's dependencies have expired.
+     *
+     * This checks whether courses have expired, and some other check, but does not check whether the user themself has expired.
+     *
+     * Although this seems unusual at first, each location calling this actually checks whether the user is elgible for
+     * deletion, irrespective if they have actually expired.
+     *
+     * For example, a request to delete the user only cares about course dependencies and the user's lack of expiry
+     * should not block their own request to be deleted; whilst the expiry eligibility check has already tested for the
+     * user being expired.
+     *
+     * @param   \context_user   $context
+     * @return  bool
+     */
+    protected static function are_user_context_dependencies_expired(\context_user $context) : bool {
+        // 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 expire.
+            return false;
+        }
+
+        $courses = enrol_get_users_courses($context->instanceid, false, ['enddate']);
+        $requireenddate = self::require_all_end_dates_for_user_deletion();
+
+        $expired = true;
+
+        foreach ($courses as $course) {
+            if (empty($course->enddate)) {
+                // This course has no end date.
+                if ($requireenddate) {
+                    // Course end dates are required, and this course has no end date.
+                    $expired = false;
+                    break;
+                }
+
+                // Course end dates are not required. The subsequent checks are pointless at this time so just
+                // skip them.
+                continue;
+            }
+
+            if ($course->enddate >= time()) {
+                // This course is still in the future.
+                $expired = false;
+                break;
+            }
+
+            // This course has an end date which is in the past.
+            if (!self::is_course_expired($course)) {
+                // This course has not expired yet.
+                $expired = false;
+                break;
+            }
+        }
+
+        return $expired;
+    }
+
+    /**
+     * Determine whether the supplied context has expired or unprotected for the specified user.
+     *
+     * @param   \context    $context
+     * @param   \stdClass   $user
+     * @return  bool
+     */
+    public static function is_context_expired_or_unprotected_for_user(\context $context, \stdClass $user) : bool {
+        $parents = $context->get_parent_contexts(true);
+        foreach ($parents as $parent) {
+            if ($parent instanceof \context_course) {
+                return self::is_course_context_expired_or_unprotected_for_user($parent, $user);
+            }
+
+            if ($parent instanceof \context_user) {
+                return self::are_user_context_dependencies_expired($context);
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Determine whether the supplied course context has expired, or is unprotected.
+     *
+     * @param   \context_course $context
+     * @param   \stdClass       $user
+     * @return  bool
+     */
+    protected static function is_course_context_expired_or_unprotected_for_user(\context_course $context, \stdClass $user) {
+        $expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
+
+        $info = $expiryrecords[$context->path]->info;
+        if ($info->is_fully_expired()) {
+            // This context is fully expired.
+            return true;
+        }
+
+        // Now perform user checks.
+        $userroles = array_map(function($assignment) {
+            return $assignment->roleid;
+        }, get_user_roles($context, $user->id));
+
+        $unexpiredprotectedroles = $info->get_unexpired_protected_roles();
+        if (!empty(array_intersect($unexpiredprotectedroles, $userroles))) {
+            // The user holds an unexpired and protected role.
+            return false;
+        }
+
+        $unprotectedoverriddenroles = $info->get_unprotected_overridden_roles();
+        $matchingroles = array_intersect($unprotectedoverriddenroles, $userroles);
+        if (!empty($matchingroles)) {
+            // This user has at least one overridden role which is not a protected.
+            // However, All such roles must match.
+            // If the user has multiple roles then all must be expired, otherwise we should fall back to the default behaviour.
+            if (empty(array_diff($userroles, $unprotectedoverriddenroles))) {
+                // All roles that this user holds are a combination of expired, or unprotected.
+                return true;
+            }
+        }
+
+        if ($info->is_default_expired()) {
+            // If the user has no unexpired roles, and the context is expired by default then this must be expired.
+            return true;
+        }
+
+        return !$info->is_default_protected();
+    }
+
     /**
      * Create a new instance of the privacy manager.
      *
index 508214f..5e409af 100644 (file)
@@ -36,15 +36,38 @@ 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 bool Whether the default purpose is protected */
+    protected $defaultprotected = false;
+
+    /** @var int[] List of expires roles */
+    protected $expired = [];
+
+    /** @var int[] List of unexpires roles */
+    protected $unexpired = [];
+
+    /** @var int[] List of unexpired roles which are also protected */
+    protected $protectedroles = [];
 
     /**
      * 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   bool    $defaultprotected Whether the default expiry is protected.
+     * @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.
+     * @param   int[]   $protectedroles A list of unexpired roles in this context which are protected.
      */
-    public function __construct(bool $isexpired) {
-        $this->isexpired = $isexpired;
+    public function __construct(bool $default, bool $defaultprotected, array $expired, array $unexpired, array $protectedroles) {
+        $this->defaultexpiryreached = $default;
+        $this->defaultprotected = $defaultprotected;
+        $this->expired = $expired;
+        $this->unexpired = $unexpired;
+        $this->protectedroles = $protectedroles;
     }
 
     /**
@@ -54,7 +77,7 @@ class expiry_info {
      * @return  bool
      */
     public function is_fully_expired() : bool {
-        return $this->isexpired;
+        return $this->defaultexpiryreached && empty($this->unexpired);
     }
 
     /**
@@ -67,9 +90,87 @@ 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;
+    }
+
+    /**
+     * Whether the default purpose is protected.
+     *
+     * @return  bool
+     */
+    public function is_default_protected() : bool {
+        return $this->defaultprotected;
+    }
+
+    /**
+     * Get the list of unexpired role IDs.
+     *
+     * @return  int[]
+     */
+    public function get_unexpired_roles() : array {
+        return $this->unexpired;
+    }
+
+    /**
+     * Get the list of unexpired protected roles.
+     *
+     * @return  int[]
+     */
+    public function get_unexpired_protected_roles() : array {
+        return array_keys(array_filter($this->protectedroles));
+    }
+
+    /**
+     * Get a list of all overridden roles which are unprotected.
+     * @return  int[]
+     */
+    public function get_unprotected_overridden_roles() : array {
+        $allroles = array_merge($this->expired, $this->unexpired);
+
+        return array_diff($allroles, $this->protectedroles);
+    }
+
     /**
      * Merge this expiry_info object with another belonging to a child context in order to set the 'safest' heritage.
      *
@@ -86,7 +187,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;
     }
index a6519c6..8e24235 100644 (file)
@@ -26,7 +26,6 @@ defined('MOODLE_INTERNAL') || die();
 
 use coding_exception;
 use core\external\persistent_exporter;
-use DateInterval;
 use Exception;
 use renderer_base;
 use tool_dataprivacy\context_instance;
@@ -79,6 +78,9 @@ class purpose_exporter extends persistent_exporter {
                 'multiple' => true,
                 'optional' => true
             ],
+            'roleoverrides' => [
+                'type' => PARAM_TEXT
+            ],
         ];
     }
 
@@ -125,23 +127,14 @@ class purpose_exporter extends persistent_exporter {
 
         $retentionperiod = $this->persistent->get('retentionperiod');
         if ($retentionperiod) {
-            $interval = new DateInterval($retentionperiod);
-
-            // It is one or another.
-            if ($interval->y) {
-                $formattedtime = get_string('numyears', 'moodle', $interval->format('%y'));
-            } else if ($interval->m) {
-                $formattedtime = get_string('nummonths', 'moodle', $interval->format('%m'));
-            } else if ($interval->d) {
-                $formattedtime = get_string('numdays', 'moodle', $interval->format('%d'));
-            } else {
-                $formattedtime = get_string('retentionperiodzero', 'tool_dataprivacy');
-            }
+            $formattedtime = \tool_dataprivacy\api::format_retention_period(new \DateInterval($retentionperiod));
         } else {
             $formattedtime = get_string('retentionperiodnotdefined', 'tool_dataprivacy');
         }
         $values['formattedretentionperiod'] = $formattedtime;
 
+        $values['roleoverrides'] = !empty($this->persistent->get_purpose_overrides());
+
         return $values;
     }
 
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 dba39df..72472e5 100644 (file)
@@ -41,6 +41,11 @@ class purpose extends persistent {
      */
     protected static $persistentclass = 'tool_dataprivacy\\purpose';
 
+    /**
+     * @var array The list of current overrides.
+     */
+    protected $existingoverrides = [];
+
     /**
      * Define the form - called by parent constructor
      */
@@ -56,54 +61,373 @@ class purpose extends persistent {
         $mform->setType('description', PARAM_CLEANHTML);
 
         // Field for selecting lawful bases (from GDPR Article 6.1).
-        $lawfulbases = [];
+        $this->add_field($this->get_lawful_base_field());
+        $mform->addRule('lawfulbases', get_string('required'), 'required', null, 'server');
+
+        // Optional field for selecting reasons for collecting sensitive personal data (from GDPR Article 9.2).
+        $this->add_field($this->get_sensitive_base_field());
+
+        $this->add_field($this->get_retention_period_fields());
+        $this->add_field($this->get_protected_field());
+
+        $this->add_override_fields();
+
+        if (!empty($this->_customdata['showbuttons'])) {
+            if (!$this->get_persistent()->get('id')) {
+                $savetext = get_string('add');
+            } else {
+                $savetext = get_string('savechanges');
+            }
+            $this->add_action_buttons(true, $savetext);
+        }
+    }
+
+    /**
+     * Add a fieldset to the current form.
+     *
+     * @param   \stdClass   $data
+     */
+    protected function add_field(\stdClass $data) {
+        foreach ($data->fields as $field) {
+            $this->_form->addElement($field);
+        }
+
+        if (!empty($data->helps)) {
+            foreach ($data->helps as $fieldname => $helpdata) {
+                $help = array_merge([$fieldname], $helpdata);
+                call_user_func_array([$this->_form, 'addHelpButton'], $help);
+            }
+        }
+
+        if (!empty($data->types)) {
+            foreach ($data->types as $fieldname => $type) {
+                $this->_form->setType($fieldname, $type);
+            }
+        }
+
+        if (!empty($data->rules)) {
+            foreach ($data->rules as $fieldname => $ruledata) {
+                $rule = array_merge([$fieldname], $ruledata);
+                call_user_func_array([$this->_form, 'addRule'], $rule);
+            }
+        }
+
+        if (!empty($data->defaults)) {
+            foreach ($data->defaults as $fieldname => $default) {
+                $this->_form($fieldname, $default);
+            }
+        }
+    }
+
+    /**
+     * Handle addition of relevant repeated element fields for role overrides.
+     */
+    protected function add_override_fields() {
+        $purpose = $this->get_persistent();
+
+        if (empty($purpose->get('id'))) {
+            // It is not possible to use repeated elements in a modal form yet.
+            return;
+        }
+
+        $fields = [
+            $this->get_role_override_id('roleoverride_'),
+            $this->get_role_field('roleoverride_'),
+            $this->get_retention_period_fields('roleoverride_'),
+            $this->get_protected_field('roleoverride_'),
+            $this->get_lawful_base_field('roleoverride_'),
+            $this->get_sensitive_base_field('roleoverride_'),
+        ];
+
+        $options = [
+            'type' => [],
+            'helpbutton' => [],
+        ];
+
+        // Start by adding the title.
+        $overrideelements = [
+            $this->_form->createElement('header', 'roleoverride', get_string('roleoverride', 'tool_dataprivacy')),
+            $this->_form->createElement(
+                'static',
+                'roleoverrideoverview',
+                '',
+                get_string('roleoverrideoverview', 'tool_dataprivacy')
+            ),
+        ];
+
+        foreach ($fields as $fielddata) {
+            foreach ($fielddata->fields as $field) {
+                $overrideelements[] = $field;
+            }
+
+            if (!empty($fielddata->helps)) {
+                foreach ($fielddata->helps as $name => $help) {
+                    if (!isset($options[$name])) {
+                        $options[$name] = [];
+                    }
+                    $options[$name]['helpbutton'] = $help;
+                }
+            }
+
+            if (!empty($fielddata->types)) {
+                foreach ($fielddata->types as $name => $type) {
+                    if (!isset($options[$name])) {
+                        $options[$name] = [];
+                    }
+                    $options[$name]['type'] = $type;
+                }
+            }
+
+            if (!empty($fielddata->rules)) {
+                foreach ($fielddata->rules as $name => $rule) {
+                    if (!isset($options[$name])) {
+                        $options[$name] = [];
+                    }
+                    $options[$name]['rule'] = $rule;
+                }
+            }
+
+            if (!empty($fielddata->defaults)) {
+                foreach ($fielddata->defaults as $name => $default) {
+                    if (!isset($options[$name])) {
+                        $options[$name] = [];
+                    }
+                    $options[$name]['default'] = $default;
+                }
+            }
+
+            if (!empty($fielddata->advanceds)) {
+                foreach ($fielddata->advanceds as $name => $advanced) {
+                    if (!isset($options[$name])) {
+                        $options[$name] = [];
+                    }
+                    $options[$name]['advanced'] = $advanced;
+                }
+            }
+        }
+
+        $this->existingoverrides = $purpose->get_purpose_overrides();
+        $existingoverridecount = count($this->existingoverrides);
+
+        $this->repeat_elements(
+                $overrideelements,
+                $existingoverridecount,
+                $options,
+                'overrides',
+                'addoverride',
+                1,
+                get_string('addroleoverride', 'tool_dataprivacy')
+            );
+    }
+
+    /**
+     * Converts fields.
+     *
+     * @param \stdClass $data
+     * @return \stdClass
+     */
+    public function filter_data_for_persistent($data) {
+        $data = parent::filter_data_for_persistent($data);
+
+        $classname = static::$persistentclass;
+        $properties = $classname::properties_definition();
+
+        $data = (object) array_filter((array) $data, function($value, $key) use ($properties) {
+            return isset($properties[$key]);
+        }, ARRAY_FILTER_USE_BOTH);
+
+        return $data;
+    }
+
+    /**
+     * Get the field for the role name.
+     *
+     * @param   string  $prefix The prefix to apply to the field
+     * @return  \stdClass
+     */
+    protected function get_role_override_id(string $prefix = '') : \stdClass {
+        $fieldname = "{$prefix}id";
+
+        $fielddata = (object) [
+            'fields' => [],
+        ];
+
+        $fielddata->fields[] = $this->_form->createElement('hidden', $fieldname);
+        $fielddata->types[$fieldname] = PARAM_INT;
+
+        return $fielddata;
+    }
+
+    /**
+     * Get the field for the role name.
+     *
+     * @param   string  $prefix The prefix to apply to the field
+     * @return  \stdClass
+     */
+    protected function get_role_field(string $prefix = '') : \stdClass {
+        $fieldname = "{$prefix}roleid";
+
+        $fielddata = (object) [
+            'fields' => [],
+            'helps' => [],
+        ];
+
+        $roles = [
+            '' => get_string('none'),
+        ];
+        foreach (role_get_names() as $roleid => $role) {
+            $roles[$roleid] = $role->localname;
+        }
+
+        $fielddata->fields[] = $this->_form->createElement('select', $fieldname, get_string('role'),
+            $roles,
+            [
+                'multiple' => false,
+            ]
+        );
+        $fielddata->helps[$fieldname] = ['role', 'tool_dataprivacy'];
+        $fielddata->defaults[$fieldname] = null;
+
+        return $fielddata;
+    }
+
+    /**
+     * Get the mform field for lawful bases.
+     *
+     * @param   string  $prefix The prefix to apply to the field
+     * @return  \stdClass
+     */
+    protected function get_lawful_base_field(string $prefix = '') : \stdClass {
+        $fieldname = "{$prefix}lawfulbases";
+
+        $data = (object) [
+            'fields' => [],
+        ];
+
+        $bases = [];
         foreach (\tool_dataprivacy\purpose::GDPR_ART_6_1_ITEMS as $article) {
             $key = 'gdpr_art_6_1_' . $article;
-            $lawfulbases[$key] = get_string($key . '_name', 'tool_dataprivacy');
+            $bases[$key] = get_string("{$key}_name", 'tool_dataprivacy');
         }
-        $options = array(
-            'multiple' => true,
+
+        $data->fields[] = $this->_form->createElement('autocomplete', $fieldname, get_string('lawfulbases', 'tool_dataprivacy'),
+            $bases,
+            [
+                'multiple' => true,
+            ]
         );
-        $mform->addElement('autocomplete', 'lawfulbases', get_string('lawfulbases', 'tool_dataprivacy'), $lawfulbases, $options);
-        $mform->addRule('lawfulbases', get_string('required'), 'required', null, 'server');
-        $mform->addHelpButton('lawfulbases', 'lawfulbases', 'tool_dataprivacy');
 
-        // Optional field for selecting reasons for collecting sensitive personal data (from GDPR Article 9.2).
-        $sensitivereasons = [];
+        $data->helps = [
+            $fieldname => ['lawfulbases', 'tool_dataprivacy'],
+        ];
+
+        $data->advanceds = [
+            $fieldname => true,
+        ];
+
+        return $data;
+    }
+
+    /**
+     * Get the mform field for sensitive bases.
+     *
+     * @param   string  $prefix The prefix to apply to the field
+     * @return  \stdClass
+     */
+    protected function get_sensitive_base_field(string $prefix = '') : \stdClass {
+        $fieldname = "{$prefix}sensitivedatareasons";
+
+        $data = (object) [
+            'fields' => [],
+        ];
+
+        $bases = [];
         foreach (\tool_dataprivacy\purpose::GDPR_ART_9_2_ITEMS as $article) {
             $key = 'gdpr_art_9_2_' . $article;
-            $sensitivereasons[$key] = get_string($key . '_name', 'tool_dataprivacy');
+            $bases[$key] = get_string("{$key}_name", 'tool_dataprivacy');
         }
-        $mform->addElement('autocomplete', 'sensitivedatareasons', get_string('sensitivedatareasons', 'tool_dataprivacy'),
-            $sensitivereasons, $options);
-        $mform->addHelpButton('sensitivedatareasons', 'sensitivedatareasons', 'tool_dataprivacy');
 
-        $number = $mform->createElement('text', 'retentionperiodnumber', null, ['size' => 8]);
+        $data->fields[] = $this->_form->createElement(
+            'autocomplete',
+            $fieldname,
+            get_string('sensitivedatareasons', 'tool_dataprivacy'),
+            $bases,
+            [
+                'multiple' => true,
+            ]
+        );
+        $data->helps = [
+            $fieldname => ['sensitivedatareasons', 'tool_dataprivacy'],
+        ];
+
+        $data->advanceds = [
+            $fieldname => true,
+        ];
+
+        return $data;
+    }
+
+    /**
+     * Get the retention period fields.
+     *
+     * @param   string  $prefix The name of the main field, and prefix for the subfields.
+     * @return  \stdClass
+     */
+    protected function get_retention_period_fields(string $prefix = '') : \stdClass {
+        $prefix = "{$prefix}retentionperiod";
+        $data = (object) [
+            'fields' => [],
+            'types' => [],
+        ];
+
+        $number = $this->_form->createElement('text', "{$prefix}number", null, ['size' => 8]);
+        $data->types["{$prefix}number"] = PARAM_INT;
+
         $unitoptions = [
             'Y' => get_string('years'),
             'M' => strtolower(get_string('months')),
             'D' => strtolower(get_string('days'))
         ];
-        $unit = $mform->createElement('select', 'retentionperiodunit', '', $unitoptions);
-        $mform->addGroup(['number' => $number, 'unit' => $unit], 'retentionperiod',
-            get_string('retentionperiod', 'tool_dataprivacy'), null, false);
-        $mform->setType('retentionperiodnumber', PARAM_INT);
+        $unit = $this->_form->createElement('select', "{$prefix}unit", '', $unitoptions);
 
-        $this->_form->addElement('advcheckbox', 'protected', get_string('protected', 'tool_dataprivacy'),
-            get_string('protectedlabel', 'tool_dataprivacy'));
+        $data->fields[] = $this->_form->createElement(
+                'group',
+                $prefix,
+                get_string('retentionperiod', 'tool_dataprivacy'),
+                [
+                    'number' => $number,
+                    'unit' => $unit,
+                ],
+                null,
+                false
+            );
 
-        if (!empty($this->_customdata['showbuttons'])) {
-            if (!$this->get_persistent()->get('id')) {
-                $savetext = get_string('add');
-            } else {
-                $savetext = get_string('savechanges');
-            }
-            $this->add_action_buttons(true, $savetext);
-        }
+        return $data;
     }
 
     /**
-     * Converts fields.
+     * Get the mform field for the protected flag.
+     *
+     * @param   string  $prefix The prefix to apply to the field
+     * @return  \stdClass
+     */
+    protected function get_protected_field(string $prefix = '') : \stdClass {
+        $fieldname = "{$prefix}protected";
+
+        return (object) [
+            'fields' => [
+                $this->_form->createElement(
+                        'advcheckbox',
+                        $fieldname,
+                        get_string('protected', 'tool_dataprivacy'),
+                        get_string('protectedlabel', 'tool_dataprivacy')
+                    ),
+            ],
+        ];
+    }
+
+    /**
+     * Converts data to data suitable for storage.
      *
      * @param \stdClass $data
      * @return \stdClass
@@ -111,7 +435,7 @@ class purpose extends persistent {
     protected static function convert_fields(\stdClass $data) {
         $data = parent::convert_fields($data);
 
-        if (is_array($data->lawfulbases)) {
+        if (!empty($data->lawfulbases) && is_array($data->lawfulbases)) {
             $data->lawfulbases = implode(',', $data->lawfulbases);
         }
         if (!empty($data->sensitivedatareasons) && is_array($data->sensitivedatareasons)) {
@@ -122,6 +446,7 @@ class purpose extends persistent {
         $data->retentionperiod = 'P' . $data->retentionperiodnumber . $data->retentionperiodunit;
         unset($data->retentionperiodnumber);
         unset($data->retentionperiodunit);
+
         return $data;
     }
 
@@ -133,6 +458,16 @@ class purpose extends persistent {
     protected function get_default_data() {
         $data = parent::get_default_data();
 
+        return $this->convert_existing_data_to_values($data);
+    }
+
+    /**
+     * Normalise any values stored in existing data.
+     *
+     * @param   \stdClass $data
+     * @return  \stdClass
+     */
+    protected function convert_existing_data_to_values(\stdClass $data) : \stdClass {
         $data->lawfulbases = explode(',', $data->lawfulbases);
         if (!empty($data->sensitivedatareasons)) {
             $data->sensitivedatareasons = explode(',', $data->sensitivedatareasons);
@@ -146,4 +481,94 @@ class purpose extends persistent {
 
         return $data;
     }
+
+    /**
+     * Fetch the role override data from the list of submitted data.
+     *
+     * @param   \stdClass   $data The complete set of processed data
+     * @return  \stdClass[] The list of overrides
+     */
+    public function get_role_overrides_from_data(\stdClass $data) {
+        $overrides = [];
+        if (!empty($data->overrides)) {
+            $searchkey = 'roleoverride_';
+
+            for ($i = 0; $i < $data->overrides; $i++) {
+                $overridedata = (object) [];
+                foreach ((array) $data as $fieldname => $value) {
+                    if (strpos($fieldname, $searchkey) !== 0) {
+                        continue;
+                    }
+
+                    $overridefieldname = substr($fieldname, strlen($searchkey));
+                    $overridedata->$overridefieldname = $value[$i];
+                }
+
+                if (empty($overridedata->roleid) || empty($overridedata->retentionperiodnumber)) {
+                    // Skip this one.
+                    // There is no value and it will be delete.
+                    continue;
+                }
+
+                $override = static::convert_fields($overridedata);
+
+                $overrides[$i] = $override;
+            }
+        }
+
+        return $overrides;
+    }
+
+    /**
+     * Define extra validation mechanims.
+     *
+     * @param  stdClass $data Data to validate.
+     * @param  array $files Array of files.
+     * @param  array $errors Currently reported errors.
+     * @return array of additional errors, or overridden errors.
+     */
+    protected function extra_validation($data, $files, array &$errors) {
+        $overrides = $this->get_role_overrides_from_data($data);
+
+        // Check role overrides to ensure that:
+        // - roles are unique; and
+        // - specifeid retention periods are numeric.
+        $seenroleids = [];
+        foreach ($overrides as $id => $override) {
+            $override->purposeid = 0;
+            $persistent = new \tool_dataprivacy\purpose_override($override->id, $override);
+
+            if (isset($seenroleids[$persistent->get('roleid')])) {
+                $errors["roleoverride_roleid[{$id}]"] = get_string('duplicaterole');
+            }
+            $seenroleids[$persistent->get('roleid')] = true;
+
+            $errors = array_merge($errors, $persistent->get_errors());
+        }
+
+        return $errors;
+    }
+
+    /**
+     * Load in existing data as form defaults. Usually new entry defaults are stored directly in
+     * form definition (new entry form); this function is used to load in data where values
+     * already exist and data is being edited (edit entry form).
+     *
+     * @param stdClass $data
+     */
+    public function set_data($data) {
+        $purpose = $this->get_persistent();
+
+        $count = 0;
+        foreach ($this->existingoverrides as $override) {
+            $overridedata = $this->convert_existing_data_to_values($override->to_record());
+            foreach ($overridedata as $key => $value) {
+                $keyname = "roleoverride_{$key}[{$count}]";
+                $data->$keyname = $value;
+            }
+            $count++;
+        }
+
+        parent::set_data($data);
+    }
 }
index c6f0e65..312ee6e 100644 (file)
@@ -57,9 +57,15 @@ class expired_contexts_table extends table_sql {
      */
     protected $selectall = true;
 
-    /** @var purpose[] Array of purposes mapped to the contexts. */
+    /** @var purpose[] Array of purposes by their id. */
     protected $purposes = [];
 
+    /** @var purpose[] Map of context => purpose. */
+    protected $purposemap = [];
+
+    /** @var array List of roles. */
+    protected $roles = [];
+
     /**
      * expired_contexts_table constructor.
      *
@@ -77,6 +83,7 @@ class expired_contexts_table extends table_sql {
             'purpose' => get_string('purpose', 'tool_dataprivacy'),
             'category' => get_string('category', 'tool_dataprivacy'),
             'retentionperiod' => get_string('retentionperiod', 'tool_dataprivacy'),
+            'tobedeleted' => get_string('tobedeleted', 'tool_dataprivacy'),
             'timecreated' => get_string('expiry', 'tool_dataprivacy'),
         ];
         $checkboxattrs = [
@@ -93,21 +100,25 @@ class expired_contexts_table extends table_sql {
         $this->no_sorting('purpose');
         $this->no_sorting('category');
         $this->no_sorting('retentionperiod');
+        $this->no_sorting('tobedeleted');
 
         // Make this table sorted by first name by default.
         $this->sortable(true, 'timecreated');
+
+        // We use roles in several places.
+        $this->roles = role_get_names();
     }
 
     /**
      * The context name column.
      *
-     * @param stdClass $data The row data.
+     * @param stdClass $expiredctx The row data.
      * @return string
      * @throws coding_exception
      */
-    public function col_name($data) {
+    public function col_name($expiredctx) {
         global $OUTPUT;
-        $context = context_helper::instance_by_id($data->contextid);
+        $context = context_helper::instance_by_id($expiredctx->get('contextid'));
         $parent = $context->get_parent_context();
         $contextdata = (object)[
             'name' => $context->get_context_name(false, true),
@@ -128,14 +139,14 @@ class expired_contexts_table extends table_sql {
     /**
      * The context information column.
      *
-     * @param stdClass $data The row data.
+     * @param stdClass $expiredctx The row data.
      * @return string
      * @throws coding_exception
      */
-    public function col_info($data) {
+    public function col_info($expiredctx) {
         global $OUTPUT;
 
-        $context = context_helper::instance_by_id($data->contextid);
+        $context = context_helper::instance_by_id($expiredctx->get('contextid'));
 
         $children = $context->get_child_contexts();
         if (empty($children)) {
@@ -156,13 +167,13 @@ class expired_contexts_table extends table_sql {
     /**
      * The category name column.
      *
-     * @param stdClass $data The row data.
+     * @param stdClass $expiredctx The row data.
      * @return mixed
      * @throws coding_exception
      * @throws dml_exception
      */
-    public function col_category($data) {
-        $context = context_helper::instance_by_id($data->contextid);
+    public function col_category($expiredctx) {
+        $context = context_helper::instance_by_id($expiredctx->get('contextid'));
         $category = api::get_effective_context_category($context);
 
         return s($category->get('name'));
@@ -171,12 +182,12 @@ class expired_contexts_table extends table_sql {
     /**
      * The purpose column.
      *
-     * @param stdClass $data The row data.
+     * @param stdClass $expiredctx The row data.
      * @return string
      * @throws coding_exception
      */
-    public function col_purpose($data) {
-        $purpose = $this->purposes[$data->contextid];
+    public function col_purpose($expiredctx) {
+        $purpose = $this->get_purpose_for_expiry($expiredctx);
 
         return s($purpose->get('name'));
     }
@@ -184,42 +195,114 @@ class expired_contexts_table extends table_sql {
     /**
      * The retention period column.
      *
-     * @param stdClass $data The row data.
+     * @param stdClass $expiredctx The row data.
      * @return string
-     * @throws Exception
      */
-    public function col_retentionperiod($data) {
-        global $PAGE;
+    public function col_retentionperiod($expiredctx) {
+        $purpose = $this->get_purpose_for_expiry($expiredctx);
+
+        $expiries = [];
+
+        $expiry = html_writer::tag('dt', get_string('default'), ['class' => 'col-sm-3']);
+        if ($expiredctx->get('defaultexpired')) {
+            $expiries[get_string('default')] = get_string('expiredrolewithretention', 'tool_dataprivacy', (object) [
+                    'retention' => api::format_retention_period(new \DateInterval($purpose->get('retentionperiod'))),
+                ]);
+        } else {
+            $expiries[get_string('default')] = get_string('unexpiredrolewithretention', 'tool_dataprivacy', (object) [
+                    'retention' => api::format_retention_period(new \DateInterval($purpose->get('retentionperiod'))),
+                ]);
+        }
+
+        if (!$expiredctx->is_fully_expired()) {
+            $purposeoverrides = $purpose->get_purpose_overrides();
+
+            foreach ($expiredctx->get('unexpiredroles') as $roleid) {
+                $role = $this->roles[$roleid];
+                $override = $purposeoverrides[$roleid];
+
+                $expiries[$role->localname] = get_string('unexpiredrolewithretention', 'tool_dataprivacy', (object) [
+                        'retention' => api::format_retention_period(new \DateInterval($override->get('retentionperiod'))),
+                    ]);
+            }
+
+            foreach ($expiredctx->get('expiredroles') as $roleid) {
+                $role = $this->roles[$roleid];
+                $override = $purposeoverrides[$roleid];
+
+                $expiries[$role->localname] = get_string('expiredrolewithretention', 'tool_dataprivacy', (object) [
+                        'retention' => api::format_retention_period(new \DateInterval($override->get('retentionperiod'))),
+                    ]);
+            }
+        }
 
-        $purpose = $this->purposes[$data->contextid];
+        $output = array_map(function($rolename, $expiry) {
+            $return = html_writer::tag('dt', $rolename, ['class' => 'col-sm-3']);
+            $return .= html_writer::tag('dd', $expiry, ['class' => 'col-sm-9']);
 
-        $exporter = new purpose_exporter($purpose, ['context' => \context_system::instance()]);
-        $exportedpurpose = $exporter->export($PAGE->get_renderer('core'));
+            return $return;
+        }, array_keys($expiries), $expiries);
 
-        return $exportedpurpose->formattedretentionperiod;
+        return html_writer::tag('dl', implode($output), ['class' => 'row']);
     }
 
     /**
      * The timecreated a.k.a. the context expiry date column.
      *
-     * @param stdClass $data The row data.
+     * @param stdClass $expiredctx The row data.
      * @return string
      */
-    public function col_timecreated($data) {
-        return userdate($data->timecreated);
+    public function col_timecreated($expiredctx) {
+        return userdate($expiredctx->get('timecreated'));
     }
 
     /**
      * Generate the select column.
      *
-     * @param stdClass $data The row data.
+     * @param stdClass $expiredctx The row data.
      * @return string
      */
-    public function col_select($data) {
-        $id = $data->id;
+    public function col_select($expiredctx) {
+        $id = $expiredctx->get('id');
         return html_writer::checkbox('expiredcontext_' . $id, $id, $this->selectall, '', ['class' => 'selectcontext']);
     }
 
+    /**
+     * Formatting for the 'tobedeleted' column which indicates in a friendlier fashion whose data will be removed.
+     *
+     * @param stdClass $expiredctx The row data.
+     * @return string
+     */
+    public function col_tobedeleted($expiredctx) {
+        if ($expiredctx->is_fully_expired()) {
+            return get_string('defaultexpired', 'tool_dataprivacy');
+        }
+
+        $purpose = $this->get_purpose_for_expiry($expiredctx);
+
+        $a = (object) [];
+
+        $expiredroles = [];
+        foreach ($expiredctx->get('expiredroles') as $roleid) {
+            $expiredroles[] = html_writer::tag('li', $this->roles[$roleid]->localname);
+        }
+        $a->expired = html_writer::tag('ul', implode($expiredroles));
+
+        $unexpiredroles = [];
+        foreach ($expiredctx->get('unexpiredroles') as $roleid) {
+            $unexpiredroles[] = html_writer::tag('li', $this->roles[$roleid]->localname);
+        }
+        $a->unexpired = html_writer::tag('ul', implode($unexpiredroles));
+
+        if ($expiredctx->get('defaultexpired')) {
+            return get_string('defaultexpiredexcept', 'tool_dataprivacy', $a);
+        } else if (empty($unexpiredroles)) {
+            return get_string('defaultunexpired', 'tool_dataprivacy', $a);
+        } else {
+            return get_string('defaultunexpiredwithexceptions', 'tool_dataprivacy', $a);
+        }
+    }
+
     /**
      * Query the database for results to display in the table.
      *
@@ -241,17 +324,16 @@ class expired_contexts_table extends table_sql {
         // Only load expired contexts that are awaiting confirmation.
         $expiredcontexts = expired_context::get_records_by_contextlevel($this->contextlevel, expired_context::STATUS_EXPIRED,
             $sort, $this->get_page_start(), $this->get_page_size());
+
         $this->rawdata = [];
+        $contextids = [];
         foreach ($expiredcontexts as $persistent) {
-            $data = $persistent->to_record();
-
-            $context = context_helper::instance_by_id($data->contextid);
-
-            $purpose = api::get_effective_context_purpose($context);
-            $this->purposes[$data->contextid] = $purpose;
-            $this->rawdata[] = $data;
+            $this->rawdata[] = $persistent;
+            $contextids[] = $persistent->get('contextid');
         }
 
+        $this->preload_contexts($contextids);
+
         // Set initial bars.
         if ($useinitialsbar) {
             $this->initialbars($total > $pagesize);
@@ -281,4 +363,48 @@ class expired_contexts_table extends table_sql {
         }
         return '';
     }
+
+    /**
+     * Get the purpose for the specified expired context.
+     *
+     * @param   expired_context $expiredcontext
+     * @return  purpose
+     */
+    protected function get_purpose_for_expiry(expired_context $expiredcontext) : purpose {
+        $context = context_helper::instance_by_id($expiredcontext->get('contextid'));
+
+        if (empty($this->purposemap[$context->id])) {
+            $purpose = api::get_effective_context_purpose($context);
+            $this->purposemap[$context->id] = $purpose->get('id');
+
+            if (empty($this->purposes[$purpose->get('id')])) {
+                $this->purposes[$purpose->get('id')] = $purpose;
+            }
+        }
+
+        return $this->purposes[$this->purposemap[$context->id]];
+    }
+
+    /**
+     * Preload context records given a set of contextids.
+     *
+     * @param   array   $contextids
+     */
+    protected function preload_contexts(array $contextids) {
+        global $DB;
+
+        if (empty($contextids)) {
+            return;
+        }
+
+        $ctxfields = \context_helper::get_preload_record_columns_sql('ctx');
+        list($insql, $inparams) = $DB->get_in_or_equal($contextids, SQL_PARAMS_NAMED);
+        $sql = "SELECT {$ctxfields} FROM {context} ctx WHERE ctx.id {$insql}";
+        $contextlist = $DB->get_recordset_sql($sql, $inparams);
+        foreach ($contextlist as $contextdata) {
+            \context_helper::preload_from_record($contextdata);
+        }
+        $contextlist->close();
+
+    }
 }
index 4ddd411..193bd70 100644 (file)
@@ -30,9 +30,11 @@ use context;
 use context_user;
 use core_privacy\local\metadata\collection;
 use core_privacy\local\request\approved_contextlist;
+use \core_privacy\local\request\approved_userlist;
 use core_privacy\local\request\contextlist;
 use core_privacy\local\request\helper;
 use core_privacy\local\request\transform;
+use \core_privacy\local\request\userlist;
 use core_privacy\local\request\writer;
 use dml_exception;
 use stdClass;
@@ -50,6 +52,9 @@ class provider implements
         // This tool stores user data.
         \core_privacy\local\metadata\provider,
 
+        // This plugin is capable of determining which users have data within it.
+        \core_privacy\local\request\core_userlist_provider,
+
         // This tool may provide access to and deletion of user data.
         \core_privacy\local\request\plugin\provider,
 
@@ -100,6 +105,32 @@ class provider implements
         return $contextlist;
     }
 
+    /**
+     * Get the list of users who have data within a context.
+     *
+     * @param   userlist    $userlist   The userlist containing the list of users who have data in this context/plugin combination.
+     *
+     */
+    public static function get_users_in_context(userlist $userlist) {
+        $context = $userlist->get_context();
+
+        if (!is_a($context, \context_user::class)) {
+            return;
+        }
+
+        $params = [
+            'contextlevel' => CONTEXT_USER,
+            'contextid' => $context->id,
+        ];
+
+        $sql = "SELECT instanceid AS userid
+                  FROM {context}
+                 WHERE id = :contextid
+                       AND contextlevel = :contextlevel";
+
+        $userlist->add_from_sql('userid', $sql, $params);
+    }
+
     /**
      * Export all user data for the specified user, in the specified contexts.
      *
@@ -172,6 +203,15 @@ class provider implements
     public static function delete_data_for_user(approved_contextlist $contextlist) {
     }
 
+    /**
+     * Delete multiple users within a single context.
+     *
+     * @param   approved_userlist    $userlist The approved context and user information to delete information for.
+     *
+     */
+    public static function delete_data_for_users(approved_userlist $userlist) {
+    }
+
     /**
      * Export all user preferences for the plugin.
      *
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..5b022ab
--- /dev/null
@@ -0,0 +1,143 @@
+<?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 {
+        $cache = \cache::make('tool_dataprivacy', 'purpose_overrides');
+
+        $overrides = [];
+        $alldata = $cache->get($purpose->get('id'));
+        if (false === $alldata) {
+            $tocache = [];
+            foreach (self::get_records(['purposeid' => $purpose->get('id')]) as $override) {
+                $tocache[] = $override->to_record();
+                $overrides[$override->get('roleid')] = $override;
+            }
+            $cache->set($purpose->get('id'), $tocache);
+        } else {
+            foreach ($alldata as $data) {
+                $override = new self(0, $data);
+                $overrides[$override->get('roleid')] = $override;
+            }
+        }
+
+        return $overrides;
+    }
+
+    /**
+     * Adds the new record to the cache.
+     *
+     * @return null
+     */
+    protected function after_create() {
+        $cache = \cache::make('tool_dataprivacy', 'purpose_overrides');
+        $cache->delete($this->get('purposeid'));
+    }
+
+    /**
+     * Updates the cache record.
+     *
+     * @param bool $result
+     * @return null
+     */
+    protected function after_update($result) {
+        $cache = \cache::make('tool_dataprivacy', 'purpose_overrides');
+        $cache->delete($this->get('purposeid'));
+    }
+
+    /**
+     * Removes unnecessary stuff from db.
+     *
+     * @return null
+     */
+    protected function before_delete() {
+        $cache = \cache::make('tool_dataprivacy', 'purpose_overrides');
+        $cache->delete($this->get('purposeid'));
+    }
+}
index 8a4f96b..a91e8da 100644 (file)
@@ -54,7 +54,7 @@ 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_contexts_manager();
+        $manager = new \tool_dataprivacy\expired_contexts_manager(new \text_progress_trace());
         list($courses, $users) = $manager->process_approved_deletions();
         mtrace("Processed deletions for {$courses} course contexts, and {$users} user contexts as expired");
     }
index f00b51a..c7068b6 100644 (file)
@@ -54,7 +54,7 @@ class expired_retention_period extends scheduled_task {
      * Run the task to flag context instances as expired.
      */
     public function execute() {
-        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        $manager = new \tool_dataprivacy\expired_contexts_manager(new \text_progress_trace());
         list($courses, $users) = $manager->flag_expired_contexts();
         mtrace("Flagged {$courses} course contexts, and {$users} user contexts as expired");
     }
index 00124bb..a07db1a 100644 (file)
@@ -33,6 +33,13 @@ $definitions = array(
         'staticacceleration' => true,
         'staticaccelerationsize' => 30,
     ),
+    'purpose_overrides' => array(
+        'mode' => cache_store::MODE_APPLICATION,
+        'simplekeys' => true,
+        'simpledata' => false,
+        'staticacceleration' => true,
+        'staticaccelerationsize' => 50,
+    ),
     'contextlevel' => array(
         'mode' => cache_store::MODE_APPLICATION,
         'simplekeys' => true,
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 20ee631..bc79471 100644 (file)
@@ -44,14 +44,47 @@ $form = new \tool_dataprivacy\form\purpose($PAGE->url->out(false),
 $returnurl = new \moodle_url('/admin/tool/dataprivacy/purposes.php');
 if ($form->is_cancelled()) {
     redirect($returnurl);
-} else if ($data = $form->get_data()) {
+} else if ($alldata = $form->get_data()) {
+    $data = $form->filter_data_for_persistent($alldata);
+
     if (empty($data->id)) {
-        \tool_dataprivacy\api::create_purpose($data);
+        $purpose = \tool_dataprivacy\api::create_purpose($data);
         $messagesuccess = get_string('purposecreated', 'tool_dataprivacy');
     } else {
-        \tool_dataprivacy\api::update_purpose($data);
+        $purpose = \tool_dataprivacy\api::update_purpose($data);
         $messagesuccess = get_string('purposeupdated', 'tool_dataprivacy');
     }
+
+    $currentoverrides = [];
+    foreach ($purpose->get_purpose_overrides() as $override) {
+        $currentoverrides[$override->get('id')] = $override;
+    }
+
+    $overrides = $form->get_role_overrides_from_data($alldata);
+    $submittedoverrides = [];
+    $tosave = [];
+
+    foreach ($overrides as $overridedata) {
+        $overridedata->purposeid = $purpose->get('id');
+        $override = new \tool_dataprivacy\purpose_override($overridedata->id, $overridedata);
+
+        $tosave[] = $override;
+
+        if (!empty($overridedata->id)) {
+            $submittedoverrides[$overridedata->id] = true;
+        }
+    }
+
+    foreach ($currentoverrides as $id => $override) {
+        if (!isset($submittedoverrides[$id])) {
+            $override->delete();
+        }
+    }
+
+    foreach ($tosave as $override) {
+        $override->save();
+    }
+
     redirect($returnurl, $messagesuccess, 0, \core\output\notification::NOTIFY_SUCCESS);
 }
 
index 0cbc55f..448435f 100644 (file)
@@ -35,6 +35,7 @@ $string['approverequest'] = 'Approve request';
 $string['bulkapproverequests'] = 'Approve requests';
 $string['bulkdenyrequests'] = 'Deny requests';
 $string['cachedef_purpose'] = 'Data purposes';
+$string['cachedef_purpose_overrides'] = 'Purpose overrides in the Data Privacy tool';
 $string['cachedef_contextlevel'] = 'Context levels purpose and category';
 $string['cancelrequest'] = 'Cancel request';
 $string['cancelrequestconfirmation'] = 'Do you really want cancel this data request?';
@@ -198,6 +199,7 @@ $string['nopurposes'] = 'There are no purposes yet';
 $string['nosubjectaccessrequests'] = 'There are no data requests that you need to act on';
 $string['nosystemdefaults'] = 'Site purpose and category have not yet been defined.';
 $string['notset'] = 'Not set (use the default value)';
+$string['notyetexpired'] = '{$a} (not yet expired)';
 $string['overrideinstances'] = 'Reset instances with custom values';
 $string['pluginregistry'] = 'Plugin privacy registry';
 $string['pluginregistrytitle'] = 'Plugin privacy compliance registry';
@@ -266,6 +268,7 @@ $string['retentionperiod'] = 'Retention period';
 $string['retentionperiod_help'] = 'The retention period specifies the length of time that data should be kept for. When the retention period has expired, the data is flagged and listed for deletion, awaiting admin confirmation.';
 $string['retentionperiodnotdefined'] = 'No retention period was defined';
 $string['retentionperiodzero'] = 'No retention period';
+$string['roleoverrides'] = 'Role overrides';
 $string['selectbulkaction'] = 'Please select a bulk action.';
 $string['selectdatarequests'] = 'Please select data requests.';
 $string['selectuserdatarequest'] = 'Select {$a->username}\'s {$a->requesttype} data request.';
@@ -291,3 +294,22 @@ $string['summary'] = 'Registry configuration summary';
 $string['user'] = 'User';
 $string['viewrequest'] = 'View the request';
 $string['visible'] = 'Expand all';
+$string['unexpiredrolewithretention'] = '{$a->retention} (Unexpired)';
+$string['expiredrolewithretention'] = '{$a->retention} (Expired)';
+$string['defaultexpired'] = 'Data for all users';
+$string['defaultexpiredexcept'] = 'Data for all users, except those who hold any of the following roles:<br>
+{$a->unexpired}';
+$string['defaultunexpiredwithexceptions'] = 'Only data for users who hold any of the following roles:<br>
+{$a->expired}
+Unless they also hold any of the following roles:<br>
+{$a->unexpired}';
+$string['defaultunexpired'] = 'Only data for users holding any of the following roles:<br>
+{$a->expired}';
+$string['tobedeleted'] = 'Data to be deleted';
+$string['addroleoverride'] = 'Add role override';
+$string['roleoverride'] = 'Role override';
+$string['role'] = 'Role';
+$string['role_help'] = 'Which role do you wish to apply this override to';
+$string['duplicaterole'] = 'Role already specified';
+$string['purposeoverview'] = 'A purpose describes the intended use and retention policy for stored data. The basis for storing and retaining that data is also described in the purpose.';
+$string['roleoverrideoverview'] = 'The default retention policy can be overridden for specific user roles, allowing you to specify a longer, or a shorter, retention policy. A user is only expired when all of their roles have expired.';
index 6408530..474d4a6 100644 (file)
 }}
 
 {{#navigation}}
-    {{> core/action_link}}
+    <div class="m-b-1">
+        {{> core/action_link}}
+    </div>
 {{/navigation}}
 
-<div data-region="purposes" class="m-t-3 m-b-1">
+<p>
+  {{#str}}purposeoverview, tool_dataprivacy{{/str}}
+</p>
+
+<div data-region="purposes" class="m-b-1">
     <div class="m-y-1">
         <button class="btn btn-secondary" data-add-element="purpose" title="{{#str}}addpurpose, tool_dataprivacy{{/str}}">
             {{#pix}}t/add, moodle, {{#str}}addpurpose, tool_dataprivacy{{/str}}{{/pix}}
@@ -72,6 +78,7 @@
                 <th scope="col">{{#str}}sensitivedatareasons, tool_dataprivacy{{/str}}</th>
                 <th scope="col">{{#str}}retentionperiod, tool_dataprivacy{{/str}}</th>
                 <th scope="col">{{#str}}protected, tool_dataprivacy{{/str}}</th>
+                <th scope="col">{{#str}}roleoverrides, tool_dataprivacy{{/str}}</th>
                 <th scope="col">{{#str}}actions{{/str}}</th>
             </tr>
         </thead>
                         {{#str}}no{{/str}}
                     {{/protected}}
                 </td>
+                <td>
+                    {{#roleoverrides}}
+                        {{#str}}yes{{/str}}
+                    {{/roleoverrides}}
+                    {{^roleoverrides}}
+                        {{#str}}no{{/str}}
+                    {{/roleoverrides}}
+                </td>
                 <td>
                     {{#actions}}
                         {{> core/action_menu}}
index 3ae2f1f..a290b27 100644 (file)
 
 use core\invalid_persistent_exception;
 use core\task\manager;
+use tool_dataprivacy\contextlist_context;
 use tool_dataprivacy\context_instance;
 use tool_dataprivacy\api;
 use tool_dataprivacy\data_registry;
 use tool_dataprivacy\expired_context;
 use tool_dataprivacy\data_request;
+use tool_dataprivacy\purpose;
+use tool_dataprivacy\category;
 use tool_dataprivacy\local\helper;
 use tool_dataprivacy\task\initiate_data_request_task;
 use tool_dataprivacy\task\process_data_request_task;
@@ -1376,6 +1379,228 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->assertEquals($data->contexts->used, $contextids, '', 0.0, 10, true);
     }
 
+    /**
+     * Test that delete requests do not filter out protected purpose contexts if they are already expired.
+     */
+    public function test_add_request_contexts_with_status_delete_course_expired_protected() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+        $purposes->course->set('protected', 1)->save();
+
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - YEARSECS]);
+        $coursecontext = \context_course::instance($course->id);
+
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        $collection = new \core_privacy\local\request\contextlist_collection($user->id);
+        $contextlist = new \core_privacy\local\request\contextlist();
+        $contextlist->set_component('tool_dataprivacy');
+        $contextlist->add_from_sql('SELECT id FROM {context} WHERE id IN(:ctx1)', ['ctx1' => $coursecontext->id]);
+        $collection->add_contextlist($contextlist);
+
+        $request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE);
+
+        $purposes->course->set('protected', 1)->save();
+        api::add_request_contexts_with_status($collection, $request->get('id'), contextlist_context::STATUS_APPROVED);
+
+        $requests = contextlist_context::get_records();
+        $this->assertCount(1, $requests);
+    }
+
+    /**
+     * Test that delete requests does filter out protected purpose contexts which are not expired.
+     */
+    public function test_add_request_contexts_with_status_delete_course_unexpired_protected() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1Y');
+        $purposes->course->set('protected', 1)->save();
+
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]);
+        $coursecontext = \context_course::instance($course->id);
+
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        $collection = new \core_privacy\local\request\contextlist_collection($user->id);
+        $contextlist = new \core_privacy\local\request\contextlist();
+        $contextlist->set_component('tool_dataprivacy');
+        $contextlist->add_from_sql('SELECT id FROM {context} WHERE id IN(:ctx1)', ['ctx1' => $coursecontext->id]);
+        $collection->add_contextlist($contextlist);
+
+        $request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE);
+
+        $purposes->course->set('protected', 1)->save();
+        api::add_request_contexts_with_status($collection, $request->get('id'), contextlist_context::STATUS_APPROVED);
+
+        $requests = contextlist_context::get_records();
+        $this->assertCount(0, $requests);
+    }
+
+    /**
+     * Test that delete requests do not filter out unexpired contexts if they are not protected.
+     */
+    public function test_add_request_contexts_with_status_delete_course_unexpired_unprotected() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1Y');
+        $purposes->course->set('protected', 1)->save();
+
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]);
+        $coursecontext = \context_course::instance($course->id);
+
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        $collection = new \core_privacy\local\request\contextlist_collection($user->id);
+        $contextlist = new \core_privacy\local\request\contextlist();
+        $contextlist->set_component('tool_dataprivacy');
+        $contextlist->add_from_sql('SELECT id FROM {context} WHERE id IN(:ctx1)', ['ctx1' => $coursecontext->id]);
+        $collection->add_contextlist($contextlist);
+
+        $request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE);
+
+        $purposes->course->set('protected', 0)->save();
+        api::add_request_contexts_with_status($collection, $request->get('id'), contextlist_context::STATUS_APPROVED);
+
+        $requests = contextlist_context::get_records();
+        $this->assertCount(1, $requests);
+    }
+
+    /**
+     * Test that delete requests do not filter out protected purpose contexts if they are already expired.
+     */
+    public function test_get_approved_contextlist_collection_for_request_delete_course_expired_protected() {
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+        $purposes->course->set('protected', 1)->save();
+
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - YEARSECS]);
+        $coursecontext = \context_course::instance($course->id);
+
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        // Create the request, with its contextlist and context.
+        $request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE);
+        $contextlist = new \tool_dataprivacy\contextlist(0, (object) ['component' => 'tool_dataprivacy']);
+        $contextlist->save();
+
+        $clcontext = new \tool_dataprivacy\contextlist_context(0, (object) [
+                'contextid' => $coursecontext->id,
+                'status' => contextlist_context::STATUS_APPROVED,
+                'contextlistid' => $contextlist->get('id'),
+            ]);
+        $clcontext->save();
+
+        $rcl = new \tool_dataprivacy\request_contextlist(0, (object) [
+                'requestid' => $request->get('id'),
+                'contextlistid' => $contextlist->get('id'),
+            ]);
+        $rcl->save();
+
+        $purposes->course->set('protected', 1)->save();
+        $collection = api::get_approved_contextlist_collection_for_request($request);
+
+        $this->assertCount(1, $collection);
+
+        $list = $collection->get_contextlist_for_component('tool_dataprivacy');
+        $this->assertCount(1, $list);
+    }
+
+    /**
+     * Test that delete requests does filter out protected purpose contexts which are not expired.
+     */
+    public function test_get_approved_contextlist_collection_for_request_delete_course_unexpired_protected() {
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1Y');
+        $purposes->course->set('protected', 1)->save();
+
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]);
+        $coursecontext = \context_course::instance($course->id);
+
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        // Create the request, with its contextlist and context.
+        $request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE);
+        $contextlist = new \tool_dataprivacy\contextlist(0, (object) ['component' => 'tool_dataprivacy']);
+        $contextlist->save();
+
+        $clcontext = new \tool_dataprivacy\contextlist_context(0, (object) [
+                'contextid' => $coursecontext->id,
+                'status' => contextlist_context::STATUS_APPROVED,
+                'contextlistid' => $contextlist->get('id'),
+            ]);
+        $clcontext->save();
+
+        $rcl = new \tool_dataprivacy\request_contextlist(0, (object) [
+                'requestid' => $request->get('id'),
+                'contextlistid' => $contextlist->get('id'),
+            ]);
+        $rcl->save();
+
+        $purposes->course->set('protected', 1)->save();
+        $collection = api::get_approved_contextlist_collection_for_request($request);
+
+        $this->assertCount(0, $collection);
+
+        $list = $collection->get_contextlist_for_component('tool_dataprivacy');
+        $this->assertEmpty($list);
+    }
+
+    /**
+     * Test that delete requests do not filter out unexpired contexts if they are not protected.
+     */
+    public function test_get_approved_contextlist_collection_for_request_delete_course_unexpired_unprotected() {
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1Y');
+        $purposes->course->set('protected', 1)->save();
+
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]);
+        $coursecontext = \context_course::instance($course->id);
+
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        // Create the request, with its contextlist and context.
+        $request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE);
+        $contextlist = new \tool_dataprivacy\contextlist(0, (object) ['component' => 'tool_dataprivacy']);
+        $contextlist->save();
+
+        $clcontext = new \tool_dataprivacy\contextlist_context(0, (object) [
+                'contextid' => $coursecontext->id,
+                'status' => contextlist_context::STATUS_APPROVED,
+                'contextlistid' => $contextlist->get('id'),
+            ]);
+        $clcontext->save();
+
+        $rcl = new \tool_dataprivacy\request_contextlist(0, (object) [
+                'requestid' => $request->get('id'),
+                'contextlistid' => $contextlist->get('id'),
+            ]);
+        $rcl->save();
+
+        $purposes->course->set('protected', 0)->save();
+        $collection = api::get_approved_contextlist_collection_for_request($request);
+
+        $this->assertCount(1, $collection);
+
+        $list = $collection->get_contextlist_for_component('tool_dataprivacy');
+        $this->assertCount(1, $list);
+    }
+
     /**
      * Data provider for \tool_dataprivacy_api_testcase::test_set_context_defaults
      */
@@ -1635,4 +1860,66 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
             'list' => $approvedcollection->get_contextlist_for_component('tool_dataprivacy'),
         ];
     }
+
+    /**
+     * 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.
+     */
+    protected function setup_basics(string $system, string $user, string $course = null, string $activity = null) : \stdClass {
+        $this->resetAfterTest();
+
+        $purposes = (object) [
+            'system' => $this->create_and_set_purpose_for_contextlevel($system, CONTEXT_SYSTEM),
+            'user' => $this->create_and_set_purpose_for_contextlevel($user, CONTEXT_USER),
+        ];
+
+        if (null !== $course) {
+            $purposes->course = $this->create_and_set_purpose_for_contextlevel($course, CONTEXT_COURSE);
+        }
+
+        if (null !== $activity) {
+            $purposes->activity = $this->create_and_set_purpose_for_contextlevel($activity, CONTEXT_MODULE);
+        }
+
+        return $purposes;
+    }
+
+    /**
+     * Create a retention period and set it for the specified context level.
+     *
+     * @param   string  $retention
+     * @param   int     $contextlevel
+     * @return  purpose
+     */
+    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();
+
+        $cat = new category(0, (object) ['name' => 'Test category']);
+        $cat->create();
+
+        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;
+    }
 }
index b236d0a..c6c5908 100644 (file)
@@ -26,7 +26,7 @@ Feature: Manage data storage purposes
     And I should see "Legal obligation (GDPR Art 6.1(c))" in the "Purpose 1" "table_row"
     And I should see "Explicit consent (GDPR Art. 9.2(a))" in the "Purpose 1" "table_row"
     And I should see "2 years" in the "Purpose 1" "table_row"
-    And I should see "No" in the "Purpose 1" "table_row"
+    And "Purpose 1 Purpose 1 description" row "5" column of "List of data purposes" table should contain "No"
 
   Scenario: Update a data storage purpose
     Given I click on "Actions" "link" in the "Purpose 1" "table_row"
@@ -45,7 +45,7 @@ Feature: Manage data storage purposes
     And I should see "Vital interests (GDPR Art. 6.1(d))" in the "Purpose 1 edited" "table_row"
     And I should see "3 years" in the "Purpose 1 edited" "table_row"
     But I should not see "Legal obligation (GDPR Art 6.1(c))" in the "Purpose 1 edited" "table_row"
-    And I should not see "No" in the "Purpose 1 edited" "table_row"
+    And "Purpose 1 edited Purpose 1 description edited" row "5" column of "List of data purposes" table should not contain "No"
 
   Scenario: Delete a data storage purpose
     Given I click on "Actions" "link" in the "Purpose 1" "table_row"
index 37cb86b..6058ac5 100644 (file)
@@ -26,8 +26,10 @@ 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;
+use tool_dataprivacy\expired_contexts_manager;
 
 defined('MOODLE_INTERNAL') || die();
 global $CFG;
@@ -49,15 +51,20 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
      * @param   string  $course Retention policy for courses.
      * @param   string  $activity Retention policy for activities.
      */
-    protected function setup_basics(string $system, string $user, string $course, string $activity = null) : array {
+    protected function setup_basics(string $system, string $user, string $course = null, string $activity = null) : \stdClass {
         $this->resetAfterTest();
 
-        $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);
+        $purposes = (object) [
+            'system' => $this->create_and_set_purpose_for_contextlevel($system, CONTEXT_SYSTEM),
+            'user' => $this->create_and_set_purpose_for_contextlevel($user, CONTEXT_USER),
+        ];
+
+        if (null !== $course) {
+            $purposes->course = $this->create_and_set_purpose_for_contextlevel($course, CONTEXT_COURSE);
+        }
+
         if (null !== $activity) {
-            $purposes[] = $this->create_and_set_purpose_for_contextlevel($activity, CONTEXT_MODULE);
+            $purposes->activity = $this->create_and_set_purpose_for_contextlevel($activity, CONTEXT_MODULE);
         }
 
         return $purposes;
@@ -217,6 +224,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 +284,40 @@ 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');
+
+        $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' => $purposes->user->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.
      */
@@ -377,7 +450,7 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
     public function test_flag_user_retention_changed() {
         $this->resetAfterTest();
 
-        list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
 
         $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]);
         $usercontext = \context_user::instance($user->id);
@@ -398,8 +471,8 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
         $this->assertNotFalse($expiredcontext);
 
         // Increase the retention period to 5 years.
-        $userpurpose->set('retentionperiod', 'P5Y');
-        $userpurpose->save();
+        $purposes->user->set('retentionperiod', 'P5Y');
+        $purposes->user->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();
@@ -417,7 +490,7 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
     public function test_flag_user_historic_block_unapproved() {
         $this->resetAfterTest();
 
-        list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H');
 
         $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]);
         $usercontext = \context_user::instance($user->id);
@@ -454,8 +527,8 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
     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);
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+        $this->create_and_set_purpose_for_contextlevel('P5Y', CONTEXT_BLOCK);
 
         $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]);
         $usercontext = \context_user::instance($user->id);
@@ -559,49 +632,615 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
             ]);
         $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();
+        // 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 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');
+
+        $role = $DB->get_record('role', ['shortname' => 'editingteacher']);
+
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $purposes->course->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');
+
+        $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' => $purposes->course->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');
+
+        $role = $DB->get_record('role', ['shortname' => 'editingteacher']);
+
+        (new purpose_override(0, (object) [
+                'purposeid' => $purposes->course->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');
+
+        $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' => $purposes->user->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');
+
+        $role = $DB->get_record('role', ['shortname' => 'editingteacher']);
+
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $purposes->course->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');
+
+        $role = $DB->get_record('role', ['shortname' => 'student']);
+
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $purposes->course->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');
+
+        $role = $DB->get_record('role', ['shortname' => 'student']);
+
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $purposes->course->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);
 
-        $this->assertEquals(4, $flaggedcourses);
-        $this->assertEquals(0, $flaggedusers);
+        $updatedcontext = new expired_context($expiredcontext->get('id'));
+        $this->assertEquals(expired_context::STATUS_CLEANED, $updatedcontext->get('status'));
     }
 
     /**
-     * Ensure that a course with an end date in the future is not flagged.
+     * Ensure that a module context previously flagged as approved is removed with appropriate expiredroles kept.
      */
-    public function test_flag_course_future_enddate() {
+    public function test_process_course_context_with_user_in_both_lists_expired() {
+        global $DB;
         $this->resetAfterTest();
 
-        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'P5Y');
 
-        $course = $this->getDataGenerator()->create_course(['enddate' => time() + YEARSECS]);
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $purposes->course->get('id'),
+                'roleid' => $studentrole->id,
+                'retentionperiod' => 'PT1M',
+            ]);
+        $override->save();
+
+        $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']);
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $purposes->course->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,
+        ]);
 
-        // Flag all expired contexts.
-        $manager = new \tool_dataprivacy\expired_contexts_manager();
-        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+        $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,
+        ]);
 
-        $this->assertEquals(0, $flaggedcourses);
-        $this->assertEquals(0, $flaggedusers);
-    }
+        // 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();
 
-    /**
-     * Ensure that a course with an end date in the future is not flagged.
-     */
-    public function test_flag_course_recent_unexpired_enddate() {
-        $this->resetAfterTest();
+        $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;
+            }));
 
-        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
 
-        $course = $this->getDataGenerator()->create_course(['enddate' => time() - 1]);
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        $manager->set_progress(new \null_progress_trace());
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
 
-        // Flag all expired contexts.
-        $manager = new \tool_dataprivacy\expired_contexts_manager();
-        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+        $this->assertEquals(1, $processedcourses);
+        $this->assertEquals(0, $processedusers);
 
-        $this->assertEquals(0, $flaggedcourses);
-        $this->assertEquals(0, $flaggedusers);
+        $updatedcontext = new expired_context($expiredcontext->get('id'));
+        $this->assertEquals(expired_context::STATUS_CLEANED, $updatedcontext->get('status'));
     }
 
     /**
@@ -936,7 +1575,7 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
     public function test_process_user_historic_block_unapproved() {
         $this->resetAfterTest();
 
-        list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H');
 
         $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]);
         $usercontext = \context_user::instance($user->id);
@@ -995,8 +1634,8 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
     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);
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+        $this->create_and_set_purpose_for_contextlevel('P5Y', CONTEXT_BLOCK);
 
         $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]);
         $usercontext = \context_user::instance($user->id);
@@ -1080,9 +1719,8 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
         $manager->set_progress(new \null_progress_trace());
         $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
 
-        $coursepurpose = $purposes[2];
-        $coursepurpose->set('retentionperiod', 'P5Y');
-        $coursepurpose->save();
+        $purposes->course->set('retentionperiod', 'P5Y');
+        $purposes->course->save();
 
         list($processedcourses, $processedusers) = $manager->process_approved_deletions();
 
@@ -1354,6 +1992,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.
      */
@@ -1497,4 +2181,387 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
 
         return $block;
     }
+
+    /**
+     * Test the is_context_expired functions when supplied with the system context.
+     */
+    public function test_is_context_expired_system() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setup_basics('PT1H', 'PT1H', 'P1D');
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+
+        $this->assertFalse(expired_contexts_manager::is_context_expired(\context_system::instance()));
+        $this->assertFalse(
+                expired_contexts_manager::is_context_expired_or_unprotected_for_user(\context_system::instance(), $user));
+    }
+
+    /**
+     * Test the is_context_expired functions when supplied with an expired course.
+     */
+    public function test_is_context_expired_course_expired() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1D');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]);
+        $coursecontext = \context_course::instance($course->id);
+
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        $this->assertFalse(expired_contexts_manager::is_context_expired($coursecontext));
+
+        $purposes->course->set('protected', 1)->save();
+        $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user));
+
+        $purposes->course->set('protected', 0)->save();
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user));
+    }
+
+    /**
+     * Test the is_context_expired functions when supplied with an unexpired course.
+     */
+    public function test_is_context_expired_course_unexpired() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1D');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - WEEKSECS]);
+        $coursecontext = \context_course::instance($course->id);
+
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        $this->assertTrue(expired_contexts_manager::is_context_expired($coursecontext));
+
+        $purposes->course->set('protected', 1)->save();
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user));
+
+        $purposes->course->set('protected', 0)->save();
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user));
+    }
+
+    /**
+     * Test the is_context_expired functions when supplied with an expired course which has role overrides.
+     */
+    public function test_is_context_expired_course_expired_override() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - WEEKSECS]);
+        $coursecontext = \context_course::instance($course->id);
+        $systemcontext = \context_system::instance();
+
+        $role = $DB->get_record('role', ['shortname' => 'manager']);
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $purposes->course->get('id'),
+                'roleid' => $role->id,
+                'retentionperiod' => 'P5Y',
+            ]);
+        $override->save();
+        role_assign($role->id, $user->id, $systemcontext->id);
+
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        $this->assertFalse(expired_contexts_manager::is_context_expired($coursecontext));
+
+        $purposes->course->set('protected', 1)->save();
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user));
+
+        $purposes->course->set('protected', 0)->save();
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user));
+    }
+
+    /**
+     * Test the is_context_expired functions when supplied with an expired course which has role overrides.
+     */
+    public function test_is_context_expired_course_expired_override_parent() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - WEEKSECS]);
+        $coursecontext = \context_course::instance($course->id);
+        $systemcontext = \context_system::instance();
+
+        $role = $DB->get_record('role', ['shortname' => 'manager']);
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $purposes->system->get('id'),
+                'roleid' => $role->id,
+                'retentionperiod' => 'P5Y',
+            ]);
+        $override->save();
+        role_assign($role->id, $user->id, $systemcontext->id);
+
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        $this->assertFalse(expired_contexts_manager::is_context_expired($coursecontext));
+
+        // The user override applies to this user. THIs means that the default expiry has no effect.
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user));
+
+        $purposes->system->set('protected', 1)->save();
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user));
+
+        $purposes->system->set('protected', 0)->save();
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user));
+
+        $override->set('protected', 1)->save();
+        $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user));
+
+        $purposes->system->set('protected', 1)->save();
+        $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user));
+
+        $purposes->system->set('protected', 0)->save();
+        $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user));
+
+    }
+
+    /**
+     * Test the is_context_expired functions when supplied with an expired course which has role overrides but the user
+     * does not hold the role.
+     */
+    public function test_is_context_expired_course_expired_override_parent_no_role() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - WEEKSECS]);
+        $coursecontext = \context_course::instance($course->id);
+        $systemcontext = \context_system::instance();
+
+        $role = $DB->get_record('role', ['shortname' => 'manager']);
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $purposes->system->get('id'),
+                'roleid' => $role->id,
+                'retentionperiod' => 'P5Y',
+            ]);
+        $override->save();
+
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        // This context is not _fully _ expired.
+        $this->assertFalse(expired_contexts_manager::is_context_expired($coursecontext));
+    }
+
+    /**
+     * Test the is_context_expired functions when supplied with an unexpired course which has role overrides.
+     */
+    public function test_is_context_expired_course_expired_override_inverse() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('P1Y', 'P1Y');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - WEEKSECS]);
+        $coursecontext = \context_course::instance($course->id);
+        $systemcontext = \context_system::instance();
+
+        $role = $DB->get_record('role', ['shortname' => 'student']);
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $purposes->system->get('id'),
+                'roleid' => $role->id,
+                'retentionperiod' => 'PT1S',
+            ]);
+        $override->save();
+
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        // This context is not _fully _ expired.
+        $this->assertFalse(expired_contexts_manager::is_context_expired($coursecontext));
+    }
+
+    /**
+     * Test the is_context_expired functions when supplied with an unexpired course which has role overrides.
+     */
+    public function test_is_context_expired_course_expired_override_inverse_parent() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('P1Y', 'P1Y');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - WEEKSECS]);
+        $coursecontext = \context_course::instance($course->id);
+        $systemcontext = \context_system::instance();
+
+        $role = $DB->get_record('role', ['shortname' => 'manager']);
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $purposes->system->get('id'),
+                'roleid' => $role->id,
+                'retentionperiod' => 'PT1S',
+            ]);
+        $override->save();
+
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+        role_assign($role->id, $user->id, $systemcontext->id);
+
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+        role_unassign($studentrole->id, $user->id, $coursecontext->id);
+
+        // This context is not _fully _ expired.
+        $this->assertFalse(expired_contexts_manager::is_context_expired($coursecontext));
+    }
+
+    /**
+     * Test the is_context_expired functions when supplied with an unexpired course which has role overrides.
+     */
+    public function test_is_context_expired_course_expired_override_inverse_parent_not_assigned() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('P1Y', 'P1Y');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - WEEKSECS]);
+        $coursecontext = \context_course::instance($course->id);
+        $systemcontext = \context_system::instance();
+
+        $role = $DB->get_record('role', ['shortname' => 'manager']);
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $purposes->system->get('id'),
+                'roleid' => $role->id,
+                'retentionperiod' => 'PT1S',
+            ]);
+        $override->save();
+
+        // Enrol the user in the course without any role.
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+        role_unassign($studentrole->id, $user->id, $coursecontext->id);
+
+        // This context is not _fully _ expired.
+        $this->assertFalse(expired_contexts_manager::is_context_expired($coursecontext));
+    }
+
+    /**
+     * Ensure that context expired checks for a specific user taken into account roles.
+     */
+    public function test_is_context_expired_or_unprotected_for_user_role_mixtures_protected() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1S', 'PT1S', 'PT1S');
+
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - DAYSECS]);
+        $coursecontext = \context_course::instance($course->id);
+        $systemcontext = \context_system::instance();
+
+        $roles = $DB->get_records_menu('role', [], 'id', 'shortname, id');
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $purposes->course->get('id'),
+                'roleid' => $roles['manager'],
+                'retentionperiod' => 'P1W',
+                'protected' => 1,
+            ]);
+        $override->save();
+
+        $s = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($s->id, $course->id, 'student');
+
+        $t = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($t->id, $course->id, 'teacher');
+
+        $sm = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($sm->id, $course->id, 'student');
+        role_assign($roles['manager'], $sm->id, $coursecontext->id);
+
+        $m = $this->getDataGenerator()->create_user();
+        role_assign($roles['manager'], $m->id, $coursecontext->id);
+
+        $tm = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($t->id, $course->id, 'teacher');
+        role_assign($roles['manager'], $tm->id, $coursecontext->id);
+
+        // The context should only be expired for users who are not a manager.
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $s));
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $t));
+        $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $sm));
+        $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $tm));
+        $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $m));
+
+        $override->set('protected', 0)->save();
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $s));
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $t));
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $sm));
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $tm));
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $m));
+    }
+
+    /**
+     * Ensure that context expired checks for a specific user taken into account roles when retention is inversed.
+     */
+    public function test_is_context_expired_or_unprotected_for_user_role_mixtures_protected_inverse() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('P5Y', 'P5Y', 'P5Y');
+
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - DAYSECS]);
+        $coursecontext = \context_course::instance($course->id);
+        $systemcontext = \context_system::instance();
+
+        $roles = $DB->get_records_menu('role', [], 'id', 'shortname, id');
+        $override = new purpose_override(0, (object) [
+                'purposeid' => $purposes->course->get('id'),
+                'roleid' => $roles['student'],
+                'retentionperiod' => 'PT1S',
+            ]);
+        $override->save();
+
+        $s = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($s->id, $course->id, 'student');
+
+        $t = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($t->id, $course->id, 'teacher');
+
+        $sm = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($sm->id, $course->id, 'student');
+        role_assign($roles['manager'], $sm->id, $coursecontext->id);
+
+        $m = $this->getDataGenerator()->create_user();
+        role_assign($roles['manager'], $m->id, $coursecontext->id);
+
+        $tm = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($t->id, $course->id, 'teacher');
+        role_assign($roles['manager'], $tm->id, $coursecontext->id);
+
+        // The context should only be expired for users who are only a student.
+        $purposes->course->set('protected', 1)->save();
+        $override->set('protected', 1)->save();
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $s));
+        $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $t));
+        $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $sm));
+        $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $tm));
+        $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $m));
+
+        $purposes->course->set('protected', 0)->save();
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $s));
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $t));
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $sm));
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $tm));
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $m));
+    }
 }
diff --git a/admin/tool/dataprivacy/tests/filtered_userlist_test.php b/admin/tool/dataprivacy/tests/filtered_userlist_test.php
new file mode 100644 (file)
index 0000000..cdffded
--- /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/>.
+
+/**
+ * Unit tests for the filtered_userlist.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Unit tests for the filtered_userlist.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_dataprivacy_filtered_userlist_testcase extends advanced_testcase {
+    /**
+     * Test the apply_expired_contexts_filters function with arange of options.
+     *
+     * @dataProvider apply_expired_contexts_filters_provider
+     * @param   array   $initial The set of userids in the initial filterlist.
+     * @param   array   $expired The set of userids considered as expired.
+     * @param   array   $unexpired The set of userids considered as unexpired.
+     * @param   array   $expected The expected values.
+     */
+    public function test_apply_expired_contexts_filters(array $initial, array $expired, array $unexpired, array $expected) {
+        $userlist = $this->getMockBuilder(\tool_dataprivacy\filtered_userlist::class)
+            ->disableOriginalConstructor()
+            ->setMethods(null)
+            ->getMock();
+
+        $rc = new \ReflectionClass(\tool_dataprivacy\filtered_userlist::class);
+        $rcm = $rc->getMethod('set_userids');
+        $rcm->setAccessible(true);
+        $rcm->invoke($userlist, $initial);
+
+
+        $userlist->apply_expired_context_filters($expired, $unexpired);
+        $filtered = $userlist->get_userids();
+
+        sort($expected);
+        sort($filtered);
+        $this->assertEquals($expected, $filtered);
+    }
+
+    /**
+     * Data provider for the apply_expired_contexts_filters function.
+     *
+     * @return  array
+     */
+    public function apply_expired_contexts_filters_provider() : array {
+        return [
+            // Entire list should be preserved.
+            'No overrides' => [
+                'users' => [1, 2, 3, 4, 5],
+                'expired' => [],
+                'unexpired' => [],
+                [1, 2, 3, 4, 5],
+            ],
+            // The list should be filtered to only keep the expired users.
+            'Expired only' => [
+                'users' => [1, 2, 3, 4, 5],
+                'expired' => [2, 3, 4],
+                'unexpired' => [],
+                'expected' => [2, 3, 4],
+            ],
+            // The list should be filtered to remove any unexpired users.
+            'Unexpired only' => [
+                'users' => [1, 2, 3, 4, 5],
+                'expired' => [],
+                'unexpired' => [1, 5],
+                'expected' => [2, 3, 4],
+            ],
+            // The list should be filtered to only keep expired users who are not on the unexpired list.
+            'Combination of expired and unexpired' => [
+                'users' => [1, 2, 3, 4, 5],
+                'expired' => [1, 2, 3],
+                'unexpired' => [1, 5],
+                'expected' => [2, 3],
+            ],
+        ];
+    }
+}
index ad971e5..3efc437 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die;
 
-$plugin->version   = 2018092500;
+$plugin->version   = 2018100403;
 $plugin->requires  = 2018050800;        // Moodle 3.5dev (Build 2018031600) and upwards.
 $plugin->component = 'tool_dataprivacy';
index c29c0a1..54c61a1 100644 (file)
  */
 namespace tool_mobile\privacy;
 defined('MOODLE_INTERNAL') || die();
-use \core_privacy\local\request\writer;
-use \core_privacy\local\metadata\collection;
-use \core_privacy\local\request\contextlist;
-use \core_privacy\local\request\approved_contextlist;
-use \core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\approved_userlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\userlist;
 
 /**
  * Privacy provider for tool_mobile.
@@ -36,6 +38,7 @@ use \core_privacy\local\request\transform;
  */
 class provider implements
     \core_privacy\local\metadata\provider,
+    \core_privacy\local\request\core_userlist_provider,
     \core_privacy\local\request\user_preference_provider,
     \core_privacy\local\request\plugin\provider {
     /**
@@ -70,6 +73,23 @@ class provider implements
 
         return $contextlist;
     }
+
+    /**
+     * Get the list of users who have data within a context.
+     *
+     * @param   userlist    $userlist   The userlist containing the list of users who have data in this context/plugin combination.
+     */
+    public static function get_users_in_context(userlist $userlist) {
+        $context = $userlist->get_context();
+
+        if (!is_a($context, \context_user::class)) {
+            return;
+        }
+
+        // Add users based on userkey.
+        \core_userkey\privacy\provider::get_user_contexts_with_script($userlist, $context, 'tool_mobile');
+    }
+
     /**
      * Export all user data for the specified user, in the specified contexts.
      *
@@ -139,4 +159,24 @@ class provider implements
         // Delete all the userkeys.
         \core_userkey\privacy\provider::delete_userkeys('tool_mobile', $userid);
     }
-}
\ No newline at end of file
+
+    /**
+     * Delete multiple users within a single context.
+     *
+     * @param   approved_userlist       $userlist The approved context and user information to delete information for.
+     */
+    public static function delete_data_for_users(approved_userlist $userlist) {
+        global $DB;
+        $context = $userlist->get_context();
+        $userids = $userlist->get_userids();
+        $userid = reset($userids);
+
+        // Only deleting data for the user ID in that user's user context should be valid.
+        if ($context->contextlevel !== CONTEXT_USER || count($userids) != 1 || $userid != $context->instanceid) {
+            return;
+        }
+
+        // Delete all the userkeys.
+        \core_userkey\privacy\provider::delete_userkeys('tool_mobile', $userid);
+    }
+}
index d553a10..6e1da9c 100644 (file)
  */
 defined('MOODLE_INTERNAL') || die();
 
-use \core_privacy\local\request\writer;
-use \core_privacy\local\request\transform;
-use \core_privacy\local\request\approved_contextlist;
-use \tool_mobile\privacy\provider;
+use core_privacy\local\request\writer;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\approved_userlist;
+use tool_mobile\privacy\provider;
 
 /**
  * Unit tests for the tool_mobile implementation of the privacy API.
@@ -59,6 +60,7 @@ class tool_mobile_privacy_testcase extends \core_privacy\tests\provider_testcase
         $this->assertEquals(get_string('privacy:metadata:preference:tool_mobile_autologin_request_last', 'tool_mobile'),
             $prefs->tool_mobile_autologin_request_last->description);
     }
+
     /**
      * Test getting the context for the user ID related to this plugin.
      */
@@ -70,6 +72,31 @@ class tool_mobile_privacy_testcase extends \core_privacy\tests\provider_testcase
         $contextlist = provider::get_contexts_for_userid($user->id);
         $this->assertEquals($context->id, $contextlist->current()->id);
     }
+
+    /**
+     * Test getting the users for a context related to this plugin.
+     */
+    public function test_get_users_in_context() {
+        $component = 'tool_mobile';
+
+        // Create users and Mobile user keys.
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $context1 = \context_user::instance($user1->id);
+        $context2 = \context_user::instance($user2->id);
+        $key1 = get_user_key('tool_mobile', $user1->id);
+        $key2 = get_user_key('tool_mobile', $user2->id);
+
+        // Ensure only user1 is found in context1.
+        $userlist = new \core_privacy\local\request\userlist($context1, $component);
+        provider::get_users_in_context($userlist);
+        $userids = $userlist->get_userids();
+        $userid = reset($userids);
+
+        $this->assertCount(1, $userids);
+        $this->assertEquals($user1->id, $userid);
+    }
+
     /**
      * Test that data is exported correctly for this plugin.
      */
@@ -89,6 +116,7 @@ class tool_mobile_privacy_testcase extends \core_privacy\tests\provider_testcase
         $this->assertCount(1, $userkeydata->keys);
         $this->assertEquals($key->script, reset($userkeydata->keys)->script);
     }
+
     /**
      * Test for provider::delete_data_for_all_users_in_context().
      */
@@ -108,6 +136,7 @@ class tool_mobile_privacy_testcase extends \core_privacy\tests\provider_testcase
         $count = $DB->count_records('user_private_key', ['script' => 'tool_mobile']);
         $this->assertEquals(0, $count);
     }
+
     /**
      * Test for provider::delete_data_for_user().
      */
@@ -129,4 +158,46 @@ class tool_mobile_privacy_testcase extends \core_privacy\tests\provider_testcase
         $count = $DB->count_records('user_private_key', ['script' => 'tool_mobile']);
         $this->assertEquals(0, $count);
     }
-}
\ No newline at end of file
+
+    /**
+     * Test for provider::test_delete_data_for_users().
+     */
+    public function test_delete_data_for_users() {
+        global $DB;
+        $component = 'tool_mobile';
+
+        // Create users and Mobile user keys.
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $context1 = \context_user::instance($user1->id);
+        $context2 = \context_user::instance($user2->id);
+        $keyvalue1 = get_user_key('tool_mobile', $user1->id);
+        $keyvalue2 = get_user_key('tool_mobile', $user2->id);
+        $key1 = $DB->get_record('user_private_key', ['value' => $keyvalue1]);
+
+        // Before deletion, we should have 2 user_private_keys.
+        $count = $DB->count_records('user_private_key', ['script' => 'tool_mobile']);
+        $this->assertEquals(2, $count);
+
+        // Ensure deleting wrong user in the user context does nothing.
+        $approveduserids = [$user2->id];
+        $approvedlist = new approved_userlist($context1, $component, $approveduserids);
+        provider::delete_data_for_users($approvedlist);
+
+        $count = $DB->count_records('user_private_key', ['script' => 'tool_mobile']);
+        $this->assertEquals(2, $count);
+
+        // Delete for user1 in context1.
+        $approveduserids = [$user1->id];
+        $approvedlist = new approved_userlist($context1, $component, $approveduserids);
+        provider::delete_data_for_users($approvedlist);
+
+        // Ensure only user1's data is deleted, user2's remains.
+        $count = $DB->count_records('user_private_key', ['script' => 'tool_mobile']);
+        $this->assertEquals(1, $count);
+
+        $params = ['script' => $component];
+        $userid = $DB->get_field_select('user_private_key', 'userid', 'script = :script', $params);
+        $this->assertEquals($user2->id, $userid);
+    }
+}
index b2174ef..d8ca913 100644 (file)
@@ -27,7 +27,9 @@ defined('MOODLE_INTERNAL') || die();
 use \core_privacy\local\metadata\collection;
 use \core_privacy\local\request\contextlist;
 use \core_privacy\local\request\approved_contextlist;
+use \core_privacy\local\request\approved_userlist;
 use \core_privacy\local\request\transform;
+use \core_privacy\local\request\userlist;
 use \core_privacy\local\request\writer;
 use \tool_monitor\subscription_manager;
 use \tool_monitor\rule_manager;
@@ -39,7 +41,10 @@ use \tool_monitor\rule_manager;
  * @copyright  2018 Adrian Greeve <adrian@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\plugin\provider {
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \core_privacy\local\request\core_userlist_provider,
+        \core_privacy\local\request\plugin\provider {
 
     /**
      * Get information about the user data stored by this plugin.
@@ -101,6 +106,40 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
         return $contextlist;
     }
 
+    /**
+     * Get the list of users who have data within a context.
+     *
+     * @param   userlist    $userlist   The userlist containing the list of users who have data in this context/plugin combination.
+     */
+    public static function get_users_in_context(userlist $userlist) {
+        $context = $userlist->get_context();
+
+        if (!is_a($context, \context_user::class)) {
+            return;
+        }
+
+        $params = [
+            'contextid' => $context->id,
+            'contextuser' => CONTEXT_USER,
+        ];
+
+        $sql = "SELECT mr.userid
+                  FROM {context} ctx
+                  JOIN {tool_monitor_rules} mr ON ctx.instanceid = mr.userid
+                       AND ctx.contextlevel = :contextuser
+                 WHERE ctx.id = :contextid";
+
+        $userlist->add_from_sql('userid', $sql, $params);
+
+        $sql = "SELECT ms.userid
+                  FROM {context} ctx
+             LEFT JOIN {tool_monitor_subscriptions} ms ON ctx.instanceid = ms.userid
+                       AND ctx.contextlevel = :contextuser
+                 WHERE ctx.id = :contextid";
+
+        $userlist->add_from_sql('userid', $sql, $params);
+    }
+
     /**
      * Export all event monitor information for the list of contexts and this user.
      *
@@ -142,6 +181,22 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
         static::delete_user_data($contextlist->get_user()->id);
     }
 
+    /**
+     * Delete multiple users within a single context.
+     *
+     * @param   approved_userlist       $userlist The approved context and user information to delete information for.
+     */
+    public static function delete_data_for_users(approved_userlist $userlist) {
+        $context = $userlist->get_context();
+        $userids = $userlist->get_userids();
+        $userid = reset($userids);
+
+        // Only delete data for user context, which should be a single user.
+        if ($context->contextlevel == CONTEXT_USER && count($userids) == 1 && $userid == $context->instanceid) {
+            static::delete_user_data($userid);
+        }
+    }
+
     /**
      * This does the deletion of user data for the event monitor.
      *
index 5c5f11c..8993b77 100644 (file)
@@ -27,6 +27,7 @@ defined('MOODLE_INTERNAL') || die();
 
 use \tool_monitor\privacy\provider;
 use \core_privacy\local\request\approved_contextlist;
+use \core_privacy\local\request\approved_userlist;
 
 /**
  * Privacy test for the event monitor
@@ -128,6 +129,55 @@ class tool_monitor_privacy_testcase extends advanced_testcase {
         $this->assertEquals($usercontext2->id, $contextlist->get_contextids()[0]);
     }
 
+    /**
+     * Check that the correct userlist is returned if there is any user data for this context.
+     */
+    public function test_get_users_in_context() {
+        $component = 'tool_monitor';
+        $user = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $usercontext = \context_user::instance($user->id);
+        $usercontext2 = \context_user::instance($user2->id);
+
+        $userlist = new \core_privacy\local\request\userlist($usercontext, $component);
+        provider::get_users_in_context($userlist);
+        $this->assertEmpty($userlist);
+
+        $userlist = new \core_privacy\local\request\userlist($usercontext2, $component);
+        provider::get_users_in_context($userlist);
+        $this->assertEmpty($userlist);
+
+        $monitorgenerator = $this->getDataGenerator()->get_plugin_generator('tool_monitor');
+
+        // Create a rule with user.
+        $this->setUser($user);
+        $rule = $monitorgenerator->create_rule();
+        $userlist = new \core_privacy\local\request\userlist($usercontext, $component);
+        provider::get_users_in_context($userlist);
+
+        // Check that we only get back user.
+        $userids = $userlist->get_userids();
+        $this->assertCount(1, $userlist);
+        $this->assertEquals($user->id, $userids[0]);
+
+        // Create a subscription with user2.
+        $this->setUser($user2);
+
+        $record = new stdClass();
+        $record->courseid = 0;
+        $record->userid = $user2->id;
+        $record->ruleid = $rule->id;
+
+        $subscription = $monitorgenerator->create_subscription($record);
+        $userlist = new \core_privacy\local\request\userlist($usercontext2, $component);
+        provider::get_users_in_context($userlist);
+
+        // Check that user2 is returned for just subscribing to a rule.
+        $userids = $userlist->get_userids();
+        $this->assertCount(1, $userlist);
+        $this->assertEquals($user2->id, $userids[0]);
+    }
+
     /**
      * Test that user data is exported correctly.
      */
@@ -286,4 +336,80 @@ class tool_monitor_privacy_testcase extends advanced_testcase {
         $this->assertEquals($user2->id, $dbsubs[$subscription2->id]->userid);
         $this->assertEquals($user2->id, $dbsubs[$subscription3->id]->userid);
     }
+
+    /**
+     * Test deleting user data for an approved userlist in a context.
+     */
+    public function test_delete_data_for_users() {
+        global $DB;
+
+        $component = 'tool_monitor';
+        $user = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $usercontext = \context_user::instance($user->id);
+        $usercontext2 = \context_user::instance($user2->id);
+        $monitorgenerator = $this->getDataGenerator()->get_plugin_generator('tool_monitor');
+
+        $this->setUser($user);
+        // Need to give user one the ability to manage rules.
+        $this->assign_user_capability('tool/monitor:managerules', \context_system::instance());
+
+        $rulerecord = (object)['name' => 'privacy rule'];
+        $rule = $monitorgenerator->create_rule($rulerecord);
+
+        $secondrulerecord = (object)['name' => 'privacy rule2'];
+        $rule2 = $monitorgenerator->create_rule($secondrulerecord);
+
+        $subscription = (object)['ruleid' => $rule->id, 'userid' => $user->id];
+        $subscription = $monitorgenerator->create_subscription($subscription);
+
+        // Have user 2 subscribe to the second rule created by user 1.
+        $subscription2 = (object)['ruleid' => $rule2->id, 'userid' => $user2->id];
+        $subscription2 = $monitorgenerator->create_subscription($subscription2);
+
+        $this->setUser($user2);
+        $thirdrulerecord = (object)['name' => 'privacy rule for second user'];
+        $rule3 = $monitorgenerator->create_rule($thirdrulerecord);
+
+        $subscription3 = (object)['ruleid' => $rule3->id, 'userid' => $user2->id];
+        $subscription3 = $monitorgenerator->create_subscription($subscription3);
+
+        // Get all of the monitor rules, ensure all exist.
+        $dbrules = $DB->get_records('tool_monitor_rules');
+        $this->assertCount(3, $dbrules);
+
+        // Delete for user2 in first user's context, should have no effect.
+        $approveduserids = [$user2->id];
+        $approvedlist = new approved_userlist($usercontext, $component, $approveduserids);
+        provider::delete_data_for_users($approvedlist);
+
+        $dbrules = $DB->get_records('tool_monitor_rules');
+        $this->assertCount(3, $dbrules);
+
+        // Delete for user in usercontext.
+        $approveduserids = [$user->id];
+        $approvedlist = new approved_userlist($usercontext, $component, $approveduserids);
+        provider::delete_data_for_users($approvedlist);
+
+        // Only the rules for user 1 that does not have any more subscriptions should be deleted (the first rule).
+        $dbrules = $DB->get_records('tool_monitor_rules');
+        $this->assertCount(2, $dbrules);
+        $this->assertEquals($user->id, $dbrules[$rule2->id]->userid);
+        $this->assertEquals($user2->id, $dbrules[$rule3->id]->userid);
+
+        // There should be two subscriptions left, both for user 2.
+        $dbsubs = $DB->get_records('tool_monitor_subscriptions');
+        $this->assertCount(2, $dbsubs);
+        $this->assertEquals($user2->id, $dbsubs[$subscription2->id]->userid);
+        $this->assertEquals($user2->id, $dbsubs[$subscription3->id]->userid);
+
+        // Delete for user2 in context 2.
+        $approveduserids = [$user2->id];
+        $approvedlist = new approved_userlist($usercontext2, $component, $approveduserids);
+        provider::delete_data_for_users($approvedlist);
+
+        // There should be no subscriptions left.
+        $dbsubs = $DB->get_records('tool_monitor_subscriptions');
+        $this->assertEmpty($dbsubs);
+    }
 }
index 8e6aece..42f0760 100644 (file)
@@ -39,6 +39,10 @@ $context = context_system::instance();
 $PAGE->set_context($context);
 $PAGE->set_url(new moodle_url('/admin/tool/policy/accept.php'));
 
+if (!in_array($action, ['accept', 'decline', 'revoke'])) {
+    throw new moodle_exception('invalidaccessparameter');
+}
+
 if ($returnurl) {
     $returnurl = new moodle_url($returnurl);
 } else if (count($userids) == 1) {
@@ -59,14 +63,8 @@ if ($form->is_cancelled()) {
     redirect($returnurl);
 }
 
-if ($action == 'revoke') {
-    $title = get_string('revokedetails', 'tool_policy');
-} else {
-    $title = get_string('consentdetails', 'tool_policy');
-}
-
 $output = $PAGE->get_renderer('tool_policy');
 echo $output->header();
-echo $output->heading($title);
+echo $output->heading(get_string('statusformtitle'.$action, 'tool_policy'));
 $form->display();
 echo $output->footer();
index 020e5bf..cdd3496 100644 (file)
Binary files a/admin/tool/policy/amd/build/acceptmodal.min.js and b/admin/tool/policy/amd/build/acceptmodal.min.js differ
index b923629..ce12346 100644 (file)
@@ -52,36 +52,6 @@ define(['jquery', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/n
          */
         AcceptOnBehalf.prototype.contextid = -1;
 
-        /**
-         * @var {Array} strings
-         * @private
-         */
-        AcceptOnBehalf.prototype.stringKeys = [
-            {
-                key: 'consentdetails',
-                component: 'tool_policy'
-            },
-            {
-                key: 'iagreetothepolicy',
-                component: 'tool_policy'
-            },
-            {
-                key: 'selectusersforconsent',
-                component: 'tool_policy'
-            },
-            {
-                key: 'ok'
-            },
-            {
-                key: 'revokedetails',
-                component: 'tool_policy'
-            },
-            {
-                key: 'irevokethepolicy',
-                component: 'tool_policy'
-            }
-        ];
-
         /**
          * @var {object} currentTrigger The triggered HTML jQuery object
          * @private
@@ -121,9 +91,14 @@ define(['jquery', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/n
                     var formData = form.serialize();
                     this.showFormModal(formData);
                 } else {
-                    Str.get_strings(this.stringKeys).done(function(strings) {
-                        Notification.alert('', strings[2], strings[3]);
-                    });
+                    Str.get_strings([
+                        {key: 'notice'},
+                        {key: 'selectusersforconsent', component: 'tool_policy'},
+                        {key: 'ok'}
+                    ]).then(function(strings) {
+                        Notification.alert(strings[0], strings[1], strings[2]);
+                        return;
+                    }).fail(Notification.exception);
                 }
             }.bind(this));
         };
@@ -143,18 +118,28 @@ define(['jquery', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/n
                 }
             }
             // Fetch the title string.
-            Str.get_strings(this.stringKeys).done(function(strings) {
+            Str.get_strings([
+                {key: 'statusformtitleaccept', component: 'tool_policy'},
+                {key: 'iagreetothepolicy', component: 'tool_policy'},
+                {key: 'statusformtitlerevoke', component: 'tool_policy'},
+                {key: 'irevokethepolicy', component: 'tool_policy'},
+                {key: 'statusformtitledecline', component: 'tool_policy'},
+                {key: 'declinethepolicy', component: 'tool_policy'}
+            ]).then(function(strings) {
                 var title;
                 var saveText;
-                if (action == 'revoke') {
-                    title = strings[4];
-                    saveText = strings[5];
-                } else {
+                if (action == 'accept') {
                     title = strings[0];
                     saveText = strings[1];
+                } else if (action == 'revoke') {
+                    title = strings[2];
+                    saveText = strings[3];
+                } else if (action == 'decline') {
+                    title = strings[4];
+                    saveText = strings[5];
                 }
                 // Create the modal.
-                ModalFactory.create({
+                return ModalFactory.create({
                     type: ModalFactory.types.SAVE_CANCEL,
                     title: title,
                     body: ''
@@ -163,7 +148,7 @@ define(['jquery', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/n
                     this.setupFormModal(formData, saveText);
                 }.bind(this));
             }.bind(this))
-                .fail(Notification.exception);
+                .catch(Notification.exception);
         };
 
         /**
index a0efdcc..35cce60 100644 (file)
@@ -177,21 +177,23 @@ class acceptances_table extends \table_sql {
         $filterstatus = $this->acceptancesfilter->get_status_filter();
         if ($filterstatus == 1) {
             $this->sql->from .= " $join AND a{$v}.status=1";
+        } else if ($filterstatus == 2) {
+            $this->sql->from .= " $join AND a{$v}.status=0";
         } else {
             $this->sql->from .= " LEFT $join";
         }
 
-        $this->sql->from .= " LEFT JOIN {user} m ON m.id = a{$v}.usermodified AND m.id <> u.id AND a{$v}.status = 1";
+        $this->sql->from .= " LEFT JOIN {user} m ON m.id = a{$v}.usermodified AND m.id <> u.id AND a{$v}.status IS NOT NULL";
 
         $this->sql->params['versionid' . $v] = $v;
 
         if ($filterstatus === 0) {
-            $this->sql->where .= " AND (a{$v}.status IS NULL OR a{$v}.status = 0)";
+            $this->sql->where .= " AND a{$v}.status IS NULL";
         }
 
-        $this->add_column_header('status' . $v, get_string('agreed', 'tool_policy'), true, 'mdl-align');
-        $this->add_column_header('timemodified', get_string('agreedon', 'tool_policy'));
-        $this->add_column_header('usermodified' . $v, get_string('agreedby', 'tool_policy'));
+        $this->add_column_header('status' . $v, get_string('response', 'tool_policy'));
+        $this->add_column_header('timemodified', get_string('responseon', 'tool_policy'));
+        $this->add_column_header('usermodified' . $v, get_string('responseby', 'tool_policy'));
         $this->add_column_header('note', get_string('acceptancenote', 'tool_policy'), false);
     }
 
@@ -207,11 +209,13 @@ class acceptances_table extends \table_sql {
             $join = "JOIN {tool_policy_acceptances} a{$v} ON a{$v}.userid = u.id AND a{$v}.policyversionid=:versionid{$v}";
             if ($filterstatus == 1) {
                 $this->sql->from .= " {$join} AND a{$v}.status=1";
+            } else if ($filterstatus == 2) {
+                $this->sql->from .= " {$join} AND a{$v}.status=0";
             } else {
                 $this->sql->from .= " LEFT {$join}";
             }
             $this->sql->params['versionid' . $v] = $v;
-            $this->add_column_header('status' . $v, $versionname, true, 'mdl-align');
+            $this->add_column_header('status' . $v, $versionname);
             $statusall[] = "COALESCE(a{$v}.status, 0)";
         }
         $this->sql->fields .= ",".join('+', $statusall)." AS statusall";
@@ -219,7 +223,7 @@ class acceptances_table extends \table_sql {
         if ($filterstatus === 0) {
             $statussql = [];
             foreach ($this->versionids as $v => $versionname) {
-                $statussql[] = "a{$v}.status IS NULL OR a{$v}.status = 0";
+                $statussql[] = "a{$v}.status IS NULL";
             }
             $this->sql->where .= " AND (u.policyagreed = 0 OR ".join(" OR ", $statussql).")";
         }
@@ -420,7 +424,7 @@ class acceptances_table extends \table_sql {
         echo \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'returnurl',
             'value' => $this->get_return_url()]);
         foreach (array_keys($this->versionids) as $versionid) {
-            echo \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => "versionids[{$versionid}]",
+            echo \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'versionids[]',
                 'value' => $versionid]);
         }
     }
@@ -433,6 +437,7 @@ class acceptances_table extends \table_sql {
     public function wrap_html_finish() {
         global $PAGE;
         if ($this->canagreeany) {
+            echo \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'action', 'value' => 'accept']);
             echo \html_writer::empty_tag('input', ['type' => 'submit', 'data-action' => 'acceptmodal',
                 'value' => get_string('consentbulk', 'tool_policy'), 'class' => 'btn btn-primary m-t-1']);
             $PAGE->requires->js_call_amd('tool_policy/acceptmodal', 'getInstance', [\context_system::instance()->id]);
@@ -529,10 +534,15 @@ class acceptances_table extends \table_sql {
         $onbehalf = false;
         $versions = $versionid ? [$versionid => $this->versionids[$versionid]] : $this->versionids; // List of versions.
         $accepted = []; // List of versionids that user has accepted.
+        $declined = [];
 
         foreach ($versions as $v => $name) {
-            if (!empty($row->{'status' . $v})) {
-                $accepted[] = $v;
+            if ($row->{'status' . $v} !== null) {
+                if (empty($row->{'status' . $v})) {
+                    $declined[] = $v;
+                } else {
+                    $accepted[] = $v;
+                }
                 $agreedby = $row->{'usermodified' . $v};
                 if ($agreedby && $agreedby != $row->id) {
                     $onbehalf = true;
@@ -540,25 +550,13 @@ class acceptances_table extends \table_sql {
             }
         }
 
-        if ($versionid) {
-            $str = new \lang_string($accepted ? 'yes' : 'no');
-        } else {
-            $str = new \lang_string('acceptancecount', 'tool_policy', (object)[
-                'agreedcount' => count($accepted),
-                'policiescount' => count($versions)
-            ]);
-        }
+        $ua = new user_agreement($row->id, $accepted, $declined, $this->get_return_url(), $versions, $onbehalf, $row->canaccept);
 
         if ($this->is_downloading()) {
-            return $str->out();
+            return $ua->export_for_download();
+
         } else {
-            $s = $this->output->render(new user_agreement($row->id, $accepted, $this->get_return_url(),
-                $versions, $onbehalf, $row->canaccept));
-            if (!$versionid) {
-                $s .= '<br>' . \html_writer::link(new \moodle_url('/admin/tool/policy/user.php',
-                        ['userid' => $row->id, 'returnurl' => $this->get_return_url()]), $str);
-            }
-            return $s;
+            return $this->output->render($ua);
         }
     }
 
index 39d49a6..46a01c8 100644 (file)
@@ -131,6 +131,8 @@ class api {
 
         $policies = [];
         $versions = [];
+        $optcache = \cache::make('tool_policy', 'policy_optional');
+
         $rs = $DB->get_recordset_sql($sql, $params);
 
         foreach ($rs as $r) {
@@ -149,6 +151,8 @@ class api {
             }
 
             $versions[$r->id][$versiondata->id] = $versiondata;
+
+            $optcache->set($versiondata->id, $versiondata->optional);
         }
 
         $rs->close();
@@ -309,8 +313,8 @@ class api {
             return true;
         }
 
-        // Users have access to all the policies they have ever accepted.
-        if (static::is_user_version_accepted($userid, $policy->id)) {
+        // Users have access to all the policies they have ever accepted/declined.
+        if (static::is_user_version_accepted($userid, $policy->id) !== null) {
             return true;
         }
 
@@ -719,20 +723,22 @@ class api {
     }
 
     /**
-     * Returns version acceptance for this user.
+     * Did the user accept the given policy version?
      *
      * @param int $userid User identifier.
      * @param int $versionid Policy version identifier.
-     * @param array|null $acceptances Iist of policy version acceptances indexed by versionid.
-     * @return bool True if this user has accepted this policy version; false otherwise.
+     * @param array|null $acceptances Pre-loaded list of policy version acceptances indexed by versionid.
+     * @return bool|null True/false if this user accepted/declined the policy; null otherwise.
      */
     public static function is_user_version_accepted($userid, $versionid, $acceptances = null) {
+
         $acceptance = static::get_user_version_acceptance($userid, $versionid, $acceptances);
+
         if (!empty($acceptance)) {
-            return $acceptance->status;
+            return (bool) $acceptance->status;
         }
 
-        return false;
+        return null;
     }
 
     /**
@@ -754,14 +760,14 @@ class api {
                 if (isset($acceptances[$policy->currentversion->id])) {
                     $policy->currentversion->acceptance = $acceptances[$policy->currentversion->id];
                 } else {
-                    $policy->currentversion->acceptance = 0;
+                    $policy->currentversion->acceptance = null;
                 }
                 $versions[] = $policy->currentversion;
             }
             foreach ($policy->archivedversions as $version) {
                 if ($version->audience != policy_version::AUDIENCE_GUESTS
                         && static::can_user_view_policy_version($version, $userid)) {
-                    $version->acceptance = isset($acceptances[$version->id]) ? $acceptances[$version->id] : 0;
+                    $version->acceptance = isset($acceptances[$version->id]) ? $acceptances[$version->id] : null;
                     $versions[] = $version;
                 }
             }
@@ -774,14 +780,19 @@ class api {
     }
 
     /**
-     * Checks if user can accept policies for themselves or on behalf of another user
+     * Check if given policies can be accepted by the current user (eventually on behalf of the other user)
      *
-     * @param int $userid
-     * @param bool $throwexception
+     * Currently, the version ids are not relevant and the check is based on permissions only. In the future, additional
+     * conditions can be added (such as policies applying to certain users only).
+     *
+     * @param array $versionids int[] List of policy version ids to check
+     * @param int $userid Accepting policies on this user's behalf (defaults to accepting on self)
+     * @param bool $throwexception Throw exception instead of returning false
      * @return bool
      */
-    public static function can_accept_policies($userid = null, $throwexception = false) {
+    public static function can_accept_policies(array $versionids, $userid = null, $throwexception = false) {
         global $USER;
+
         if (!isloggedin() || isguestuser()) {
             if ($throwexception) {
                 throw new \moodle_exception('noguest');
@@ -789,6 +800,7 @@ class api {
                 return false;
             }
         }
+
         if (!$userid) {
             $userid = $USER->id;
         }
@@ -814,15 +826,48 @@ class api {
     }
 
     /**
-     * Checks if user can revoke policies for themselves or on behalf of another user
+     * Check if given policies can be declined by the current user (eventually on behalf of the other user)
      *
-     * @param int $userid
-     * @param bool $throwexception
+     * Only optional policies can be declined. Otherwise, the permissions are same as for accepting policies.
+     *
+     * @param array $versionids int[] List of policy version ids to check
+     * @param int $userid Declining policies on this user's behalf (defaults to declining by self)
+     * @param bool $throwexception Throw exception instead of returning false
      * @return bool
      */
-    public static function can_revoke_policies($userid = null, $throwexception = false) {
+    public static function can_decline_policies(array $versionids, $userid = null, $throwexception = false) {
+
+        foreach ($versionids as $versionid) {
+            if (static::get_agreement_optional($versionid) == policy_version::AGREEMENT_COMPULSORY) {
+                // Compulsory policies can't be declined (that is what makes them compulsory).
+                if ($throwexception) {
+                    throw new \moodle_exception('errorpolicyversioncompulsory', 'tool_policy');
+                } else {
+                    return false;
+                }
+            }
+        }
+
+        return static::can_accept_policies($versionids, $userid, $throwexception);
+    }
+
+    /**
+     * Check if acceptances to given policies can be revoked by the current user (eventually on behalf of the other user)
+     *
+     * Revoking optional policies is controlled by the same rules as declining them. Compulsory policies can be revoked
+     * only by users with the permission to accept policies on other's behalf. The reasoning behind this is to make sure
+     * the user communicates with the site's privacy officer and is well aware of all consequences of the decision (such
+     * as losing right to access the site).
+     *
+     * @param array $versionids int[] List of policy version ids to check
+     * @param int $userid Revoking policies on this user's behalf (defaults to revoking by self)
+     * @param bool $throwexception Throw exception instead of returning false
+     * @return bool
+     */
+    public static function can_revoke_policies(array $versionids, $userid = null, $throwexception = false) {
         global $USER;
 
+        // Guests' acceptance is not stored so there is nothing to revoke.
         if (!isloggedin() || isguestuser()) {
             if ($throwexception) {
                 throw new \moodle_exception('noguest');
@@ -830,32 +875,84 @@ class api {
                 return false;
             }
         }
-        if (!$userid) {
-            $userid = $USER->id;
+
+        // Sort policies into two sets according the optional flag.
+        $compulsory = [];
+        $optional = [];
+
+        foreach ($versionids as $versionid) {
+            $agreementoptional = static::get_agreement_optional($versionid);
+            if ($agreementoptional == policy_version::AGREEMENT_COMPULSORY) {
+                $compulsory[] = $versionid;
+            } else if ($agreementoptional == policy_version::AGREEMENT_OPTIONAL) {
+                $optional[] = $versionid;
+            } else {
+                throw new \coding_exception('Unexpected optional flag value');
+            }
         }
 
-        // At the moment, current users can't revoke their own policies.
-        // Check capability to revoke on behalf as the real user.
-        $realuser = manager::get_realuser();
-        $usercontext = \context_user::instance($userid);
-        if ($throwexception) {
-            require_capability('tool/policy:acceptbehalf', $usercontext, $realuser);
-            return;
-        } else {
-            return has_capability('tool/policy:acceptbehalf', $usercontext, $realuser);
+        // Check if the user can revoke the optional policies from the list.
+        if ($optional) {
+            if (!static::can_decline_policies($optional, $userid, $throwexception)) {
+                return false;
+            }
         }
+
+        // Check if the user can revoke the compulsory policies from the list.
+        if ($compulsory) {
+            if (!$userid) {
+                $userid = $USER->id;
+            }
+
+            $realuser = manager::get_realuser();
+            $usercontext = \context_user::instance($userid);
+            if ($throwexception) {
+                require_capability('tool/policy:acceptbehalf', $usercontext, $realuser);
+                return;
+            } else {
+                return has_capability('tool/policy:acceptbehalf', $usercontext, $realuser);
+            }
+        }
+
+        return true;
     }
 
     /**
-     * Accepts the current revisions of all policies that the user has not yet accepted
+     * Mark the given policy versions as accepted by the user.
      *
-     * @param array|int $policyversionid
-     * @param int|null $userid
-     * @param string|null $note
-     * @param string|null $lang
+     * @param array|int $policyversionid Policy version id(s) to set acceptance status for.
+     * @param int|null $userid Id of the user accepting the policy version, defaults to the current one.
+     * @param string|null $note Note to be recorded.
+     * @param string|null $lang Language in which the policy was shown, defaults to the current one.
      */
     public static function accept_policies($policyversionid, $userid = null, $note = null, $lang = null) {
+        static::set_acceptances_status($policyversionid, $userid, $note, $lang, 1);
+    }
+
+    /**
+     * Mark the given policy versions as declined by the user.
+     *
+     * @param array|int $policyversionid Policy version id(s) to set acceptance status for.
+     * @param int|null $userid Id of the user accepting the policy version, defaults to the current one.
+     * @param string|null $note Note to be recorded.
+     * @param string|null $lang Language in which the policy was shown, defaults to the current one.
+     */
+    public static function decline_policies($policyversionid, $userid = null, $note = null, $lang = null) {
+        static::set_acceptances_status($policyversionid, $userid, $note, $lang, 0);
+    }
+
+    /**
+     * Mark the given policy versions as accepted or declined by the user.
+     *
+     * @param array|int $policyversionid Policy version id(s) to set acceptance status for.
+     * @param int|null $userid Id of the user accepting the policy version, defaults to the current one.
+     * @param string|null $note Note to be recorded.
+     * @param string|null $lang Language in which the policy was shown, defaults to the current one.
+     * @param int $status The acceptance status, defaults to 1 = accepted
+     */
+    protected static function set_acceptances_status($policyversionid, $userid = null, $note = null, $lang = null, $status = 1) {
         global $DB, $USER;
+
         // Validate arguments and capabilities.
         if (empty($policyversionid)) {
             return;
@@ -865,18 +962,22 @@ class api {
         if (!$userid) {
             $userid = $USER->id;
         }
-        self::can_accept_policies($userid, true);
+        self::can_accept_policies([$policyversionid], $userid, true);
 
         // Retrieve the list of policy versions that need agreement (do not update existing agreements).
         list($sql, $params) = $DB->get_in_or_equal($policyversionid, SQL_PARAMS_NAMED);
         $sql = "SELECT v.id AS versionid, a.*
                   FROM {tool_policy_versions} v
-                  LEFT JOIN {tool_policy_acceptances} a ON a.userid = :userid AND a.policyversionid = v.id
-                  WHERE (a.id IS NULL or a.status <> 1) AND v.id " . $sql;
-        $needacceptance = $DB->get_records_sql($sql, ['userid' => $userid] + $params);
+             LEFT JOIN {tool_policy_acceptances} a ON a.userid = :userid AND a.policyversionid = v.id
+                 WHERE v.id $sql AND (a.id IS NULL OR a.status <> :status)";
+
+        $needacceptance = $DB->get_records_sql($sql, $params + [
+            'userid' => $userid,
+            'status' => $status,
+        ]);
 
         $realuser = manager::get_realuser();
-        $updatedata = ['status' => 1, 'lang' => $lang ?: current_language(),
+        $updatedata = ['status' => $status, 'lang' => $lang ?: current_language(),
             'timemodified' => time(), 'usermodified' => $realuser->id, 'note' => $note];
         foreach ($needacceptance as $versionid => $currentacceptance) {
             unset($currentacceptance->versionid);
@@ -911,23 +1012,30 @@ class api {
             $user = $DB->get_record('user', ['id' => $user], 'id, policyagreed');
         }
 
-        $sql = "SELECT d.id, a.status
+        $sql = "SELECT d.id, v.optional, a.status
                   FROM {tool_policy} d
-                  INNER JOIN {tool_policy_versions} v ON v.policyid = d.id AND v.id = d.currentversionid
-                  LEFT JOIN {tool_policy_acceptances} a ON a.userid = :userid AND a.policyversionid = v.id
-                  WHERE (v.audience = :audience OR v.audience = :audienceall)";
+            INNER JOIN {tool_policy_versions} v ON v.policyid = d.id AND v.id = d.currentversionid
+             LEFT JOIN {tool_policy_acceptances} a ON a.userid = :userid AND a.policyversionid = v.id
+                 WHERE (v.audience = :audience OR v.audience = :audienceall)";
+
         $params = [
             'audience' => policy_version::AUDIENCE_LOGGEDIN,
             'audienceall' => policy_version::AUDIENCE_ALL,
             'userid' => $user->id
         ];
-        $policies = $DB->get_records_sql_menu($sql, $params);
-        $acceptedpolicies = array_filter($policies);
-        $policyagreed = (count($policies) == count($acceptedpolicies)) ? 1 : 0;
 
-        if ($user->policyagreed != $policyagreed) {
-            $user->policyagreed = $policyagreed;
-            $DB->set_field('user', 'policyagreed', $policyagreed, ['id' => $user->id]);
+        $allresponded = true;
+        foreach ($DB->get_records_sql($sql, $params) as $policyacceptance) {
+            if ($policyacceptance->optional == policy_version::AGREEMENT_COMPULSORY && empty($policyacceptance->status)) {
+                $allresponded = false;
+            } else if ($policyacceptance->optional == policy_version::AGREEMENT_OPTIONAL && $policyacceptance->status === null) {
+                $allresponded = false;
+            }
+        }
+
+        if ($user->policyagreed != $allresponded) {
+            $user->policyagreed = $allresponded;
+            $DB->set_field('user', 'policyagreed', $allresponded, ['id' => $user->id]);
         }
     }
 
@@ -943,7 +1051,7 @@ class api {
         if (!$userid) {
             $userid = $USER->id;
         }
-        self::can_accept_policies($userid, true);
+        self::can_accept_policies([$policyversionid], $userid, true);
 
         if ($currentacceptance = $DB->get_record('tool_policy_acceptances',
                 ['policyversionid' => $policyversionid, 'userid' => $userid])) {
@@ -963,7 +1071,7 @@ class api {
      * @param \core\event\user_created $event
      */
     public static function create_acceptances_user_created(\core\event\user_created $event) {
-        global $CFG, $DB;
+        global $USER, $CFG, $DB;
 
         // Do nothing if not set as the site policies handler.
         if (empty($CFG->sitepolicyhandler) || $CFG->sitepolicyhandler !== 'tool_policy') {
@@ -977,28 +1085,59 @@ class api {
         if (!$user->policyagreed) {
             return;
         }
-        // Remove the presignup cache after the user account is created.
+
+        // Cleanup our bits in the presignup cache (we can not rely on them at this stage any more anyway).
         $cache = \cache::make('core', 'presignup');
         $cache->delete('tool_policy_userpolicyagreed');
         $cache->delete('tool_policy_viewedpolicies');
+        $cache->delete('tool_policy_policyversionidsagreed');
 
-        // Get all active policies.
-        $currentpolicyversions = static::get_current_versions_ids(policy_version::AUDIENCE_LOGGEDIN);
-        // Save active policies as accepted by the user.
-        if (!empty($currentpolicyversions)) {
+        // Mark all compulsory policies as implicitly accepted during the signup.
+        if ($policyversions = static::list_current_versions(policy_version::AUDIENCE_LOGGEDIN)) {
             $acceptances = array();
-            foreach ($currentpolicyversions as $policy) {
+            $now = time();
+            foreach ($policyversions as $policyversion) {
+                if ($policyversion->optional == policy_version::AGREEMENT_OPTIONAL) {
+                    continue;
+                }
                 $acceptances[] = array(
-                    'policyversionid' => $policy,
+                    'policyversionid' => $policyversion->id,
                     'userid' => $userid,
                     'status' => 1,
                     'lang' => $lang,
-                    'usermodified' => 0,
-                    'timecreated' => time(),
-                    'timemodified' => time()
+                    'usermodified' => isset($USER->id) ? $USER->id : 0,
+                    'timecreated' => $now,
+                    'timemodified' => $now,
                 );
             }
             $DB->insert_records('tool_policy_acceptances', $acceptances);
         }
+
+        static::update_policyagreed($userid);
+    }
+
+    /**
+     * Returns the value of the optional flag for the given policy version.
+     *
+     * Optimised for being called multiple times by making use of a request cache. The cache is normally populated as a
+     * side effect of calling {@link self::list_policies()} and in most cases should be warm enough for hits.
+     *
+     * @param int $versionid
+     * @return int policy_version::AGREEMENT_COMPULSORY | policy_version::AGREEMENT_OPTIONAL
+     */
+    public static function get_agreement_optional($versionid) {
+        global $DB;
+
+        $optcache = \cache::make('tool_policy', 'policy_optional');
+
+        $hit = $optcache->get($versionid);
+
+        if ($hit === false) {
+            $flags = $DB->get_records_menu('tool_policy_versions', null, '', 'id, optional');
+            $optcache->set_many($flags);
+            $hit = $flags[$versionid];
+        }
+
+        return $hit;
     }
 }
index 3ff0daf..e7bcec4 100644 (file)
@@ -44,7 +44,7 @@ class accept_policy extends \moodleform {
      * Defines the form fields.
      */
     public function definition() {
-        global $PAGE;
+        global $PAGE, $USER;
         $mform = $this->_form;
 
         if (empty($this->_customdata['userids']) || !is_array($this->_customdata['userids'])) {
@@ -53,10 +53,10 @@ class accept_policy extends \moodleform {
         if (empty($this->_customdata['versionids']) || !is_array($this->_customdata['versionids'])) {
             throw new \moodle_exception('missingparam', '', '', 'versionids');
         }
-        $revoke = (!empty($this->_customdata['action']) && $this->_customdata['action'] == 'revoke');
+        $action = $this->_customdata['action'];
         $userids = clean_param_array($this->_customdata['userids'], PARAM_INT);
         $versionids = clean_param_array($this->_customdata['versionids'], PARAM_INT);
-        $usernames = $this->validate_and_get_users($userids, $revoke);
+        $usernames = $this->validate_and_get_users($versionids, $userids, $action);
         $versionnames = $this->validate_and_get_versions($versionids);
 
         foreach ($usernames as $userid => $name) {
@@ -78,22 +78,36 @@ class accept_policy extends \moodleform {
                 get_string('policydochdrpolicy', 'tool_policy');
         $mform->addElement('static', 'policy', $policyacceptancelabel, join(', ', $versionnames));
 
-        if ($revoke) {
+        if ($action === 'revoke') {
             $mform->addElement('static', 'ack', '', get_string('revokeacknowledgement', 'tool_policy'));
             $mform->addElement('hidden', 'action', 'revoke');
-            $mform->setType('action', PARAM_ALPHA);
-        } else {
+        } else if ($action === 'accept') {
             $mform->addElement('static', 'ack', '', get_string('acceptanceacknowledgement', 'tool_policy'));
+            $mform->addElement('hidden', 'action', 'accept');
+        } else if ($action === 'decline') {
+            $mform->addElement('static', 'ack', '', get_string('declineacknowledgement', 'tool_policy'));
+            $mform->addElement('hidden', 'action', 'decline');
+        } else {
+            throw new \moodle_exception('invalidaccessparameter');
+        }
+
+        $mform->setType('action', PARAM_ALPHA);
+
+        if (count($usernames) == 1 && isset($usernames[$USER->id])) {
+            // No need to display the acknowledgement if the users are giving/revoking acceptance on their own.
+            $mform->removeElement('ack');
         }
 
         $mform->addElement('textarea', 'note', get_string('acceptancenote', 'tool_policy'));
         $mform->setType('note', PARAM_NOTAGS);
 
         if (!empty($this->_customdata['showbuttons'])) {
-            if ($revoke) {
+            if ($action === 'revoke') {
                 $this->add_action_buttons(true, get_string('irevokethepolicy', 'tool_policy'));
-            } else {
+            } else if ($action === 'accept') {
                 $this->add_action_buttons(true, get_string('iagreetothepolicy', 'tool_policy'));
+            } else if ($action === 'decline') {
+                $this->add_action_buttons(true, get_string('declinethepolicy', 'tool_policy'));
             }
         }
 
@@ -103,12 +117,14 @@ class accept_policy extends \moodleform {
     /**
      * Validate userids and return usernames
      *
+     * @param array $versionids int[] List of policy version ids to process.
      * @param array $userids
-     * @param boolean $revoke True if policies will be revoked; false when policies will be accepted.
+     * @param string $action accept|decline|revoke
      * @return array (userid=>username)
      */
-    protected function validate_and_get_users($userids, $revoke = false) {
+    protected function validate_and_get_users($versionids, $userids, $action) {
         global $DB;
+
         $usernames = [];
         list($sql, $params) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
         $params['usercontextlevel'] = CONTEXT_USER;
@@ -126,10 +142,12 @@ class accept_policy extends \moodleform {
                 throw new \moodle_exception('noguest');
             }
             \context_helper::preload_from_record($user);
-            if ($revoke) {
-                api::can_revoke_policies($userid, true);
-            } else {
-                api::can_accept_policies($userid, true);
+            if ($action === 'revoke') {
+                api::can_revoke_policies($versionids, $userid, true);
+            } else if ($action === 'accept') {
+                api::can_accept_policies($versionids, $userid, true);
+            } else if ($action === 'decline') {
+                api::can_decline_policies($versionids, $userid, true);
             }
             $usernames[$userid] = fullname($user);
         }
@@ -166,14 +184,15 @@ class accept_policy extends \moodleform {
      */
     public function process() {
         if ($data = $this->get_data()) {
-            $revoke = (!empty($data->action) && $data->action == 'revoke');
             foreach ($data->userids as $userid) {
-                if ($revoke) {
+                if ($data->action === 'revoke') {
                     foreach ($data->versionids as $versionid) {
                         \tool_policy\api::revoke_acceptance($versionid, $userid, $data->note);
                     }
-                } else {
+                } else if ($data->action === 'accept') {
                     \tool_policy\api::accept_policies($data->versionids, $userid, $data->note);
+                } else if ($data->action === 'decline') {
+                    \tool_policy\api::decline_policies($data->versionids, $userid, $data->note);
                 }
             }
         }
index 9c16180..917238b 100644 (file)
@@ -91,6 +91,8 @@ class policydoc extends moodleform {
 
         $mform->addElement('selectyesno', 'agreementstyle', get_string('policypriorityagreement', 'tool_policy'));
 
+        $mform->addElement('selectyesno', 'optional', get_string('policydocoptional', 'tool_policy'));
+
         if (!$formdata->id || $formdata->status == policy_version::STATUS_DRAFT) {
             // Creating a new version or editing a draft/archived version.
             $mform->addElement('hidden', 'minorchange');
index 526f33c..86f0dbe 100644 (file)
@@ -50,9 +50,6 @@ class acceptances implements renderable, templatable {
     /** @var moodle_url */
     protected $returnurl;
 
-    /** @var bool */
-    protected $canrevoke;
-
     /**
      * Contructor.
      *
@@ -62,7 +59,6 @@ class acceptances implements renderable, templatable {
     public function __construct($userid, $returnurl = null) {
         $this->userid = $userid;
         $this->returnurl = $returnurl ? (new moodle_url($returnurl))->out(false) : null;
-        $this->canrevoke = \tool_policy\api::can_revoke_policies($this->userid);
     }
 
     /**
@@ -76,19 +72,20 @@ class acceptances implements renderable, templatable {
         $data->hasonbehalfagreements = false;
         $data->pluginbaseurl = (new moodle_url('/admin/tool/policy'))->out(false);
         $data->returnurl = $this->returnurl;
-        $data->canrevoke = $this->canrevoke;
 
         // Get the list of policies and versions that current user is able to see
         // and the respective acceptance records for the selected user.
         $policies = api::get_policies_with_acceptances($this->userid);
+        $versionids = [];
 
         $canviewfullnames = has_capability('moodle/site:viewfullnames', \context_system::instance());
         foreach ($policies as $policy) {
-
             foreach ($policy->versions as $version) {
+                $versionids[$version->id] = $version->id;
                 unset($version->summary);
                 unset($version->content);
                 $version->iscurrent = ($version->status == policy_version::STATUS_ACTIVE);
+                $version->isoptional = ($version->optional == policy_version::AGREEMENT_OPTIONAL);
                 $version->name = $version->name;
                 $version->revision = $version->revision;
                 $returnurl = new moodle_url('/admin/tool/policy/user.php', ['userid' => $this->userid]);
@@ -98,12 +95,17 @@ class acceptances implements renderable, templatable {
                     'returnurl' => $returnurl->out(false),
                 ]))->out(false);
 
-                if (!empty($version->acceptance->status)) {
+                if ($version->acceptance !== null) {
                     $acceptance = $version->acceptance;
                     $version->timeaccepted = userdate($acceptance->timemodified, get_string('strftimedatetime'));
                     $onbehalf = $acceptance->usermodified && $acceptance->usermodified != $this->userid;
-                    $version->agreement = new user_agreement($this->userid, [$version->id], $returnurl,
-                        [$version->id => $version->name], $onbehalf);
+                    if ($version->acceptance->status == 1) {
+                        $version->agreement = new user_agreement($this->userid, [$version->id], [], $returnurl,
+                            [$version->id => $version->name], $onbehalf);
+                    } else {
+                        $version->agreement = new user_agreement($this->userid, [], [$version->id], $returnurl,
+                            [$version->id => $version->name], $onbehalf);
+                    }
                     if ($onbehalf) {
                         $usermodified = (object)['id' => $acceptance->usermodified];
                         username_load_fields_from_object($usermodified, $acceptance, 'mod');
@@ -114,7 +116,7 @@ class acceptances implements renderable, templatable {
                     }
                     $version->note = format_text($acceptance->note);
                 } else if ($version->iscurrent) {
-                    $version->agreement = new user_agreement($this->userid, [], $returnurl, [$version->id => $version->name]);
+                    $version->agreement = new user_agreement($this->userid, [], [], $returnurl, [$version->id => $version->name]);
                 }
                 if (isset($version->agreement)) {
                     $version->agreement = $version->agreement->export_for_template($output);
@@ -133,6 +135,8 @@ class acceptances implements renderable, templatable {
         }
 
         $data->policies = array_values($policies);
+        $data->canrevoke = \tool_policy\api::can_revoke_policies(array_keys($versionids), $this->userid);
+
         return $data;
     }
 }
index 347a509..d35a994 100644 (file)
@@ -94,11 +94,9 @@ class acceptances_filter implements \templatable, \renderable {
                 switch ((int)$parts[1]) {
                     case self::FILTER_POLICYID:
                     case self::FILTER_VERSIONID:
-                        $value = (int)$parts[2];
-                        break;
                     case self::FILTER_CAPABILITY_ACCEPT:
                     case self::FILTER_STATUS:
-                        $value = (int)(bool)$parts[2];
+                        $value = (int)$parts[2];
                         break;
                     case self::FILTER_ROLE:
                         $value = (int)$parts[2];
@@ -408,8 +406,9 @@ class acceptances_filter implements \templatable, \renderable {
 
         // Status.
         $statuses = [
+            self::FILTER_STATUS.':2' => get_string('filterstatusdeclined', 'tool_policy'),
             self::FILTER_STATUS.':1' => get_string('filterstatusyes', 'tool_policy'),
-            self::FILTER_STATUS.':0' => get_string('filterstatusno', 'tool_policy'),
+            self::FILTER_STATUS.':0' => get_string('filterstatuspending', 'tool_policy'),
         ];
         if (($currentstatus = $this->get_status_filter()) !== null) {
             $selectedoptions[] = $key = self::FILTER_STATUS . ':' . $currentstatus;
index 771ac05..9a62976 100644 (file)
@@ -57,6 +57,9 @@ class page_agreedocs implements renderable, templatable {
     /** @var array $agreedocs List of policy identifiers which the user has agreed using the form. */
     protected $agreedocs = null;
 
+    /** @var array $declinedocs List of policy identifiers that the user declined. */
+    protected $declinedocs = null;
+
     /** @var string $action Form action to identify when user agreeds policies. */
     protected $action = null;
 
@@ -80,15 +83,17 @@ class page_agreedocs implements renderable, templatable {
      *
      * @param array $listdocs List of policy version ids that were displayed to the user to agree with.
      * @param array $agreedocs List of policy version ids that the user actually agreed with.
+     * @param array $declinedocs List of policy version ids that the user declined.
      * @param int $behalfid The userid to accept the policy versions as (such as child's id).
      * @param string $action Form action to identify when user agreeds policies.
      */
-    public function __construct(array $listdocs, array $agreedocs = [], $behalfid = 0, $action = null) {
+    public function __construct(array $listdocs, array $agreedocs = [], array $declinedocs = [], $behalfid = 0, $action = null) {
         global $USER;
         $realuser = manager::get_realuser();
 
         $this->listdocs = $listdocs;
         $this->agreedocs = $agreedocs;
+        $this->declinedocs = $declinedocs;
         $this->action = $action;
         $this->isexistinguser = isloggedin() && !isguestuser();
 
@@ -99,11 +104,23 @@ class page_agreedocs implements renderable, templatable {
         }
 
         $this->policies = api::list_current_versions(policy_version::AUDIENCE_LOGGEDIN);
+
+        if (!$this->isexistinguser) {
+            // During the signup, show compulsory policies only.
+            foreach ($this->policies as $ix => $policyversion) {
+                if ($policyversion->optional == policy_version::AGREEMENT_OPTIONAL) {
+                    unset($this->policies[$ix]);
+                }
+            }
+            $this->policies = array_values($this->policies);
+        }
+
         if (empty($this->behalfid)) {
             $userid = $USER->id;
         } else {
             $userid = $this->behalfid;
         }
+
         $this->accept_and_revoke_policies();
         $this->prepare_global_page_access($userid);
         $this->prepare_user_acceptances($userid);
@@ -120,25 +137,30 @@ class page_agreedocs implements renderable, templatable {
         if ($this->isexistinguser) {
             // Existing user.
             if (!empty($this->action) && confirm_sesskey()) {
-                // The form has been sent. Update policies acceptances according to $this->agreedocs.
+                // The form has been sent, update policies acceptances.
                 $lang = current_language();
                 // Accept / revoke policies.
-                $acceptversionids = array();
+                $acceptversionids = [];
+                $declineversionids = [];
+
                 foreach ($this->policies as $policy) {
                     if (in_array($policy->id, $this->listdocs)) {
                         if (in_array($policy->id, $this->agreedocs)) {
-                            // Save policy version doc to accept it.
                             $acceptversionids[] = $policy->id;
+                        } else if (in_array($policy->id, $this->declinedocs)) {
+                            $declineversionids[] = $policy->id;
                         } else {
-                            // If the policy was displayed but not agreed, revoke the eventually given acceptance.
+                            // If the policy was displayed but not answered, revoke the eventually given acceptance.
                             api::revoke_acceptance($policy->id, $this->behalfid);
                         }
                     }
                 }
-                // Accept all policy docs saved in $acceptversionids.
+
                 api::accept_policies($acceptversionids, $this->behalfid, null, $lang);
+                api::decline_policies($declineversionids, $this->behalfid, null, $lang);
+
                 // Show a message to let know the user he/she must agree all the policies.
-             &