Merge branch 'MDL-62145-master' of git://github.com/damyon/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 24 Oct 2018 23:35:09 +0000 (01:35 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 24 Oct 2018 23:35:09 +0000 (01:35 +0200)
362 files changed:
admin/cli/upgrade.php
admin/settings/moodleservices.php [new file with mode: 0644]
admin/settings/top.php
admin/templates/setting_description.mustache [new file with mode: 0644]
admin/tool/dataprivacy/classes/api.php
admin/tool/dataprivacy/classes/data_registry.php
admin/tool/dataprivacy/classes/expired_context.php
admin/tool/dataprivacy/classes/expired_contexts_manager.php
admin/tool/dataprivacy/classes/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/context_instance.php
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_defaults.feature
admin/tool/dataprivacy/tests/behat/manage_purposes.feature
admin/tool/dataprivacy/tests/data_registry_test.php [new file with mode: 0644]
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/settings.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
admin/tool/xmldb/actions/edit_table/edit_table.class.php
admin/tool/xmldb/actions/edit_xml_file/edit_xml_file.class.php
admin/tool/xmldb/amd/build/move.min.js [new file with mode: 0644]
admin/tool/xmldb/amd/src/move.js [new file with mode: 0644]
admin/tool/xmldb/classes/external.php [new file with mode: 0644]
admin/tool/xmldb/db/services.php [new file with mode: 0644]
admin/tool/xmldb/styles_boost.css [new file with mode: 0644]
admin/tool/xmldb/version.php
admin/user/user_bulk_cohortadd.php
admin/user/user_bulk_delete.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
backup/moodle2/backup_stepslib.php
backup/moodle2/restore_stepslib.php
blocks/classes/external.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
blocks/tests/externallib_test.php
blocks/upgrade.txt
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
config-dist.php
course/classes/privacy/provider.php
course/moodleform_mod.php
course/tests/privacy_test.php
enrol/classes/privacy/provider.php
enrol/cohort/classes/privacy/provider.php
enrol/cohort/tests/privacy_test.php
enrol/externallib.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/externallib_test.php
enrol/tests/privacy_test.php
enrol/upgrade.txt
favourites/classes/local/entity/favourite.php [new file with mode: 0644]
favourites/classes/local/repository/favourite_repository.php [new file with mode: 0644]
favourites/classes/local/repository/favourite_repository_interface.php [new file with mode: 0644]
favourites/classes/local/service/user_favourite_service.php [new file with mode: 0644]
favourites/classes/privacy/provider.php [new file with mode: 0644]
favourites/classes/service_factory.php [new file with mode: 0644]
favourites/tests/privacy_test.php [new file with mode: 0644]
favourites/tests/repository_test.php [new file with mode: 0644]
favourites/tests/service_test.php [new file with mode: 0644]
files/classes/privacy/provider.php
files/converter/classes/privacy/provider.php
files/tests/privacy_test.php [new file with mode: 0644]
grade/report/history/classes/output/tablelog.php
grade/report/user/lib.php
group/classes/privacy/provider.php
group/tests/privacy_provider_test.php
install/lang/af/moodle.php
install/lang/el/error.php
install/lang/ru/admin.php
lang/en/admin.php
lang/en/bulkusers.php
lang/en/deprecated.txt
lang/en/favourites.php [new file with mode: 0644]
lang/en/message.php
lang/en/privacy.php
lang/en/role.php
lib/adminlib.php
lib/amd/build/dragdrop.min.js [new file with mode: 0644]
lib/amd/build/pending.min.js [new file with mode: 0644]
lib/amd/build/sortable_list.min.js
lib/amd/build/templates.min.js
lib/amd/src/autoscroll.js
lib/amd/src/dragdrop.js [new file with mode: 0644]
lib/amd/src/pending.js [new file with mode: 0644]
lib/amd/src/sortable_list.js
lib/amd/src/templates.js
lib/classes/component.php
lib/classes/event/message_deleted.php
lib/classes/privacy/provider.php
lib/db/access.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/filelib.php
lib/grade/constants.php
lib/grade/grade_grade.php
lib/grade/grade_item.php
lib/grade/grade_object.php
lib/gradelib.php
lib/installlib.php
lib/javascript-static.js
lib/messagelib.php
lib/moodlelib.php
lib/outputrenderers.php
lib/templates/action_menu_link.mustache
lib/templates/action_menu_trigger.mustache
lib/tests/component_test.php
lib/tests/user_menu_test.php
lib/upgrade.txt
lib/upgradelib.php
lib/userkey/classes/privacy/provider.php
message/amd/build/message_preferences.min.js
message/amd/src/message_preferences.js
message/classes/api.php
message/classes/task/migrate_message_data.php
message/externallib.php
message/lib.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/output/popup/tests/behat/message_popover_unread.feature
message/renderer.php
message/templates/message_preferences.mustache
message/tests/api_test.php
message/tests/behat/delete_all_messages.feature
message/tests/behat/delete_messages.feature
message/tests/behat/reply_message.feature
message/tests/behat/search_messages.feature
message/tests/behat/view_messages.feature
message/tests/events_test.php
message/tests/externallib_test.php
message/tests/messagelib_test.php
message/tests/privacy_provider_test.php
message/upgrade.txt
mod/assign/feedback/comments/backup/moodle2/backup_assignfeedback_comments_subplugin.class.php
mod/assign/feedback/comments/backup/moodle2/restore_assignfeedback_comments_subplugin.class.php
mod/assign/feedback/comments/classes/privacy/provider.php
mod/assign/feedback/comments/lang/en/assignfeedback_comments.php
mod/assign/feedback/comments/lib.php [new file with mode: 0644]
mod/assign/feedback/comments/locallib.php
mod/assign/feedback/comments/tests/privacy_test.php
mod/assign/feedback/editpdf/classes/document_services.php
mod/assign/feedbackplugin.php
mod/assign/locallib.php
mod/assign/submission/onlinetext/locallib.php
mod/assign/tests/locallib_test.php
mod/assign/upgrade.txt
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
phpunit.xml.dist
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]
question/type/ddmarker/amd/build/form.min.js [new file with mode: 0644]
question/type/ddmarker/amd/build/question.min.js [new file with mode: 0644]
question/type/ddmarker/amd/build/shapes.min.js [new file with mode: 0644]
question/type/ddmarker/amd/src/form.js [new file with mode: 0644]
question/type/ddmarker/amd/src/question.js [new file with mode: 0644]
question/type/ddmarker/amd/src/shapes.js [new file with mode: 0644]
question/type/ddmarker/edit_ddmarker_form.php
question/type/ddmarker/lang/en/qtype_ddmarker.php
question/type/ddmarker/renderer.php
question/type/ddmarker/shapes.php
question/type/ddmarker/styles.css
question/type/ddmarker/tests/behat/preview.feature
question/type/ddmarker/tests/helper.php
question/type/ddmarker/yui/build/moodle-qtype_ddmarker-dd/moodle-qtype_ddmarker-dd-debug.js [deleted file]
question/type/ddmarker/yui/build/moodle-qtype_ddmarker-dd/moodle-qtype_ddmarker-dd-min.js [deleted file]
question/type/ddmarker/yui/build/moodle-qtype_ddmarker-dd/moodle-qtype_ddmarker-dd.js [deleted file]
question/type/ddmarker/yui/build/moodle-qtype_ddmarker-form/moodle-qtype_ddmarker-form-debug.js [deleted file]
question/type/ddmarker/yui/build/moodle-qtype_ddmarker-form/moodle-qtype_ddmarker-form-min.js [deleted file]
question/type/ddmarker/yui/build/moodle-qtype_ddmarker-form/moodle-qtype_ddmarker-form.js [deleted file]
question/type/ddmarker/yui/src/ddmarker/build.json [deleted file]
question/type/ddmarker/yui/src/ddmarker/js/ddmarker.js [deleted file]
question/type/ddmarker/yui/src/ddmarker/meta/ddmarker.json [deleted file]
question/type/ddmarker/yui/src/form/build.json [deleted file]
question/type/ddmarker/yui/src/form/js/form.js [deleted file]
question/type/ddmarker/yui/src/form/meta/form.json [deleted file]
question/type/ddwtos/amd/build/ddwtos.min.js [new file with mode: 0644]
question/type/ddwtos/amd/src/ddwtos.js [new file with mode: 0644]
question/type/ddwtos/renderer.php
question/type/ddwtos/styles.css
question/type/ddwtos/tests/behat/preview.feature
question/type/ddwtos/yui/build/moodle-qtype_ddwtos-dd/moodle-qtype_ddwtos-dd-debug.js [deleted file]
question/type/ddwtos/yui/build/moodle-qtype_ddwtos-dd/moodle-qtype_ddwtos-dd-min.js [deleted file]
question/type/ddwtos/yui/build/moodle-qtype_ddwtos-dd/moodle-qtype_ddwtos-dd.js [deleted file]
question/type/ddwtos/yui/src/ddwtos/build.json [deleted file]
question/type/ddwtos/yui/src/ddwtos/js/ddwtos.js [deleted file]
question/type/ddwtos/yui/src/ddwtos/meta/ddwtos.json [deleted file]
question/type/gapselect/rendererbase.php
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
theme/boost/amd/src/aria.js
theme/boost/templates/core/auth_verify_age_location_page.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/templates/core_admin/setting_description.mustache [new file with mode: 0644]
theme/boost/tests/behat/behat_theme_boost_behat_action_menu.php
user/classes/privacy/provider.php
user/profile/definelib.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/behat/bulk_editenrolment.feature
user/tests/behat/delete_users.feature
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.
diff --git a/admin/settings/moodleservices.php b/admin/settings/moodleservices.php
new file mode 100644 (file)
index 0000000..2f34cfd
--- /dev/null
@@ -0,0 +1,53 @@
+<?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/>.
+
+/**
+ * This file gives information about Moodle Services
+ *
+ * @package    core
+ * @copyright  2018 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+if ($hassiteconfig) {
+
+    // Create Moodle Services information.
+    $moodleservices->add(new admin_setting_heading('moodleservicesintro', '',
+        new lang_string('moodleservices_help', 'admin')));
+
+    // Moodle Partners information.
+    if (empty($CFG->disableserviceads_partner)) {
+        $moodleservices->add(new admin_setting_heading('moodlepartners',
+            new lang_string('moodlepartners', 'admin'),
+            new lang_string('moodlepartners_help', 'admin')));
+    }
+
+    // Moodle app information.
+    $moodleservices->add(new admin_setting_heading('moodleapp',
+        new lang_string('moodleapp', 'admin'),
+        new lang_string('moodleapp_help', 'admin')));
+
+    // Branded Moodle app information.
+    if (empty($CFG->disableserviceads_branded)) {
+        $moodleservices->add(new admin_setting_heading('moodlebrandedapp',
+            new lang_string('moodlebrandedapp', 'admin'),
+            new lang_string('moodlebrandedapp_help', 'admin')));
+    }
+}
+
+
index 17621cb..102b758 100644 (file)
@@ -15,6 +15,11 @@ $ADMIN->add('root', new admin_externalpage('registrationmoodleorg', new lang_str
  // hidden upgrade script
 $ADMIN->add('root', new admin_externalpage('upgradesettings', new lang_string('upgradesettings', 'admin'), "$CFG->wwwroot/$CFG->admin/upgradesettings.php", 'moodle/site:config', true));
 
+// Adding Moodle Services information page.
+$moodleservices = new admin_settingpage('moodleservices', new lang_string('moodleservices',
+    'admin'));
+$ADMIN->add('root', $moodleservices);
+
 if ($hassiteconfig) {
     $optionalsubsystems = new admin_settingpage('optionalsubsystems', new lang_string('advancedfeatures', 'admin'));
     $ADMIN->add('root', $optionalsubsystems);
diff --git a/admin/templates/setting_description.mustache b/admin/templates/setting_description.mustache
new file mode 100644 (file)
index 0000000..c6d0e57
--- /dev/null
@@ -0,0 +1,45 @@
+{{!
+    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/>.
+}}
+{{!
+    @template core_admin/setting_description
+
+    Admin setting description template.
+
+    Context variables required for this template:
+    * labelfor - id of the form element
+    * title - Setting title
+    * name - Setting name
+
+    Example context (json):
+    {
+        "title": "Setting title",
+        "name": "Name",
+        "description": "Description goes here"
+    }
+}}
+{{!
+    Setting description.
+}}
+<div class="form-item form-horizontal clearfix">
+    <div class="form-label">
+        <label>
+            {{{title}}}
+        </label>
+        <span class="form-shortname ">{{{name}}}</span>
+    </div>
+    <div class="controls felement fstatic">{{{description}}}</div>
+</div>
\ No newline at end of file
index f626371..e73a34c 100644 (file)
@@ -914,7 +914,7 @@ class api {
      * @param int $forcedvalue Use this categoryid value as if this was this context instance category.
      * @return category|false
      */
-    public static function get_effective_context_category(\context $context, $forcedvalue=false) {
+    public static function get_effective_context_category(\context $context, $forcedvalue = false) {
         if (!data_registry::defaults_set()) {
             return false;
         }
@@ -941,15 +941,14 @@ class api {
      * Returns the effective category given a context level.
      *
      * @param int $contextlevel
-     * @param int $forcedvalue Use this categoryid value as if this was this context level category.
      * @return category|false
      */
-    public static function get_effective_contextlevel_category($contextlevel, $forcedvalue=false) {
+    public static function get_effective_contextlevel_category($contextlevel) {
         if (!data_registry::defaults_set()) {
             return false;
         }
 
-        return data_registry::get_effective_contextlevel_value($contextlevel, 'category', $forcedvalue);
+        return data_registry::get_effective_contextlevel_value($contextlevel, 'category');
     }
 
     /**
@@ -988,6 +987,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 +998,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 +1103,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 +1209,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 d10ab83..50681ba 100644 (file)
@@ -39,18 +39,6 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class data_registry {
-
-    /**
-     * @var array Inheritance between context levels.
-     */
-    private static $contextlevelinheritance = [
-        CONTEXT_USER => [CONTEXT_SYSTEM],
-        CONTEXT_COURSECAT => [CONTEXT_SYSTEM],
-        CONTEXT_COURSE => [CONTEXT_COURSECAT, CONTEXT_SYSTEM],
-        CONTEXT_MODULE => [CONTEXT_COURSE, CONTEXT_COURSECAT, CONTEXT_SYSTEM],
-        CONTEXT_BLOCK => [CONTEXT_COURSE, CONTEXT_COURSECAT, CONTEXT_SYSTEM],
-    ];
-
     /**
      * Returns purpose and category var names from a context class name
      *
@@ -83,7 +71,6 @@ class data_registry {
      * @return int[]|false[]
      */
     public static function get_defaults($contextlevel, $pluginname = '') {
-
         $classname = \context_helper::get_class_for_level($contextlevel);
         list($purposevar, $categoryvar) = self::var_names_from_context($classname, $pluginname);
 
@@ -104,10 +91,10 @@ class data_registry {
         }
 
         if (empty($purposeid)) {
-            $purposeid = false;
+            $purposeid = context_instance::NOTSET;
         }
         if (empty($categoryid)) {
-            $categoryid = false;
+            $categoryid = context_instance::NOTSET;
         }
 
         return [$purposeid, $categoryid];
@@ -190,69 +177,92 @@ class data_registry {
      * @return persistent|false It return a 'purpose' instance or a 'category' instance, depending on $element
      */
     public static function get_effective_context_value(\context $context, $element, $forcedvalue = false) {
+        global $DB;
 
         if ($element !== 'purpose' && $element !== 'category') {
             throw new coding_exception('Only \'purpose\' and \'category\' are supported.');
         }
         $fieldname = $element . 'id';
 
-        if (empty($forcedvalue)) {
-            $instance = context_instance::get_record_by_contextid($context->id, false);
-
-            if (!$instance) {
-                // If the instance does not have a value defaults to not set, so we grab the context level default as its value.
-                $instancevalue = context_instance::NOTSET;
-            } else {
-                $instancevalue = $instance->get($fieldname);
-            }
+        if (!empty($forcedvalue) && ($forcedvalue === context_instance::INHERIT)) {
+            // Do not include the current context when calculating the value.
+            // This has the effect that an inheritted value is calculated.
+            $parentcontextids = $context->get_parent_context_ids(false);
+        } else if (!empty($forcedvalue) && ($forcedvalue !== context_instance::NOTSET)) {
+            return self::get_element_instance($element, $forcedvalue);
         } else {
-            $instancevalue = $forcedvalue;
+            // Fetch all parent contexts, including self.
+            $parentcontextids = $context->get_parent_context_ids(true);
         }
+        list($insql, $inparams) = $DB->get_in_or_equal($parentcontextids, SQL_PARAMS_NAMED);
+        $inparams['contextmodule'] = CONTEXT_MODULE;
 
-        // Not set.
-        if ($instancevalue == context_instance::NOTSET) {
-
-            // The effective value varies depending on the context level.
-            if ($context->contextlevel == CONTEXT_USER) {
-                // Use the context level value as we don't allow people to set specific instances values.
+        if ('purpose' === $element) {
+             $elementjoin = 'LEFT JOIN {tool_dataprivacy_purpose} ele ON ctxins.purposeid = ele.id';
+             $elementfields = purpose::get_sql_fields('ele', 'ele');
+        } else {
+             $elementjoin = 'LEFT JOIN {tool_dataprivacy_category} ele ON ctxins.categoryid = ele.id';
+             $elementfields = category::get_sql_fields('ele', 'ele');
+        }
+        $contextfields = \context_helper::get_preload_record_columns_sql('ctx');
+        $fields = implode(', ', ['ctx.id', 'm.name AS modname', $contextfields, $elementfields]);
+
+        $sql = "SELECT $fields
+                  FROM {context} ctx
+             LEFT JOIN {tool_dataprivacy_ctxinstance} ctxins ON ctx.id = ctxins.contextid
+             LEFT JOIN {course_modules} cm ON ctx.contextlevel = :contextmodule AND ctx.instanceid = cm.id
+             LEFT JOIN {modules} m ON m.id = cm.module
+             {$elementjoin}
+                 WHERE ctx.id {$insql}
+              ORDER BY ctx.path DESC";
+        $contextinstances = $DB->get_records_sql($sql, $inparams);
+
+        // Check whether this context is a user context, or a child of a user context.
+        // All children of a User context share the same context and cannot be set individually.
+        foreach ($contextinstances as $record) {
+            \context_helper::preload_from_record($record);
+            $parent = \context::instance_by_id($record->id, false);
+
+            if ($parent->contextlevel == CONTEXT_USER) {
+                // Use the context level value for the user.
                 return self::get_effective_contextlevel_value(CONTEXT_USER, $element);
             }
+        }
 
-            $parents = $context->get_parent_contexts(true);
-            foreach ($parents as $parent) {
-                if ($parent->contextlevel == CONTEXT_USER) {
-                    // Use the context level value as we don't allow people to set specific instances values.
-                    return self::get_effective_contextlevel_value(CONTEXT_USER, $element);
-                }
+        foreach ($contextinstances as $record) {
+            $parent = \context::instance_by_id($record->id, false);
+
+            $checkcontextlevel = false;
+            if (empty($record->eleid)) {
+                $checkcontextlevel = true;
             }
 
-            // Check if we need to pass the plugin name of an activity.
-            $forplugin = '';
-            if ($context->contextlevel == CONTEXT_MODULE) {
-                list($course, $cm) = get_course_and_cm_from_cmid($context->instanceid);
-                $forplugin = $cm->modname;
+            if (!empty($forcedvalue) && context_instance::NOTSET === $forcedvalue) {
+                $checkcontextlevel = true;
             }
-            // Use the default context level value.
-            list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category(
-                $context->contextlevel, false, false, $forplugin
-            );
 
-            return self::get_element_instance($element, $$fieldname);
-        }
+            if ($checkcontextlevel) {
+                // Check for a value at the contextlevel
+                $forplugin = empty($record->modname) ? '' : $record->modname;
+                list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category(
+                        $parent->contextlevel, false, false, $forplugin);
 
-        // Specific value for this context instance.
-        if ($instancevalue != context_instance::INHERIT) {
-            return self::get_element_instance($element, $instancevalue);
-        }
+                $instancevalue = $$fieldname;
 
-        // This context is using inherited so let's return the parent effective value.
-        $parentcontext = $context->get_parent_context();
-        if (!$parentcontext) {
-            return false;
+                if (context_instance::NOTSET !== $instancevalue && context_instance::INHERIT !== $instancevalue) {
+                    // There is an actual value. Return it.
+                    return self::get_element_instance($element, $instancevalue);
+                }
+            } else {
+                $elementclass = "\\tool_dataprivacy\\{$element}";
+                $instance = new $elementclass(null, $elementclass::extract_record($record, 'ele'));
+                $instance->validate();
+
+                return $instance;
+            }
         }
 
-        // The forced value should not be transmitted to parent contexts.
-        return self::get_effective_context_value($parentcontext, $element);
+        throw new coding_exception('Something went wrong, system defaults should be set and we should already have a value.');
     }
 
     /**
@@ -264,11 +274,9 @@ class data_registry {
      *
      * @param int $contextlevel
      * @param string $element 'category' or 'purpose'
-     * @param int $forcedvalue Use this value as if this was this context level purpose.
      * @return \tool_dataprivacy\purpose|false
      */
-    public static function get_effective_contextlevel_value($contextlevel, $element, $forcedvalue = false) {
-
+    public static function get_effective_contextlevel_value($contextlevel, $element) {
         if ($element !== 'purpose' && $element !== 'category') {
             throw new coding_exception('Only \'purpose\' and \'category\' are supported.');
         }
@@ -279,39 +287,15 @@ class data_registry {
                 'have a purpose or a category.');
         }
 
-        if ($forcedvalue === false) {
-            $instance = contextlevel::get_record_by_contextlevel($contextlevel, false);
-            if (!$instance) {
-                // If the context level does not have a value defaults to not set, so we grab the context level default as
-                // its value.
-                $instancevalue = context_instance::NOTSET;
-            } else {
-                $instancevalue = $instance->get($fieldname);
-            }
-        } else {
-            $instancevalue = $forcedvalue;
-        }
+        list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category($contextlevel);
 
-        // Not set -> Use the default context level value.
-        if ($instancevalue == context_instance::NOTSET) {
-            list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category($contextlevel);
+        // Note: The $$fieldname points to either $purposeid, or $categoryid.
+        if (context_instance::NOTSET !== $$fieldname && context_instance::INHERIT !== $$fieldname) {
+            // There is a specific value set.
             return self::get_element_instance($element, $$fieldname);
         }
 
-        // Specific value for this context instance.
-        if ($instancevalue != context_instance::INHERIT) {
-            return self::get_element_instance($element, $instancevalue);
-        }
-
-        if ($contextlevel == CONTEXT_SYSTEM) {
-            throw new coding_exception('Something went wrong, system defaults should be set and we should already have a value.');
-        }
-
-        // If we reach this point is that we are inheriting so get the parent context level and repeat.
-        $parentcontextlevel = reset(self::$contextlevelinheritance[$contextlevel]);
-
-        // Forced value are intentionally not passed as the force value should only affect the immediate context level.
-        return self::get_effective_contextlevel_value($parentcontextlevel, $element);
+        throw new coding_exception('Something went wrong, system defaults should be set and we should already have a value.');
     }
 
     /**
@@ -320,13 +304,13 @@ class data_registry {
      * @param int $contextlevel
      * @param int|bool $forcedpurposevalue Use this value as if this was this context level purpose.
      * @param int|bool $forcedcategoryvalue Use this value as if this was this context level category.
-     * @param string $activity The plugin name of the activity.
+     * @param string $component The name of the component to check.
      * @return int[]
      */
     public static function get_effective_default_contextlevel_purpose_and_category($contextlevel, $forcedpurposevalue = false,
-                                                                                   $forcedcategoryvalue = false, $activity = '') {
-
-        list($purposeid, $categoryid) = self::get_defaults($contextlevel, $activity);
+                                                                                   $forcedcategoryvalue = false, $component = '') {
+        // Get the defaults for this context level.
+        list($purposeid, $categoryid) = self::get_defaults($contextlevel, $component);
 
         // Honour forced values.
         if ($forcedpurposevalue) {
@@ -336,37 +320,19 @@ class data_registry {
             $categoryid = $forcedcategoryvalue;
         }
 
-        // Not set == INHERIT for defaults.
-        if ($purposeid == context_instance::INHERIT || $purposeid == context_instance::NOTSET) {
-            $purposeid = false;
-        }
-        if ($categoryid == context_instance::INHERIT || $categoryid == context_instance::NOTSET) {
-            $categoryid = false;
-        }
-
-        if ($contextlevel != CONTEXT_SYSTEM && ($purposeid === false || $categoryid === false)) {
-            foreach (self::$contextlevelinheritance[$contextlevel] as $parent) {
+        if ($contextlevel == CONTEXT_USER) {
+            // Only user context levels inherit from a parent context level.
+            list($parentpurposeid, $parentcategoryid) = self::get_defaults(CONTEXT_SYSTEM);
 
-                list($parentpurposeid, $parentcategoryid) = self::get_defaults($parent);
-                // Not set == INHERIT for defaults.
-                if ($parentpurposeid == context_instance::INHERIT || $parentpurposeid == context_instance::NOTSET) {
-                    $parentpurposeid = false;
-                }
-                if ($parentcategoryid == context_instance::INHERIT || $parentcategoryid == context_instance::NOTSET) {
-                    $parentcategoryid = false;
-                }
-
-                if ($purposeid === false && $parentpurposeid) {
-                    $purposeid = $parentpurposeid;
-                }
+            if (context_instance::INHERIT == $purposeid || context_instance::NOTSET == $purposeid) {
+                $purposeid = $parentpurposeid;
+            }
 
-                if ($categoryid === false && $parentcategoryid) {
-                    $categoryid = $parentcategoryid;
-                }
+            if (context_instance::INHERIT == $categoryid || context_instance::NOTSET == $categoryid) {
+                $categoryid = $parentcategoryid;
             }
         }
 
-        // They may still be false, but we return anyway.
         return [$purposeid, $categoryid];
     }
 
@@ -379,7 +345,6 @@ class data_registry {
      * @return \core\persistent
      */
     private static function get_element_instance($element, $id) {
-
         if ($element !== 'purpose' && $element !== 'category') {
             throw new coding_exception('No other elements than purpose and category are allowed');
         }
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 bd1204f..c5cd3c9 100644 (file)
@@ -145,12 +145,12 @@ class context_instance extends \core\form\persistent {
             $persistent->set('contextid', $context->id);
         }
 
-        $purposeoptions = \tool_dataprivacy\output\data_registry_page::purpose_options(
-            api::get_purposes()
-        );
-        $categoryoptions = \tool_dataprivacy\output\data_registry_page::category_options(
-            api::get_categories()
-        );
+        $purposes = [];
+        foreach (api::get_purposes() as $purpose) {
+            $purposes[$purpose->get('id')] = $purpose;
+        }
+        $purposeoptions = \tool_dataprivacy\output\data_registry_page::purpose_options($purposes);
+        $categoryoptions = \tool_dataprivacy\output\data_registry_page::category_options(api::get_categories());
 
         $customdata = [
             'context' => $context,
@@ -168,9 +168,14 @@ class context_instance extends \core\form\persistent {
                 $context);
 
             $customdata['purposeretentionperiods'] = [];
-            foreach ($purposeoptions as $optionvalue => $unused) {
-                // Get the effective purpose if $optionvalue would be the selected value.
-                $purpose = api::get_effective_context_purpose($context, $optionvalue);
+            foreach (array_keys($purposeoptions) as $optionvalue) {
+
+                if (isset($purposes[$optionvalue])) {
+                    $purpose = $purposes[$optionvalue];
+                } else {
+                    // Get the effective purpose if $optionvalue would be the selected value.
+                    $purpose = api::get_effective_context_purpose($context, $optionvalue);
+                }
 
                 $retentionperiod = self::get_retention_display_text(
                     $purpose,
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..f0617d9 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;
@@ -1147,11 +1150,6 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->assertEquals($purposes[0]->get('id'), $purposeid);
         $this->assertEquals(false, $categoryid);
 
-        // Course inherits from system if not defined.
-        list($purposeid, $categoryid) = data_registry::get_effective_default_contextlevel_purpose_and_category(CONTEXT_COURSE);
-        $this->assertEquals($purposes[0]->get('id'), $purposeid);
-        $this->assertEquals(false, $categoryid);
-
         // Course defined values should have preference.
         list($purposevar, $categoryvar) = data_registry::var_names_from_context(
             \context_helper::get_class_for_level(CONTEXT_COURSE)
@@ -1165,159 +1163,293 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
 
         // Context level defaults are also allowed to be set to 'inherit'.
         set_config($purposevar, context_instance::INHERIT, 'tool_dataprivacy');
+    }
 
-        list($purposeid, $categoryid) = data_registry::get_effective_default_contextlevel_purpose_and_category(CONTEXT_COURSE);
-        $this->assertEquals($purposes[0]->get('id'), $purposeid);
-        $this->assertEquals($categories[0]->get('id'), $categoryid);
+    /**
+     * Ensure that when nothing is configured, all values return false.
+     */
+    public function test_get_effective_contextlevel_unset() {
+        // Before setup, get_effective_contextlevel_purpose will return false.
+        $this->assertFalse(api::get_effective_contextlevel_category(CONTEXT_SYSTEM));
+        $this->assertFalse(api::get_effective_contextlevel_purpose(CONTEXT_SYSTEM));
 
-        list($purposeid, $categoryid) = data_registry::get_effective_default_contextlevel_purpose_and_category(CONTEXT_MODULE);
-        $this->assertEquals($purposes[0]->get('id'), $purposeid);
-        $this->assertEquals($categories[0]->get('id'), $categoryid);
+        $this->assertFalse(api::get_effective_contextlevel_category(CONTEXT_USER));
+        $this->assertFalse(api::get_effective_contextlevel_purpose(CONTEXT_USER));
     }
 
-    public function test_get_effective_contextlevel_category() {
+    /**
+     * Ensure that when nothing is configured, all values return false.
+     */
+    public function test_get_effective_context_unset() {
         // Before setup, get_effective_contextlevel_purpose will return false.
-        $this->assertFalse(api::get_effective_contextlevel_category(CONTEXT_SYSTEM));
+        $this->assertFalse(api::get_effective_context_category(\context_system::instance()));
+        $this->assertFalse(api::get_effective_context_purpose(\context_system::instance()));
+    }
+
+    /**
+     * Ensure that fetching the effective value for context levels is only available to system, and user context levels.
+     *
+     * @dataProvider invalid_effective_contextlevel_provider
+     * @param   int $contextlevel
+     */
+    public function test_set_contextlevel_invalid_contextlevels($contextlevel) {
+
+        $this->expectException(coding_exception::class);
+        api::set_contextlevel((object) [
+                'contextlevel' => $contextlevel,
+            ]);
+
     }
 
     /**
      * Test effective contextlevel return.
      */
     public function test_effective_contextlevel() {
-        $this->setAdminUser();
-
         $this->resetAfterTest();
 
-        // Before setup, get_effective_contextlevel_purpose will return false.
-        $this->assertFalse(api::get_effective_contextlevel_purpose(CONTEXT_SYSTEM));
+        // Set the initial purpose and category.
+        $purpose1 = api::create_purpose((object)['name' => 'p1', 'retentionperiod' => 'PT1H', 'lawfulbases' => 'gdpr_art_6_1_a']);
+        $category1 = api::create_category((object)['name' => 'a']);
+        api::set_contextlevel((object)[
+            'contextlevel' => CONTEXT_SYSTEM,
+            'purposeid' => $purpose1->get('id'),
+            'categoryid' => $category1->get('id'),
+        ]);
 
-        list($purposes, $categories, $courses, $modules) = $this->add_purposes_and_categories();
+        $this->assertEquals($purpose1, api::get_effective_contextlevel_purpose(CONTEXT_SYSTEM));
+        $this->assertEquals($category1, api::get_effective_contextlevel_category(CONTEXT_SYSTEM));
 
-        // Set the system context level to purpose 1.
-        $record = (object)[
-            'contextlevel' => CONTEXT_SYSTEM,
-            'purposeid' => $purposes[1]->get('id'),
-            'categoryid' => $categories[1]->get('id'),
-        ];
-        api::set_contextlevel($record);
+        // The user context inherits from the system context when not set.
+        $this->assertEquals($purpose1, api::get_effective_contextlevel_purpose(CONTEXT_USER));
+        $this->assertEquals($category1, api::get_effective_contextlevel_category(CONTEXT_USER));
 
-        $purpose = api::get_effective_contextlevel_purpose(CONTEXT_SYSTEM);
-        $this->assertEquals($purposes[1]->get('id'), $purpose->get('id'));
+        // Forcing the behaviour to inherit will have the same result.
+        api::set_contextlevel((object) [
+                'contextlevel' => CONTEXT_USER,
+                'purposeid' => context_instance::INHERIT,
+                'categoryid' => context_instance::INHERIT,
+            ]);
+        $this->assertEquals($purpose1, api::get_effective_contextlevel_purpose(CONTEXT_USER));
+        $this->assertEquals($category1, api::get_effective_contextlevel_category(CONTEXT_USER));
 
-        // Value 'not set' will get the default value for the context level. For context level defaults
-        // both 'not set' and 'inherit' result in inherit, so the parent context (system) default
-        // will be retrieved.
-        $purpose = api::get_effective_contextlevel_purpose(CONTEXT_USER);
-        $this->assertEquals($purposes[1]->get('id'), $purpose->get('id'));
+        // Setting specific values will override the inheritance behaviour.
+        $purpose2 = api::create_purpose((object)['name' => 'p2', 'retentionperiod' => 'PT2H', 'lawfulbases' => 'gdpr_art_6_1_a']);
+        $category2 = api::create_category((object)['name' => 'b']);
+        // Set the system context level to purpose 1.
+        api::set_contextlevel((object) [
+                'contextlevel' => CONTEXT_USER,
+                'purposeid' => $purpose2->get('id'),
+                'categoryid' => $category2->get('id'),
+            ]);
 
-        // The behaviour forcing an inherit from context system should result in the same effective
-        // purpose.
-        $record->purposeid = context_instance::INHERIT;
-        $record->contextlevel = CONTEXT_USER;
-        api::set_contextlevel($record);
-        $purpose = api::get_effective_contextlevel_purpose(CONTEXT_USER);
-        $this->assertEquals($purposes[1]->get('id'), $purpose->get('id'));
+        $this->assertEquals($purpose2, api::get_effective_contextlevel_purpose(CONTEXT_USER));
+        $this->assertEquals($category2, api::get_effective_contextlevel_category(CONTEXT_USER));
+    }
 
-        $record->purposeid = $purposes[2]->get('id');
-        $record->contextlevel = CONTEXT_USER;
-        api::set_contextlevel($record);
+    /**
+     * Ensure that fetching the effective value for context levels is only available to system, and user context levels.
+     *
+     * @dataProvider invalid_effective_contextlevel_provider
+     * @param   int $contextlevel
+     */
+    public function test_effective_contextlevel_invalid_contextlevels($contextlevel) {
+        $this->resetAfterTest();
 
-        $purpose = api::get_effective_contextlevel_purpose(CONTEXT_USER);
-        $this->assertEquals($purposes[2]->get('id'), $purpose->get('id'));
+        $purpose1 = api::create_purpose((object)['name' => 'p1', 'retentionperiod' => 'PT1H', 'lawfulbases' => 'gdpr_art_6_1_a']);
+        $category1 = api::create_category((object)['name' => 'a']);
+        api::set_contextlevel((object)[
+            'contextlevel' => CONTEXT_SYSTEM,
+            'purposeid' => $purpose1->get('id'),
+            'categoryid' => $category1->get('id'),
+        ]);
 
-        // Only system and user allowed.
         $this->expectException(coding_exception::class);
-        $record->contextlevel = CONTEXT_COURSE;
-        $record->purposeid = $purposes[1]->get('id');
-        api::set_contextlevel($record);
+        api::get_effective_contextlevel_purpose($contextlevel);
     }
 
     /**
-     * Test effective context purposes and categories.
-     *
-     * @return null
+     * Data provider for invalid contextlevel fetchers.
+     */
+    public function invalid_effective_contextlevel_provider() {
+        return [
+            [CONTEXT_COURSECAT],
+            [CONTEXT_COURSE],
+            [CONTEXT_MODULE],
+            [CONTEXT_BLOCK],
+        ];
+    }
+
+    /**
+     * Ensure that context inheritance works up the context tree.
      */
-    public function test_effective_context() {
+    public function test_effective_context_inheritance() {
         $this->resetAfterTest();
 
-        $this->setAdminUser();
+        $systemdata = $this->create_and_set_purpose_for_contextlevel('PT1S', CONTEXT_SYSTEM);
+
+        /*
+         * System
+         * - Cat
+         *   - Subcat
+         *     - Course
+         *       - Forum
+         * - User
+         *   - User block
+         */
+        $cat = $this->getDataGenerator()->create_category();
+        $subcat = $this->getDataGenerator()->create_category(['parent' => $cat->id]);
+        $course = $this->getDataGenerator()->create_course(['category' => $subcat->id]);
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        list(, $forumcm) = get_course_and_cm_from_instance($forum->id, 'forum');
 
-        list($purposes, $categories, $courses, $modules) = $this->add_purposes_and_categories();
+        $user = $this->getDataGenerator()->create_user();
 
-        // Define system defaults (all context levels below will inherit).
-        list($purposevar, $categoryvar) = data_registry::var_names_from_context(
-            \context_helper::get_class_for_level(CONTEXT_SYSTEM)
-        );
-        set_config($purposevar, $purposes[0]->get('id'), 'tool_dataprivacy');
-        set_config($categoryvar, $categories[0]->get('id'), 'tool_dataprivacy');
+        $contextsystem = \context_system::instance();
+        $contextcat = \context_coursecat::instance($cat->id);
+        $contextsubcat = \context_coursecat::instance($subcat->id);
+        $contextcourse = \context_course::instance($course->id);
+        $contextforum = \context_module::instance($forumcm->id);
+        $contextuser = \context_user::instance($user->id);
+
+        // Initially everything is set to Inherit.
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextcat));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsubcat));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextcourse));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextforum));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextuser));
+
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextcat));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsubcat));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextcourse));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextforum));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextuser));
+
+        // When actively set, user will use the specified value.
+        $userdata = $this->create_and_set_purpose_for_contextlevel('PT1S', CONTEXT_USER);
+
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextcat));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsubcat));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextcourse));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextforum));
+        $this->assertEquals($userdata->purpose, api::get_effective_context_purpose($contextuser));
+
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextcat));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsubcat));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextcourse));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextforum));
+        $this->assertEquals($userdata->category, api::get_effective_context_category($contextuser));
+
+        // Set a context for the top category.
+        $catpurpose = new purpose(0, (object) [
+                'name' => 'Purpose',
+                'retentionperiod' => 'P1D',
+                'lawfulbases' => 'gdpr_art_6_1_a',
+            ]);
+        $catpurpose->save();
+        $catcategory = new category(0, (object) ['name' => 'Category']);
+        $catcategory->save();
+        api::set_context_instance((object) [
+                'contextid' => $contextcat->id,
+                'purposeid' => $catpurpose->get('id'),
+                'categoryid' => $catcategory->get('id'),
+            ]);
 
-        // Define course defaults.
-        list($purposevar, $categoryvar) = data_registry::var_names_from_context(
-            \context_helper::get_class_for_level(CONTEXT_COURSE)
-        );
-        set_config($purposevar, $purposes[1]->get('id'), 'tool_dataprivacy');
-        set_config($categoryvar, $categories[1]->get('id'), 'tool_dataprivacy');
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+        $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextcat));
+        $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextsubcat));
+        $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextcourse));
+        $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextforum));
+
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+        $this->assertEquals($catcategory, api::get_effective_context_category($contextcat));
+        $this->assertEquals($catcategory, api::get_effective_context_category($contextsubcat));
+        $this->assertEquals($catcategory, api::get_effective_context_category($contextcourse));
+        $this->assertEquals($catcategory, api::get_effective_context_category($contextforum));
+
+        // Set a context for the sub category.
+        $subcatpurpose = new purpose(0, (object) [
+                'name' => 'Purpose',
+                'retentionperiod' => 'P1D',
+                'lawfulbases' => 'gdpr_art_6_1_a',
+            ]);
+        $subcatpurpose->save();
+        $subcatcategory = new category(0, (object) ['name' => 'Category']);
+        $subcatcategory->save();
+        api::set_context_instance((object) [
+                'contextid' => $contextsubcat->id,
+                'purposeid' => $subcatpurpose->get('id'),
+                'categoryid' => $subcatcategory->get('id'),
+            ]);
 
-        $course0context = \context_course::instance($courses[0]->id);
-        $course1context = \context_course::instance($courses[1]->id);
-        $mod0context = \context_module::instance($modules[0]->cmid);
-        $mod1context = \context_module::instance($modules[1]->cmid);
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+        $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextcat));
+        $this->assertEquals($subcatpurpose, api::get_effective_context_purpose($contextsubcat));
+        $this->assertEquals($subcatpurpose, api::get_effective_context_purpose($contextcourse));
+        $this->assertEquals($subcatpurpose, api::get_effective_context_purpose($contextforum));
+
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+        $this->assertEquals($catcategory, api::get_effective_context_category($contextcat));
+        $this->assertEquals($subcatcategory, api::get_effective_context_category($contextsubcat));
+        $this->assertEquals($subcatcategory, api::get_effective_context_category($contextcourse));
+        $this->assertEquals($subcatcategory, api::get_effective_context_category($contextforum));
+
+        // Set a context for the course.
+        $coursepurpose = new purpose(0, (object) [
+                'name' => 'Purpose',
+                'retentionperiod' => 'P1D',
+                'lawfulbases' => 'gdpr_art_6_1_a',
+            ]);
+        $coursepurpose->save();
+        $coursecategory = new category(0, (object) ['name' => 'Category']);
+        $coursecategory->save();
+        api::set_context_instance((object) [
+                'contextid' => $contextcourse->id,
+                'purposeid' => $coursepurpose->get('id'),
+                'categoryid' => $coursecategory->get('id'),
+            ]);
 
-        // Set course instance values.
-        $record = (object)[
-            'contextid' => $course0context->id,
-            'purposeid' => $purposes[1]->get('id'),
-            'categoryid' => $categories[2]->get('id'),
-        ];
-        api::set_context_instance($record);
-        $category = api::get_effective_context_category($course0context);
-        $this->assertEquals($record->categoryid, $category->get('id'));
-
-        // Module instances get the context level default if nothing specified.
-        $category = api::get_effective_context_category($mod0context);
-        $this->assertEquals($categories[1]->get('id'), $category->get('id'));
-
-        // Module instances get the parent context category if they inherit.
-        $record->contextid = $mod0context->id;
-        $record->categoryid = context_instance::INHERIT;
-        api::set_context_instance($record);
-        $category = api::get_effective_context_category($mod0context);
-        $this->assertEquals($categories[2]->get('id'), $category->get('id'));
-
-        // The $forcedvalue param allows us to override the actual value (method php-docs for more info).
-        $category = api::get_effective_context_category($mod0context, $categories[1]->get('id'));
-        $this->assertEquals($categories[1]->get('id'), $category->get('id'));
-        $category = api::get_effective_context_category($mod0context, $categories[0]->get('id'));
-        $this->assertEquals($categories[0]->get('id'), $category->get('id'));
-
-        // Module instances get the parent context category if they inherit; in
-        // this case the parent context category is not set so it should use the
-        // context level default (see 'Define course defaults' above).
-        $record->contextid = $mod1context->id;
-        $record->categoryid = context_instance::INHERIT;
-        api::set_context_instance($record);
-        $category = api::get_effective_context_category($mod1context);
-        $this->assertEquals($categories[1]->get('id'), $category->get('id'));
-
-        // User instances use the value set at user context level instead of the user default.
-
-        // User defaults to cat 0 and user context level to 1.
-        list($purposevar, $categoryvar) = data_registry::var_names_from_context(
-            \context_helper::get_class_for_level(CONTEXT_USER)
-        );
-        set_config($purposevar, $purposes[0]->get('id'), 'tool_dataprivacy');
-        set_config($categoryvar, $categories[0]->get('id'), 'tool_dataprivacy');
-        $usercontextlevel = (object)[
-            'contextlevel' => CONTEXT_USER,
-            'purposeid' => $purposes[1]->get('id'),
-            'categoryid' => $categories[1]->get('id'),
-        ];
-        api::set_contextlevel($usercontextlevel);
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+        $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextcat));
+        $this->assertEquals($subcatpurpose, api::get_effective_context_purpose($contextsubcat));
+        $this->assertEquals($coursepurpose, api::get_effective_context_purpose($contextcourse));
+        $this->assertEquals($coursepurpose, api::get_effective_context_purpose($contextforum));
+
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+        $this->assertEquals($catcategory, api::get_effective_context_category($contextcat));
+        $this->assertEquals($subcatcategory, api::get_effective_context_category($contextsubcat));
+        $this->assertEquals($coursecategory, api::get_effective_context_category($contextcourse));
+        $this->assertEquals($coursecategory, api::get_effective_context_category($contextforum));
+
+        // Set a context for the forum.
+        $forumpurpose = new purpose(0, (object) [
+                'name' => 'Purpose',
+                'retentionperiod' => 'P1D',
+                'lawfulbases' => 'gdpr_art_6_1_a',
+            ]);
+        $forumpurpose->save();
+        $forumcategory = new category(0, (object) ['name' => 'Category']);
+        $forumcategory->save();
+        api::set_context_instance((object) [
+                'contextid' => $contextforum->id,
+                'purposeid' => $forumpurpose->get('id'),
+                'categoryid' => $forumcategory->get('id'),
+            ]);
 
-        $newuser = $this->getDataGenerator()->create_user();
-        $usercontext = \context_user::instance($newuser->id);
-        $category = api::get_effective_context_category($usercontext);
-        $this->assertEquals($categories[1]->get('id'), $category->get('id'));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+        $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextcat));
+        $this->assertEquals($subcatpurpose, api::get_effective_context_purpose($contextsubcat));
+        $this->assertEquals($coursepurpose, api::get_effective_context_purpose($contextcourse));
+        $this->assertEquals($forumpurpose, api::get_effective_context_purpose($contextforum));
+
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+        $this->assertEquals($catcategory, api::get_effective_context_category($contextcat));
+        $this->assertEquals($subcatcategory, api::get_effective_context_category($contextsubcat));
+        $this->assertEquals($coursecategory, api::get_effective_context_category($contextcourse));
+        $this->assertEquals($forumcategory, api::get_effective_context_category($contextforum));
     }
 
     /**
@@ -1376,6 +1508,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->purpose->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->purpose->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->purpose->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->purpose->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->purpose->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->purpose->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->purpose->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->purpose->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->purpose->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->purpose->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->purpose->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->purpose->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 +1989,68 @@ 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
+     */
+    protected function create_and_set_purpose_for_contextlevel(string $retention, int $contextlevel) {
+        $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 (object) [
+            'purpose' => $purpose,
+            'category' => $cat,
+        ];
+    }
 }
index a6287d5..0bb5e75 100644 (file)
@@ -32,12 +32,11 @@ Feature: Manage data registry defaults
       | Purpose 2    | P5Y            |
     And I set the site category and purpose to "Site category" and "Site purpose"
 
+  # Setting a default for course categories should apply to everything beneath that category.
   Scenario: Set course category data registry defaults
-    Given I set the category and purpose for the course category "scitech" to "Category 2" and "Purpose 2"
-    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    Given I navigate to "Users > Privacy and policies > Data registry" in site administration
     And I click on "Set defaults" "link"
     And I should see "Inherit"
-    And I should not see "Add a new module default"
     And I press "Edit"
     And I set the field "Category" to "Category 1"
     And I set the field "Purpose" to "Purpose 1"
@@ -47,27 +46,91 @@ Feature: Manage data registry defaults
     And I navigate to "Users > Privacy and policies > Data registry" in site administration
     And I click on "Science and technology" "link"
     And I wait until the page is ready
-    And the field "categoryid" matches value "Category 2"
+    And the field "categoryid" matches value "Not set (use the default value)"
+    And the field "purposeid" matches value "Not set (use the default value)"
+    And I should see "3 years"
+    And I click on "Courses" "link"
+    And I wait until the page is ready
+    And I click on "Physics 101" "link"
+    And I wait until the page is ready
+    And I should see "3 years"
+    And I click on "Activities and resources" "link"
+    And I wait until the page is ready
+    And I should see "3 years"
+    And I click on "Assignment 1 (Assignment)" "link"
+    And I wait until the page is ready
+    And I should see "3 years"
+
+  # When Setting a default for course categories, and overriding a specific category, only that category and its
+  # children will be overridden.
+  # If any child is a course category, it will get the default.
+  Scenario: Set course category data registry defaults with override
+    Given I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Set defaults" "link"
+    And I press "Edit"
+    And I set the field "Category" to "Category 1"
+    And I set the field "Purpose" to "Purpose 1"
+    And I press "Save changes"
+    And I should see "Category 1"
+    And I should see "Purpose 1"
+    And I set the category and purpose for the course category "scitech" to "Category 2" and "Purpose 2"
+    When I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Science and technology" "link"
+    And I wait until the page is ready
+    Then the field "categoryid" matches value "Category 2"
+    And the field "purposeid" matches value "Purpose 2"
+    And I should see "5 years"
+    And I click on "Courses" "link"
+    And I wait until the page is ready
+    # Physics 101 is also a category, so it will get the category default.
+    And I click on "Physics 101" "link"
+    And I wait until the page is ready
+    And I should see "3 years"
+    And I click on "Activities and resources" "link"
+    And I wait until the page is ready
+    And I should see "3 years"
+    And I click on "Assignment 1 (Assignment)" "link"
+    And I wait until the page is ready
+    And I should see "3 years"
+
+  # When overriding a specific category, only that category and its children will be overridden.
+  Scenario: Set course category data registry defaults with override
+    Given I set the category and purpose for the course category "scitech" to "Category 2" and "Purpose 2"
+    When I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Science and technology" "link"
+    And I wait until the page is ready
+    Then the field "categoryid" matches value "Category 2"
     And the field "purposeid" matches value "Purpose 2"
     And I should see "5 years"
+    And I click on "Courses" "link"
+    And I wait until the page is ready
+    # Physics 101 is also a category, so it will get the category default.
+    And I click on "Physics 101" "link"
+    And I wait until the page is ready
+    And I should see "5 years"
+    And I click on "Activities and resources" "link"
+    And I wait until the page is ready
+    And I should see "5 years"
+    And I click on "Assignment 1 (Assignment)" "link"
+    And I wait until the page is ready
+    And I should see "5 years"
 
+  # Resetting instances removes custom values.
   Scenario: Set course category data registry defaults with override
     Given I set the category and purpose for the course category "scitech" to "Category 2" and "Purpose 2"
     And I navigate to "Users > Privacy and policies > Data registry" in site administration
     And I click on "Set defaults" "link"
-    And I should see "Inherit"
-    And I should not see "Add a new module default"
     And I press "Edit"
     And I set the field "Category" to "Category 1"
     And I set the field "Purpose" to "Purpose 1"
-    And I click on "Reset instances with custom values" "checkbox"
-    When I press "Save changes"
-    Then I should see "Category 1"
+    When I click on "Reset instances with custom values" "checkbox"
+    And I press "Save changes"
+    And I should see "Category 1"
     And I should see "Purpose 1"
     And I navigate to "Users > Privacy and policies > Data registry" in site administration
     And I click on "Science and technology" "link"
     And I wait until the page is ready
-    And the field "categoryid" matches value "Not set (use the default value)"
+    Then the field "categoryid" matches value "Not set (use the default value)"
     And the field "purposeid" matches value "Not set (use the default value)"
     And I should see "3 years"
 
@@ -94,6 +157,12 @@ Feature: Manage data registry defaults
     And the field "categoryid" matches value "Category 2"
     And the field "purposeid" matches value "Purpose 2"
     And I should see "5 years (after the course end date)"
+    And I click on "Activities and resources" "link"
+    And I wait until the page is ready
+    And I should see "5 years"
+    And I click on "Assignment 1 (Assignment)" "link"
+    And I wait until the page is ready
+    And I should see "5 years"
 
   Scenario: Set course data registry defaults with override
     Given I set the category and purpose for the course "Physics 101" to "Category 2" and "Purpose 2"
@@ -119,6 +188,12 @@ Feature: Manage data registry defaults
     And the field "categoryid" matches value "Not set (use the default value)"
     And the field "purposeid" matches value "Not set (use the default value)"
     And I should see "3 years (after the course end date)"
+    And I click on "Activities and resources" "link"
+    And I wait until the page is ready
+    And I should see "3 years"
+    And I click on "Assignment 1 (Assignment)" "link"
+    And I wait until the page is ready
+    And I should see "3 years"
 
   Scenario: Set module level data registry defaults
     Given I set the category and purpose for the "assign1" "assign" in course "Physics 101" to "Category 2" and "Purpose 2"
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"
diff --git a/admin/tool/dataprivacy/tests/data_registry_test.php b/admin/tool/dataprivacy/tests/data_registry_test.php
new file mode 100644 (file)
index 0000000..b565f72
--- /dev/null
@@ -0,0 +1,57 @@
+<?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 data_registry class.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use \tool_dataprivacy\data_registry;
+
+/**
+ * Unit tests for the data_registry class.
+ *
+ * @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_dataregistry_testcase extends advanced_testcase {
+
+    /**
+     * Ensure that the get_effective_context_value only errors if provided an inappropriate element.
+     *
+     * This test is not great because we only test a limited set of values. This is a fault of the underlying API.
+     */
+    public function test_get_effective_context_value_invalid_element() {
+        $this->expectException(coding_exception::class);
+        data_registry::get_effective_context_value(\context_system::instance(), 'invalid');
+    }
+
+    /**
+     * Ensure that the get_effective_contextlevel_value only errors if provided an inappropriate element.
+     *
+     * This test is not great because we only test a limited set of values. This is a fault of the underlying API.
+     */
+    public function test_get_effective_contextlevel_value_invalid_element() {
+        $this->expectException(coding_exception::class);
+        data_registry::get_effective_contextlevel_value(\context_system::instance(), 'invalid');
+    }
+}
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.
+