Merge branch 'MDL-63041-master' of git://github.com/bmbrands/moodle
authorDamyon Wiese <damyon@moodle.com>
Mon, 15 Oct 2018 05:29:31 +0000 (13:29 +0800)
committerDamyon Wiese <damyon@moodle.com>
Mon, 15 Oct 2018 05:29:31 +0000 (13:29 +0800)
166 files changed:
admin/filters.php
admin/message.php
admin/mnet/access_control.php
admin/mnet/delete.php
admin/mnet/index.php
admin/mnet/peers.php
admin/mnet/profilefields.php
admin/mnet/services.php
admin/mnet/testclient.php
admin/mnet/trustedhosts.php
admin/portfolio.php
admin/qtypes.php
admin/repository.php
admin/repositoryinstance.php
admin/roles/define.php
admin/roles/manage.php
admin/settings/appearance.php
admin/tool/dataprivacy/classes/api.php
admin/tool/dataprivacy/classes/data_registry.php
admin/tool/dataprivacy/classes/expired_context.php
admin/tool/dataprivacy/classes/expired_contexts_manager.php
admin/tool/dataprivacy/classes/expired_course_related_contexts.php [deleted file]
admin/tool/dataprivacy/classes/expired_user_contexts.php [deleted file]
admin/tool/dataprivacy/classes/expiry_info.php [new file with mode: 0644]
admin/tool/dataprivacy/classes/external.php
admin/tool/dataprivacy/classes/task/delete_expired_contexts.php
admin/tool/dataprivacy/classes/task/expired_retention_period.php
admin/tool/dataprivacy/createdatarequest.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/dataprivacy/settings.php
admin/tool/dataprivacy/templates/categories.mustache
admin/tool/dataprivacy/templates/purposes.mustache
admin/tool/dataprivacy/tests/api_test.php
admin/tool/dataprivacy/tests/expired_contexts_test.php
admin/tool/dataprivacy/tests/external_test.php
admin/tool/dataprivacy/version.php
admin/tool/dbtransfer/dbexport.php
admin/tool/dbtransfer/index.php
admin/tool/health/index.php
admin/tool/httpsreplace/index.php
admin/tool/httpsreplace/tool.php
admin/tool/innodb/index.php
admin/tool/log/store/database/test_settings.php
admin/tool/monitor/managerules.php
admin/tool/spamcleaner/index.php
admin/tool/unsuproles/index.php
admin/tool/uploaduser/index.php
admin/tool/uploaduser/picture.php
admin/tool/xmldb/index.php
admin/user/user_bulk_confirm.php
admin/user/user_bulk_delete.php
admin/user/user_bulk_download.php
admin/user/user_bulk_forcepasswordchange.php
admin/user/user_bulk_message.php
admin/webservice/tokens.php
auth/test_settings.php
blocks/myprofile/block_myprofile.php
blocks/myprofile/classes/output/myprofile.php [new file with mode: 0644]
blocks/myprofile/classes/output/renderer.php [new file with mode: 0644]
blocks/myprofile/styles.css
blocks/myprofile/templates/myprofile.mustache [new file with mode: 0644]
blog/classes/external.php [new file with mode: 0644]
blog/classes/external/post_exporter.php [new file with mode: 0644]
blog/index.php
blog/lib.php
blog/tests/external_test.php [new file with mode: 0644]
calendar/lib.php
comment/index.php
config-dist.php
course/classes/list_element.php
course/classes/output/activity_navigation.php
course/externallib.php
course/pending.php
course/renderer.php
course/tests/behat/behat_course.php
course/tests/behat/course_contact.feature [new file with mode: 0644]
course/tests/category_test.php
course/tests/externallib_test.php
course/upgrade.txt
enrol/test_settings.php
grade/edit/letter/index.php
install/lang/el/admin.php
install/lang/el/install.php
install/lang/sl/admin.php
lang/en/admin.php
lang/en/moodle.php
lang/en/privacy.php
lib/amd/build/autoscroll.min.js [new file with mode: 0644]
lib/amd/build/sortable_list.min.js [new file with mode: 0644]
lib/amd/build/tree.min.js
lib/amd/src/autoscroll.js [new file with mode: 0644]
lib/amd/src/sortable_list.js [new file with mode: 0644]
lib/amd/src/tree.js
lib/classes/shutdown_manager.php
lib/db/services.php
lib/editor/atto/plugins/emoticon/styles.css
lib/editor/tinymce/module.js
lib/editor/tinymce/readme_moodle.txt
lib/editor/tinymce/tiny_mce/3.5.11/themes/advanced/skins/moodle/content.css
lib/editor/tinymce/tiny_mce/3.5.11/themes/advanced/skins/moodle/dialog.css
lib/externallib.php
lib/jquery/readme_moodle.txt
lib/moodlelib.php
lib/requirejs/readme_moodle.txt
lib/templates/drag_handle.mustache [new file with mode: 0644]
lib/tests/externallib_test.php
lib/tests/moodlelib_test.php
lib/upgradelib.php
message/defaultoutputs.php
mod/assign/feedback/editpdf/classes/pdf.php
mod/assign/feedback/editpdf/tests/editpdf_test.php
mod/assign/submission/onlinetext/classes/privacy/provider.php
mod/forum/lib.php
mod/forum/styles.css
mod/forum/tests/behat/posts_ordering_blog.feature
mod/forum/upgrade.txt
mod/lti/classes/local/ltiservice/resource_base.php
mod/quiz/yui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop-debug.js
mod/quiz/yui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop-min.js
mod/quiz/yui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop.js
mod/quiz/yui/src/dragdrop/js/resource.js
my/indexsys.php
pix/s/angry.svg [new file with mode: 0644]
pix/s/approve.svg [new file with mode: 0644]
pix/s/biggrin.svg [new file with mode: 0644]
pix/s/blackeye.svg [new file with mode: 0644]
pix/s/blush.svg [new file with mode: 0644]
pix/s/clown.svg [new file with mode: 0644]
pix/s/cool.svg [new file with mode: 0644]
pix/s/dead.svg [new file with mode: 0644]
pix/s/egg.svg [new file with mode: 0644]
pix/s/evil.svg [new file with mode: 0644]
pix/s/heart.svg [new file with mode: 0644]
pix/s/kiss.svg [new file with mode: 0644]
pix/s/martin.svg [new file with mode: 0644]
pix/s/mixed.svg [new file with mode: 0644]
pix/s/no.svg [new file with mode: 0644]
pix/s/sad.svg [new file with mode: 0644]
pix/s/shy.svg [new file with mode: 0644]
pix/s/sleepy.svg [new file with mode: 0644]
pix/s/smiley.svg [new file with mode: 0644]
pix/s/surprise.svg [new file with mode: 0644]
pix/s/thoughtful.svg [new file with mode: 0644]
pix/s/tongueout.svg [new file with mode: 0644]
pix/s/wideeyes.svg [new file with mode: 0644]
pix/s/wink.svg [new file with mode: 0644]
pix/s/yes.svg [new file with mode: 0644]
privacy/classes/local/request/moodle_content_writer.php
privacy/classes/output/exported_html_page.php [new file with mode: 0644]
privacy/classes/output/exported_navigation_page.php [new file with mode: 0644]
privacy/classes/output/renderer.php [new file with mode: 0644]
privacy/export_files/general.css [new file with mode: 0644]
privacy/export_files/general.js [new file with mode: 0644]
privacy/templates/htmlpage.mustache [new file with mode: 0644]
privacy/templates/navigation.mustache [new file with mode: 0644]
privacy/tests/moodle_content_writer_test.php
report/loglive/index.php
report/performance/index.php
report/security/index.php
theme/boost/scss/moodle/core.scss
theme/boost/style/moodle.css
theme/bootstrapbase/less/moodle/bs4-compat.less
theme/bootstrapbase/less/moodle/core.less
theme/bootstrapbase/style/moodle.css
user/profilesys.php
version.php

index 9ae73f3..958fc6e 100644 (file)
@@ -28,10 +28,6 @@ require_once($CFG->libdir . '/adminlib.php');
 $action = optional_param('action', '', PARAM_ALPHA);
 $filterpath = optional_param('filterpath', '', PARAM_PLUGIN);
 
-require_login();
-$systemcontext = context_system::instance();
-require_capability('moodle/site:config', $systemcontext);
-
 admin_externalpage_setup('managefilters');
 
 // Clean up bogus filter states first.
index 6924e61..c10034a 100644 (file)
@@ -28,9 +28,6 @@ require_once($CFG->libdir.'/adminlib.php');
 // This is an admin page
 admin_externalpage_setup('managemessageoutputs');
 
-// Require site configuration capability
-require_capability('moodle/site:config', context_system::instance());
-
 // Get the submitted params
 $disable    = optional_param('disable', 0, PARAM_INT);
 $enable     = optional_param('enable', 0, PARAM_INT);
index 88abd77..a122301 100644 (file)
@@ -12,8 +12,6 @@ $page         = optional_param('page', 0, PARAM_INT);
 $perpage      = optional_param('perpage', 30, PARAM_INT);
 $action       = trim(strtolower(optional_param('action', '', PARAM_ALPHA)));
 
-require_login();
-
 admin_externalpage_setup('ssoaccesscontrol');
 
 if (!extension_loaded('openssl')) {
index 03eac9b..479d559 100644 (file)
@@ -34,10 +34,8 @@ $step   = optional_param('step', 'verify', PARAM_ALPHA);
 $hostid = required_param('hostid', PARAM_INT);
 
 
-require_login();
 
 $context = context_system::instance();
-require_capability('moodle/site:config', $context, $USER->id, true, "nopermissions");
 
 $mnet = get_mnet_environment();
 
index 879caa8..fff9386 100644 (file)
@@ -6,12 +6,10 @@
     require_once($CFG->libdir.'/adminlib.php');
     include_once($CFG->dirroot.'/mnet/lib.php');
 
-    require_login();
     admin_externalpage_setup('net');
 
     $context = context_system::instance();
 
-    require_capability('moodle/site:config', $context, $USER->id, true, "nopermissions");
 
     $site = get_site();
     $mnet = get_mnet_environment();
index 559d2f4..49fd346 100644 (file)
@@ -32,10 +32,6 @@ require_once($CFG->libdir.'/adminlib.php');
 require_once($CFG->dirroot.'/mnet/lib.php');
 require_once($CFG->dirroot.'/'.$CFG->admin.'/mnet/peer_forms.php');
 
-require_login();
-
-$context = context_system::instance();
-require_capability('moodle/site:config', $context, $USER->id, true, 'nopermissions');
 
 /// Initialize variables.
 $hostid = optional_param('hostid', 0, PARAM_INT);
index a27f162..88e9004 100644 (file)
@@ -29,14 +29,10 @@ require_once($CFG->libdir.'/adminlib.php');
 require_once($CFG->dirroot . '/' . $CFG->admin .'/mnet/profilefields_form.php');
 $mnet = get_mnet_environment();
 
-require_login();
 $hostid = required_param('hostid', PARAM_INT);
 $mnet_peer = new mnet_peer();
 $mnet_peer->set_id($hostid);
 
-$context = context_system::instance();
-
-require_capability('moodle/site:config', $context, $USER->id, true, 'nopermissions');
 admin_externalpage_setup('mnetpeers');
 $form = new mnet_profile_form(null, array('hostid' => $hostid));
 
index 79e46e9..d5048eb 100644 (file)
@@ -30,11 +30,8 @@ require_once($CFG->libdir.'/adminlib.php');
 require_once($CFG->dirroot . '/' . $CFG->admin . '/mnet/services_form.php');
 $mnet = get_mnet_environment();
 
-require_login();
 admin_externalpage_setup('mnetpeers');
 
-$context = context_system::instance();
-require_capability('moodle/site:config', $context, $USER->id, true, "nopermissions");
 
 $hostid = required_param('hostid', PARAM_INT);
 
index 2560e84..a12ebb6 100644 (file)
@@ -21,12 +21,8 @@ if ($CFG->mnet_dispatcher_mode === 'off') {
     print_error('mnetdisabled', 'mnet');
 }
 
-require_login();
 admin_externalpage_setup('mnettestclient');
 
-$context = context_system::instance();
-require_capability('moodle/site:config', $context);
-
 error_reporting(DEBUG_ALL);
 
 echo $OUTPUT->header();
index cbbbfa3..5ad348c 100644 (file)
@@ -5,12 +5,8 @@
     require_once($CFG->libdir.'/adminlib.php');
     include_once($CFG->dirroot.'/mnet/lib.php');
 
-    require_login();
     admin_externalpage_setup('trustedhosts');
 
-    $context = context_system::instance();
-
-    require_capability('moodle/site:config', $context, $USER->id, true, "nopermissions");
 
     if (!extension_loaded('openssl')) {
         echo $OUTPUT->header();
index 6a33fc0..66ff3a7 100644 (file)
@@ -35,8 +35,6 @@ if ($action == 'newon') {
 
 admin_externalpage_setup($pagename);
 
-require_capability('moodle/site:config', context_system::instance());
-
 $baseurl    = "$CFG->wwwroot/$CFG->admin/portfolio.php";
 $sesskeyurl = "$CFG->wwwroot/$CFG->admin/portfolio.php?sesskey=" . sesskey();
 $configstr  = get_string('manageportfolios', 'portfolio');
index b13baf0..f9dfc2e 100644 (file)
@@ -31,7 +31,6 @@ require_once($CFG->libdir . '/adminlib.php');
 require_once($CFG->libdir . '/tablelib.php');
 
 // Check permissions.
-require_login();
 $systemcontext = context_system::instance();
 require_capability('moodle/question:config', $systemcontext);
 $canviewreports = has_capability('report/questioninstances:view', $systemcontext);
index 6984da6..0c79987 100644 (file)
@@ -47,7 +47,6 @@ if ($action == 'newon') {
     $visible = false;
 }
 
-require_capability('moodle/site:config', context_system::instance());
 admin_externalpage_setup($pagename);
 
 $sesskeyurl = $CFG->wwwroot.'/'.$CFG->admin.'/repository.php?sesskey=' . sesskey();
index 960d0ff..7020224 100644 (file)
@@ -42,7 +42,6 @@ if ($edit){
 }
 
 admin_externalpage_setup($pagename, '', null, new moodle_url('/admin/repositoryinstance.php'));
-require_capability('moodle/site:config', $context);
 
 $baseurl = new moodle_url("/$CFG->admin/repositoryinstance.php", array('sesskey'=>sesskey()));
 
index de3eac1..a298052 100644 (file)
@@ -54,7 +54,6 @@ if ($return === 'manage') {
 
 // Check access permissions.
 $systemcontext = context_system::instance();
-require_login();
 require_capability('moodle/role:manage', $systemcontext);
 admin_externalpage_setup('defineroles', '', array('action' => $action, 'roleid' => $roleid), new moodle_url('/admin/roles/define.php'));
 
index d2e703f..9da57eb 100644 (file)
@@ -48,7 +48,6 @@ $defineurl = $CFG->wwwroot . '/' . $CFG->admin . '/roles/define.php';
 
 // Check access permissions.
 $systemcontext = context_system::instance();
-require_login();
 require_capability('moodle/role:manage', $systemcontext);
 admin_externalpage_setup('defineroles');
 
index b971e30..57a230e 100644 (file)
@@ -218,6 +218,9 @@ preferences,moodle|/user/preferences.php|t/preferences',
     // coursecontact is the person responsible for course - usually manages enrolments, receives notification, etc.
     $temp = new admin_settingpage('coursecontact', new lang_string('courses'));
     $temp->add(new admin_setting_special_coursecontact());
+    $temp->add(new admin_setting_configcheckbox('coursecontactduplicates',
+            new lang_string('coursecontactduplicates', 'admin'),
+            new lang_string('coursecontactduplicates_desc', 'admin'), 0));
     $temp->add(new admin_setting_configcheckbox('courselistshortnames',
             new lang_string('courselistshortnames', 'admin'),
             new lang_string('courselistshortnames_desc', 'admin'), 0));
index 7e5f350..f626371 100644 (file)
@@ -244,14 +244,6 @@ class api {
             if (self::is_site_dpo($requestinguser)) {
                 // The user making the request is a DPO. Should be fine.
                 $datarequest->set('dpo', $requestinguser);
-            } else {
-                // If not a DPO, only users with the capability to make data requests for the user should be allowed.
-                // (e.g. users with the Parent role, etc).
-                if (!self::can_create_data_request_for_user($foruser)) {
-                    $forusercontext = \context_user::instance($foruser);
-                    throw new required_capability_exception($forusercontext,
-                            'tool/dataprivacy:makedatarequestsforchildren', 'nopermissions', '');
-                }
             }
         }
         // The user making the request.
@@ -667,16 +659,31 @@ class api {
     /**
      * Checks whether a non-DPO user can make a data request for another user.
      *
-     * @param int $user The user ID of the target user.
-     * @param int $requester The user ID of the user making the request.
-     * @return bool
-     * @throws coding_exception
+     * @param   int     $user The user ID of the target user.
+     * @param   int     $requester The user ID of the user making the request.
+     * @return  bool
      */
     public static function can_create_data_request_for_user($user, $requester = null) {
         $usercontext = \context_user::instance($user);
+
         return has_capability('tool/dataprivacy:makedatarequestsforchildren', $usercontext, $requester);
     }
 
+    /**
+     * Require that the current user can make a data request for the specified other user.
+     *
+     * @param   int     $user The user ID of the target user.
+     * @param   int     $requester The user ID of the user making the request.
+     * @return  bool
+     */
+    public static function require_can_create_data_request_for_user($user, $requester = null) {
+        $usercontext = \context_user::instance($user);
+
+        require_capability('tool/dataprivacy:makedatarequestsforchildren', $usercontext, $requester);
+
+        return true;
+    }
+
     /**
      * Checks whether a user can download a data request.
      *
@@ -732,8 +739,6 @@ class api {
      * @return \tool_dataprivacy\purpose.
      */
     public static function create_purpose(stdClass $record) {
-        self::check_can_manage_data_registry();
-
         $purpose = new purpose(0, $record);
         $purpose->create();
 
@@ -747,8 +752,6 @@ class api {
      * @return \tool_dataprivacy\purpose.
      */
     public static function update_purpose(stdClass $record) {
-        self::check_can_manage_data_registry();
-
         if (!isset($record->sensitivedatareasons)) {
             $record->sensitivedatareasons = '';
         }
@@ -768,8 +771,6 @@ class api {
      * @return bool
      */
     public static function delete_purpose($id) {
-        self::check_can_manage_data_registry();
-
         $purpose = new purpose($id);
         if ($purpose->is_used()) {
             throw new \moodle_exception('Purpose with id ' . $id . ' can not be deleted because it is used.');
@@ -783,8 +784,6 @@ class api {
      * @return \tool_dataprivacy\purpose[]
      */
     public static function get_purposes() {
-        self::check_can_manage_data_registry();
-
         return purpose::get_records([], 'name', 'ASC');
     }
 
@@ -795,8 +794,6 @@ class api {
      * @return \tool_dataprivacy\category.
      */
     public static function create_category(stdClass $record) {
-        self::check_can_manage_data_registry();
-
         $category = new category(0, $record);
         $category->create();
 
@@ -810,8 +807,6 @@ class api {
      * @return \tool_dataprivacy\category.
      */
     public static function update_category(stdClass $record) {
-        self::check_can_manage_data_registry();
-
         $category = new category($record->id);
         $category->from_record($record);
 
@@ -827,8 +822,6 @@ class api {
      * @return bool
      */
     public static function delete_category($id) {
-        self::check_can_manage_data_registry();
-
         $category = new category($id);
         if ($category->is_used()) {
             throw new \moodle_exception('Category with id ' . $id . ' can not be deleted because it is used.');
@@ -842,8 +835,6 @@ class api {
      * @return \tool_dataprivacy\category[]
      */
     public static function get_categories() {
-        self::check_can_manage_data_registry();
-
         return category::get_records([], 'name', 'ASC');
     }
 
@@ -854,8 +845,6 @@ class api {
      * @return \tool_dataprivacy\context_instance
      */
     public static function set_context_instance($record) {
-        self::check_can_manage_data_registry($record->contextid);
-
         if ($instance = context_instance::get_record_by_contextid($record->contextid, false)) {
             // Update.
             $instance->from_record($record);
@@ -882,7 +871,6 @@ class api {
      * @return null
      */
     public static function unset_context_instance(context_instance $instance) {
-        self::check_can_manage_data_registry($instance->get('contextid'));
         $instance->delete();
     }
 
@@ -896,9 +884,6 @@ class api {
     public static function set_contextlevel($record) {
         global $DB;
 
-        // Only manager at system level can set this.
-        self::check_can_manage_data_registry();
-
         if ($record->contextlevel != CONTEXT_SYSTEM && $record->contextlevel != CONTEXT_USER) {
             throw new \coding_exception('Only context system and context user can set a contextlevel ' .
                 'purpose and retention');
@@ -930,7 +915,6 @@ class api {
      * @return category|false
      */
     public static function get_effective_context_category(\context $context, $forcedvalue=false) {
-        self::check_can_manage_data_registry($context->id);
         if (!data_registry::defaults_set()) {
             return false;
         }
@@ -945,8 +929,7 @@ class api {
      * @param int $forcedvalue Use this purposeid value as if this was this context instance purpose.
      * @return purpose|false
      */
-    public static function get_effective_context_purpose(\context $context, $forcedvalue=false) {
-        self::check_can_manage_data_registry($context->id);
+    public static function get_effective_context_purpose(\context $context, $forcedvalue = false) {
         if (!data_registry::defaults_set()) {
             return false;
         }
@@ -962,7 +945,6 @@ class api {
      * @return category|false
      */
     public static function get_effective_contextlevel_category($contextlevel, $forcedvalue=false) {
-        self::check_can_manage_data_registry(\context_system::instance()->id);
         if (!data_registry::defaults_set()) {
             return false;
         }
@@ -978,7 +960,6 @@ class api {
      * @return purpose|false
      */
     public static function get_effective_contextlevel_purpose($contextlevel, $forcedvalue=false) {
-        self::check_can_manage_data_registry(\context_system::instance()->id);
         if (!data_registry::defaults_set()) {
             return false;
         }
@@ -986,38 +967,6 @@ class api {
         return data_registry::get_effective_contextlevel_value($contextlevel, 'purpose', $forcedvalue);
     }
 
-    /**
-     * Creates an expired context record for the provided context id.
-     *
-     * @param int $contextid
-     * @return \tool_dataprivacy\expired_context
-     */
-    public static function create_expired_context($contextid) {
-        self::check_can_manage_data_registry();
-
-        $record = (object)[
-            'contextid' => $contextid,
-            'status' => expired_context::STATUS_EXPIRED,
-        ];
-        $expiredctx = new expired_context(0, $record);
-        $expiredctx->save();
-
-        return $expiredctx;
-    }
-
-    /**
-     * Deletes an expired context record.
-     *
-     * @param int $id The tool_dataprivacy_ctxexpire id.
-     * @return bool True on success.
-     */
-    public static function delete_expired_context($id) {
-        self::check_can_manage_data_registry();
-
-        $expiredcontext = new expired_context($id);
-        return $expiredcontext->delete();
-    }
-
     /**
      * Updates the status of an expired context.
      *
@@ -1026,8 +975,6 @@ class api {
      * @return null
      */
     public static function set_expired_context_status(expired_context $expiredctx, $status) {
-        self::check_can_manage_data_registry();
-
         $expiredctx->set('status', $status);
         $expiredctx->save();
     }
@@ -1178,8 +1125,6 @@ class api {
     public static function set_context_defaults($contextlevel, $categoryid, $purposeid, $activity = null, $override = false) {
         global $DB;
 
-        self::check_can_manage_data_registry();
-
         // Get the class name associated with this context level.
         $classname = context_helper::get_class_for_level($contextlevel);
         list($purposevar, $categoryvar) = data_registry::var_names_from_context($classname, $activity);
index 7b46b4d..d10ab83 100644 (file)
@@ -189,14 +189,14 @@ class data_registry {
      * @param int|false $forcedvalue Use this value as if this was this context instance value.
      * @return persistent|false It return a 'purpose' instance or a 'category' instance, depending on $element
      */
-    public static function get_effective_context_value(\context $context, $element, $forcedvalue=false) {
+    public static function get_effective_context_value(\context $context, $element, $forcedvalue = false) {
 
         if ($element !== 'purpose' && $element !== 'category') {
             throw new coding_exception('Only \'purpose\' and \'category\' are supported.');
         }
         $fieldname = $element . 'id';
 
-        if ($forcedvalue === false) {
+        if (empty($forcedvalue)) {
             $instance = context_instance::get_record_by_contextid($context->id, false);
 
             if (!$instance) {
@@ -215,20 +215,29 @@ class data_registry {
             // The effective value varies depending on the context level.
             if ($context->contextlevel == CONTEXT_USER) {
                 // Use the context level value as we don't allow people to set specific instances values.
-                return self::get_effective_contextlevel_value($context->contextlevel, $element);
-            } else {
-                // Check if we need to pass the plugin name of an activity.
-                $forplugin = '';
-                if ($context->contextlevel == CONTEXT_MODULE) {
-                    list($course, $cm) = get_course_and_cm_from_cmid($context->instanceid);
-                    $forplugin = $cm->modname;
+                return self::get_effective_contextlevel_value(CONTEXT_USER, $element);
+            }
+
+            $parents = $context->get_parent_contexts(true);
+            foreach ($parents as $parent) {
+                if ($parent->contextlevel == CONTEXT_USER) {
+                    // Use the context level value as we don't allow people to set specific instances values.
+                    return self::get_effective_contextlevel_value(CONTEXT_USER, $element);
                 }
-                // Use the default context level value.
-                list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category(
-                    $context->contextlevel, false, false, $forplugin
-                );
-                return self::get_element_instance($element, $$fieldname);
             }
+
+            // Check if we need to pass the plugin name of an activity.
+            $forplugin = '';
+            if ($context->contextlevel == CONTEXT_MODULE) {
+                list($course, $cm) = get_course_and_cm_from_cmid($context->instanceid);
+                $forplugin = $cm->modname;
+            }
+            // Use the default context level value.
+            list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category(
+                $context->contextlevel, false, false, $forplugin
+            );
+
+            return self::get_element_instance($element, $$fieldname);
         }
 
         // Specific value for this context instance.
index 3010438..5ac228d 100644 (file)
@@ -159,4 +159,51 @@ class expired_context extends \core\persistent {
 
         return $DB->count_records_sql($sql, $params);
     }
+
+    /**
+     * Create a new expired_context based on the context, and expiry_info object.
+     *
+     * @param   \context        $context
+     * @param   expiry_info     $info
+     * @return  expired_context
+     */
+    public static function create_from_expiry_info(\context $context, expiry_info $info) : expired_context {
+        $record = (object) [
+            'contextid' => $context->id,
+            'status' => self::STATUS_EXPIRED,
+        ];
+
+        $expiredcontext = new static(0, $record);
+        $expiredcontext->save();
+
+        return $expiredcontext;
+    }
+
+    /**
+     * Update the expired_context from an expiry_info object which relates to this context.
+     *
+     * @param   expiry_info     $info
+     * @return  $this
+     */
+    public function update_from_expiry_info(expiry_info $info) : expired_context {
+        return $this;
+    }
+
+    /**
+     * Check whether this expired_context record is in a state ready for deletion to actually take place.
+     *
+     * @return  bool
+     */
+    public function can_process_deletion() : bool {
+        return ($this->get('status') == self::STATUS_APPROVED);
+    }
+
+    /**
+     * Check whether this expired_context record has already been cleaned.
+     *
+     * @return  bool
+     */
+    public function is_complete() : bool {
+        return ($this->get('status') == self::STATUS_CLEANED);
+    }
 }
index 539fc28..be8d731 100644 (file)
@@ -34,121 +34,680 @@ defined('MOODLE_INTERNAL') || die();
  * @copyright  2018 David Monllao
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-abstract class expired_contexts_manager {
+class expired_contexts_manager {
 
     /**
      * Number of deleted contexts for each scheduled task run.
      */
     const DELETE_LIMIT = 200;
 
+    /** @var progress_trace The log progress tracer */
+    protected $progresstracer = null;
+
+    /** @var manager The privacy manager */
+    protected $manager = null;
+
     /**
-     * Returns the list of expired context instances.
+     * Flag expired contexts as expired.
      *
-     * @return \stdClass[]
+     * @return  int[]   The number of contexts flagged as expired for courses, and users.
+     */
+    public function flag_expired_contexts() : array {
+        if (!$this->check_requirements()) {
+            return [0, 0];
+        }
+
+        // Clear old and stale records first.
+        static::clear_old_records();
+
+        $data = static::get_nested_expiry_info_for_courses();
+        $coursecount = 0;
+        foreach ($data as $expiryrecord) {
+            if ($this->update_from_expiry_info($expiryrecord)) {
+                $coursecount++;
+            }
+        }
+
+        $data = static::get_nested_expiry_info_for_user();
+        $usercount = 0;
+        foreach ($data as $expiryrecord) {
+            if ($this->update_from_expiry_info($expiryrecord)) {
+                $usercount++;
+            }
+        }
+
+        return [$coursecount, $usercount];
+    }
+
+    /**
+     * Clear old and stale records.
      */
-    abstract protected function get_expired_contexts();
+    protected static function clear_old_records() {
+        global $DB;
+
+        $sql = "SELECT dpctx.*
+                  FROM {tool_dataprivacy_ctxexpired} dpctx
+             LEFT JOIN {context} ctx ON ctx.id = dpctx.contextid
+                 WHERE ctx.id IS NULL";
+
+        $orphaned = $DB->get_recordset_sql($sql);
+        foreach ($orphaned as $orphan) {
+            $expiredcontext = new expired_context(0, $orphan);
+            $expiredcontext->delete();
+        }
+
+        // Delete any child of a user context.
+        $parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
+        $params = [
+            'contextuser' => CONTEXT_USER,
+        ];
+
+        $sql = "SELECT dpctx.*
+                  FROM {tool_dataprivacy_ctxexpired} dpctx
+                 WHERE dpctx.contextid IN (
+                    SELECT ctx.id
+                        FROM {context} ctxuser
+                        JOIN {context} ctx ON ctx.path LIKE {$parentpath}
+                       WHERE ctxuser.contextlevel = :contextuser
+                    )";
+        $userchildren = $DB->get_recordset_sql($sql, $params);
+        foreach ($userchildren as $child) {
+            $expiredcontext = new expired_context(0, $child);
+            $expiredcontext->delete();
+        }
+    }
 
     /**
-     * Specify with context levels this expired contexts manager is deleting.
+     * Get the full nested set of expiry data relating to all contexts.
      *
-     * @return int[]
+     * @param   string      $contextpath A contexpath to restrict results to
+     * @return  \stdClass[]
      */
-    abstract protected function get_context_levels();
+    protected static function get_nested_expiry_info($contextpath = '') : array {
+        $coursepaths = self::get_nested_expiry_info_for_courses($contextpath);
+        $userpaths = self::get_nested_expiry_info_for_user($contextpath);
+
+        return array_merge($coursepaths, $userpaths);
+    }
 
     /**
-     * Flag expired contexts as expired.
+     * Get the full nested set of expiry data relating to course-related contexts.
      *
-     * @return int The number of contexts flagged as expired.
+     * @param   string      $contextpath A contexpath to restrict results to
+     * @return  \stdClass[]
      */
-    public function flag_expired() {
+    protected static function get_nested_expiry_info_for_courses($contextpath = '') : array {
+        global $DB;
 
-        if (!$this->check_requirements()) {
-            return 0;
-        }
+        $contextfields = \context_helper::get_preload_record_columns_sql('ctx');
+        $expiredfields = expired_context::get_sql_fields('expiredctx', 'expiredctx');
+        $purposefields = 'dpctx.purposeid';
+        $coursefields = 'ctxcourse.expirydate AS expirydate';
+        $fields = implode(', ', ['ctx.id', $contextfields, $expiredfields, $coursefields, $purposefields]);
+
+        // We want all contexts at course-dependant levels.
+        $parentpath = $DB->sql_concat('ctxcourse.path', "'/%'");
 
-        $contexts = $this->get_expired_contexts();
-        foreach ($contexts as $context) {
-            api::create_expired_context($context->id);
+        // This SQL query returns all course-dependant contexts (including the course context)
+        // which course end date already passed.
+        // This is ordered by the context path in reverse order, which will give the child nodes before any parent node.
+        $params = [
+            'contextlevel' => CONTEXT_COURSE,
+        ];
+        $where = '';
+
+        if (!empty($contextpath)) {
+            $where = "WHERE (ctx.path = :pathmatchexact OR ctx.path LIKE :pathmatchchildren)";
+            $params['pathmatchexact'] = $contextpath;
+            $params['pathmatchchildren'] = "{$contextpath}/%";
         }
 
-        return count($contexts);
+        $sql = "SELECT $fields
+                  FROM {context} ctx
+                  JOIN (
+                        SELECT c.enddate AS expirydate, subctx.path
+                          FROM {context} subctx
+                          JOIN {course} c
+                            ON subctx.contextlevel = :contextlevel
+                           AND subctx.instanceid = c.id
+                           AND c.format != 'site'
+                       ) ctxcourse
+                    ON ctx.path LIKE {$parentpath} OR ctx.path = ctxcourse.path
+             LEFT JOIN {tool_dataprivacy_ctxinstance} dpctx
+                    ON dpctx.contextid = ctx.id
+             LEFT JOIN {tool_dataprivacy_ctxexpired} expiredctx
+                    ON ctx.id = expiredctx.contextid
+                 {$where}
+              ORDER BY ctx.path DESC";
+
+        return self::get_nested_expiry_info_from_sql($sql, $params);
     }
 
     /**
-     * Deletes the expired contexts.
+     * Get the full nested set of expiry data.
      *
-     * @return int The number of deleted contexts.
+     * @param   string      $contextpath A contexpath to restrict results to
+     * @return  \stdClass[]
      */
-    public function delete() {
+    protected static function get_nested_expiry_info_for_user($contextpath = '') : array {
+        global $DB;
 
-        $numprocessed = 0;
+        $contextfields = \context_helper::get_preload_record_columns_sql('ctx');
+        $expiredfields = expired_context::get_sql_fields('expiredctx', 'expiredctx');
+        $purposefields = 'dpctx.purposeid';
+        $userfields = 'u.lastaccess AS expirydate';
+        $fields = implode(', ', ['ctx.id', $contextfields, $expiredfields, $userfields, $purposefields]);
 
-        if (!$this->check_requirements()) {
-            return $numprocessed;
+        // We want all contexts at user-dependant levels.
+        $parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
+
+        // This SQL query returns all user-dependant contexts (including the user context)
+        // This is ordered by the context path in reverse order, which will give the child nodes before any parent node.
+        $params = [
+            'contextlevel' => CONTEXT_USER,
+        ];
+        $where = '';
+
+        if (!empty($contextpath)) {
+            $where = "AND ctx.path = :pathmatchexact";
+            $params['pathmatchexact'] = $contextpath;
         }
 
-        $privacymanager = new manager();
-        $privacymanager->set_observer(new \tool_dataprivacy\manager_observer());
+        $sql = "SELECT $fields, u.deleted AS userdeleted
+                  FROM {context} ctx
+                  JOIN {user} u ON ctx.instanceid = u.id
+             LEFT JOIN {tool_dataprivacy_ctxinstance} dpctx
+                    ON dpctx.contextid = ctx.id
+             LEFT JOIN {tool_dataprivacy_ctxexpired} expiredctx
+                    ON ctx.id = expiredctx.contextid
+                 WHERE ctx.contextlevel = :contextlevel {$where}
+              ORDER BY ctx.path DESC";
 
-        foreach ($this->get_context_levels() as $level) {
+        return self::get_nested_expiry_info_from_sql($sql, $params);
+    }
+
+    /**
+     * Get the full nested set of expiry data given appropriate SQL.
+     * Only contexts which have expired will be included.
+     *
+     * @param   string      $sql The SQL used to select the nested information.
+     * @param   array       $params The params required by the SQL.
+     * @return  \stdClass[]
+     */
+    protected static function get_nested_expiry_info_from_sql(string $sql, array $params) : array {
+        global $DB;
+
+        $fulllist = $DB->get_recordset_sql($sql, $params);
+        $datalist = [];
+        $expiredcontents = [];
+        $pathstoskip = [];
+        foreach ($fulllist as $record) {
+            \context_helper::preload_from_record($record);
+            $context = \context::instance_by_id($record->id, false);
 
-            $expiredcontexts = expired_context::get_records_by_contextlevel($level, expired_context::STATUS_APPROVED);
+            if (!self::is_eligible_for_deletion($pathstoskip, $context)) {
+                // We should skip this context, and therefore all of it's children.
+                $datalist = array_filter($datalist, function($data, $path) use ($context) {
+                    // Remove any child of this context.
+                    // Technically this should never be fulfilled because the query is ordered in path DESC, but is kept
+                    // in to be certain.
+                    return (false === strpos($path, "{$context->path}/"));
+                }, ARRAY_FILTER_USE_BOTH);
 
-            foreach ($expiredcontexts as $expiredctx) {
+                if ($record->expiredctxid) {
+                    // There was previously an expired context record.
+                    // Delete it to be on the safe side.
+                    $expiredcontext = new expired_context(null, expired_context::extract_record($record, 'expiredctx'));
+                    $expiredcontext->delete();
+                }
+                continue;
+            }
+
+            $purposevalue = $record->purposeid !== null ? $record->purposeid : context_instance::NOTSET;
+            $purpose = api::get_effective_context_purpose($context, $purposevalue);
 
-                if (!$this->delete_expired_context($privacymanager, $expiredctx)) {
+            if ($context instanceof \context_user && !empty($record->userdeleted)) {
+                $expiryinfo = static::get_expiry_info($purpose, $record->userdeleted);
+            } else {
+                $expiryinfo = static::get_expiry_info($purpose, $record->expirydate);
+            }
+            foreach ($datalist as $path => $data) {
+                // Merge with already-processed children.
+                if (strpos($path, $context->path) !== 0) {
                     continue;
                 }
 
-                $numprocessed += 1;
-                if ($numprocessed == self::DELETE_LIMIT) {
-                    // Close the recordset.
-                    $expiredcontexts->close();
-                    break 2;
+                $expiryinfo->merge_with_child($data->info);
+            }
+            $datalist[$context->path] = (object) [
+                'context' => $context,
+                'record' => $record,
+                'purpose' => $purpose,
+                'info' => $expiryinfo,
+            ];
+        }
+        $fulllist->close();
+
+        return $datalist;
+    }
+
+    /**
+     * Check whether the supplied context would be elible for deletion.
+     *
+     * @param   array       $pathstoskip A set of paths which should be skipped
+     * @param   \context    $context
+     * @return  bool
+     */
+    protected static function is_eligible_for_deletion(array &$pathstoskip, \context $context) : bool {
+        $shouldskip = false;
+        // Check whether any of the child contexts are ineligble.
+        $shouldskip = !empty(array_filter($pathstoskip, function($path) use ($context) {
+            // If any child context has already been skipped then it will appear in this list.
+            // Since paths include parents, test if the context under test appears as the haystack in the skipped
+            // context's needle.
+            return false !== (strpos($context->path, $path));
+        }));
+
+        if (!$shouldskip && $context instanceof \context_user) {
+            // The context instanceid is the user's ID.
+            if (isguestuser($context->instanceid) || is_siteadmin($context->instanceid)) {
+                // This is an admin, or the guest and cannot be deleted.
+                $shouldskip = true;
+            }
+
+            if (!$shouldskip) {
+                $courses = enrol_get_users_courses($context->instanceid, false, ['enddate']);
+                $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;
+                    }
+                }
+            }
+        }
+
+        if ($shouldskip) {
+            // Add this to the list of contexts to skip for parentage checks.
+            $pathstoskip[] = $context->path;
+        }
+
+        return !$shouldskip;
+    }
+
+    /**
+     * Deletes the expired contexts.
+     *
+     * @return  int[]       The number of deleted contexts.
+     */
+    public function process_approved_deletions() : array {
+        if (!$this->check_requirements()) {
+            return [0, 0];
+        }
+
+        $expiredcontexts = expired_context::get_records(['status' => expired_context::STATUS_APPROVED]);
+        $totalprocessed = 0;
+        $usercount = 0;
+        $coursecount = 0;
+        foreach ($expiredcontexts as $expiredctx) {
+            $context = \context::instance_by_id($expiredctx->get('contextid'), IGNORE_MISSING);
+            if (empty($context)) {
+                // Unable to process this request further.
+                // We have no context to delete.
+                $expiredctx->delete();
+                continue;
+            }
+
+            if ($this->delete_expired_context($expiredctx)) {
+                if ($context instanceof \context_user) {
+                    $usercount++;
+                } else {
+                    $coursecount++;
+                }
+
+                $totalprocessed++;
+                if ($totalprocessed >= $this->get_delete_limit()) {
+                    break;
                 }
             }
         }
 
-        return $numprocessed;
+        return [$coursecount, $usercount];
     }
 
     /**
      * Deletes user data from the provided context.
      *
-     * @param manager $privacymanager
      * @param expired_context $expiredctx
      * @return \context|false
      */
-    protected function delete_expired_context(manager $privacymanager, expired_context $expiredctx) {
+    protected function delete_expired_context(expired_context $expiredctx) {
+        $context = \context::instance_by_id($expiredctx->get('contextid'));
+
+        $this->get_progress()->output("Deleting context {$context->id} - " . $context->get_context_name(true, true));
 
-        $context = \context::instance_by_id($expiredctx->get('contextid'), IGNORE_MISSING);
-        if (!$context) {
-            api::delete_expired_context($expiredctx->get('contextid'));
+        // Update the expired_context and verify that it is still ready for deletion.
+        $expiredctx = $this->update_expired_context($expiredctx);
+        if (empty($expiredctx)) {
+            $this->get_progress()->output("Context has changed since approval and is no longer pending approval. Skipping", 1);
             return false;
         }
 
-        if (!PHPUNIT_TEST) {
-            mtrace('Deleting context ' . $context->id . ' - ' .
-                shorten_text($context->get_context_name(true, true)));
+        if (!$expiredctx->can_process_deletion()) {
+            // This only happens if the record was updated after being first fetched.
+            $this->get_progress()->output("Context has changed since approval and must be re-approved. Skipping", 1);
+            $expiredctx->set('status', expired_context::STATUS_EXPIRED);
+            $expiredctx->save();
+
+            return false;
         }
 
-        $privacymanager->delete_data_for_all_users_in_context($context);
-        api::set_expired_context_status($expiredctx, expired_context::STATUS_CLEANED);
+        $privacymanager = $this->get_privacy_manager();
+        if ($context instanceof \context_user) {
+            $this->delete_expired_user_context($expiredctx);
+        } else {
+            // This context is fully expired - that is that the default retention period has been reached.
+            $privacymanager->delete_data_for_all_users_in_context($context);
+        }
+
+        // Mark the record as cleaned.
+        $expiredctx->set('status', expired_context::STATUS_CLEANED);
+        $expiredctx->save();
 
         return $context;
     }
 
+    /**
+     * Deletes user data from the provided user context.
+     *
+     * @param expired_context $expiredctx
+     */
+    protected function delete_expired_user_context(expired_context $expiredctx) {
+        global $DB;
+
+        $contextid = $expiredctx->get('contextid');
+        $context = \context::instance_by_id($contextid);
+        $user = \core_user::get_user($context->instanceid, '*', MUST_EXIST);
+
+        $privacymanager = $this->get_privacy_manager();
+
+        // Delete all child contexts of the user context.
+        $parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
+
+        $params = [
+            'contextlevel'  => CONTEXT_USER,
+            'contextid'     => $expiredctx->get('contextid'),
+        ];
+
+        $fields = \context_helper::get_preload_record_columns_sql('ctx');
+        $sql = "SELECT ctx.id, $fields
+                  FROM {context} ctxuser
+                  JOIN {context} ctx ON ctx.path LIKE {$parentpath}
+                 WHERE ctxuser.contextlevel = :contextlevel AND ctxuser.id = :contextid
+              ORDER BY ctx.path DESC";
+
+        $children = $DB->get_recordset_sql($sql, $params);
+        foreach ($children as $child) {
+            \context_helper::preload_from_record($child);
+            $context = \context::instance_by_id($child->id);
+
+            $privacymanager->delete_data_for_all_users_in_context($context);
+        }
+        $children->close();
+
+        // Delete all unprotected data that the user holds.
+        $approvedlistcollection = new \core_privacy\local\request\contextlist_collection($user->id);
+        $contextlistcollection = $privacymanager->get_contexts_for_userid($user->id);
+
+        foreach ($contextlistcollection as $contextlist) {
+            $contextids = [];
+            $approvedlistcollection->add_contextlist(new \core_privacy\local\request\approved_contextlist(
+                    $user,
+                    $contextlist->get_component(),
+                    $contextlist->get_contextids()
+                ));
+        }
+        $privacymanager->delete_data_for_user($approvedlistcollection, $this->get_progress());
+
+        // Delete the user context.
+        $context = \context::instance_by_id($expiredctx->get('contextid'));
+        $privacymanager->delete_data_for_all_users_in_context($context);
+
+        // This user is now fully expired - finish by deleting the user.
+        delete_user($user);
+    }
+
+    /**
+     * Whether end dates are required on all courses in order for a user to be expired from them.
+     *
+     * @return bool
+     */
+    protected static function require_all_end_dates_for_user_deletion() : bool {
+        $requireenddate = get_config('tool_dataprivacy', 'requireallenddatesforuserdeletion');
+
+        return !empty($requireenddate);
+    }
+
     /**
      * Check that the requirements to start deleting contexts are satisified.
      *
      * @return bool
      */
     protected function check_requirements() {
-        api::check_can_manage_data_registry(\context_system::instance()->id);
-
         if (!data_registry::defaults_set()) {
             return false;
         }
         return true;
     }
+
+    /**
+     * Check whether a date is beyond the specified period.
+     *
+     * @param   string      $period The Expiry Period
+     * @param   int         $comparisondate The date for comparison
+     * @return  bool
+     */
+    protected static function has_expired(string $period, int $comparisondate) : bool {
+        $dt = new \DateTime();
+        $dt->setTimestamp($comparisondate);
+        $dt->add(new \DateInterval($period));
+
+        return (time() >= $dt->getTimestamp());
+    }
+
+    /**
+     * Get the expiry info object for the specified purpose and comparison date.
+     *
+     * @param   purpose     $purpose The purpose of this context
+     * @param   int         $comparisondate The date for comparison
+     * @return  expiry_info
+     */
+    protected static function get_expiry_info(purpose $purpose, int $comparisondate = 0) : expiry_info {
+        if (empty($comparisondate)) {
+            // The date is empty, therefore this context cannot be considered for automatic expiry.
+            $defaultexpired = false;
+        } else {
+            $defaultexpired = static::has_expired($purpose->get('retentionperiod'), $comparisondate);
+        }
+
+        return new expiry_info($defaultexpired);
+    }
+
+    /**
+     * Update or delete the expired_context from the expiry_info object.
+     * This function depends upon the data structure returned from get_nested_expiry_info.
+     *
+     * If the context is expired in any way, then an expired_context will be returned, otherwise null will be returned.
+     *
+     * @param   \stdClass   $expiryrecord
+     * @return  expired_context|null
+     */
+    protected function update_from_expiry_info(\stdClass $expiryrecord) {
+        if ($expiryrecord->info->is_any_expired()) {
+            // The context is expired in some fashion.
+            // Create or update as required.
+            if ($expiryrecord->record->expiredctxid) {
+                $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
+                $expiredcontext->update_from_expiry_info($expiryrecord->info);
+
+                if ($expiredcontext->is_complete()) {
+                    return null;
+                }
+            } else {
+                $expiredcontext = expired_context::create_from_expiry_info($expiryrecord->context, $expiryrecord->info);
+            }
+
+            return $expiredcontext;
+        } else {
+            // The context is not expired.
+            if ($expiryrecord->record->expiredctxid) {
+                // There was previously an expired context record, but it is no longer relevant.
+                // Delete it to be on the safe side.
+                $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
+                $expiredcontext->delete();
+            }
+
+            return null;
+        }
+    }
+
+    /**
+     * Update the expired context record.
+     *
+     * Note: You should use the return value as the provided value will be used to fetch data only.
+     *
+     * @param   expired_context $expiredctx The record to update
+     * @return  expired_context|null
+     */
+    protected function update_expired_context(expired_context $expiredctx) {
+        // Fetch the context from the expired_context record.
+        $context = \context::instance_by_id($expiredctx->get('contextid'));
+
+        // Fetch the current nested expiry data.
+        $expiryrecords = self::get_nested_expiry_info($context->path);
+
+        // Find the current record.
+        if (empty($expiryrecords[$context->path])) {
+            $expiredctx->delete();
+            return null;
+        }
+
+        // Refresh the record.
+        // Note: Use the returned expiredctx.
+        $expiredctx = $this->update_from_expiry_info($expiryrecords[$context->path]);
+        if (empty($expiredctx)) {
+            return null;
+        }
+
+        if (!$context instanceof \context_user) {
+            // Where the target context is not a user, we check all children of the context.
+            // The expiryrecords array only contains children, fetched from the get_nested_expiry_info call above.
+            // No need to check that these _are_ children.
+            foreach ($expiryrecords as $expiryrecord) {
+                if ($expiryrecord->context->id === $context->id) {
+                    // This is record for the context being tested that we checked earlier.
+                    continue;
+                }
+
+                if (empty($expiryrecord->record->expiredctxid)) {
+                    // There is no expired context record for this context.
+                    // If there is no record, then this context cannot have been approved for removal.
+                    return null;
+                }
+
+                // Fetch the expired_context object for this record.
+                // This needs to be updated from the expiry_info data too as there may be child changes to consider.
+                $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
+                $expiredcontext->update_from_expiry_info($expiryrecord->info);
+                if (!$expiredcontext->is_complete()) {
+                    return null;
+                }
+            }
+        }
+
+        return $expiredctx;
+    }
+
+    /**
+     * Check whether the course has expired.
+     *
+     * @param   \stdClass   $course
+     * @return  bool
+     */
+    protected static function is_course_expired(\stdClass $course) : bool {
+        $context = \context_course::instance($course->id);
+        $expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
+
+        return !empty($expiryrecords[$context->path]) && $expiryrecords[$context->path]->info->is_fully_expired();
+    }
+
+    /**
+     * Create a new instance of the privacy manager.
+     *
+     * @return  manager
+     */
+    protected function get_privacy_manager() : manager {
+        if (null === $this->manager) {
+            $this->manager = new manager();
+            $this->manager->set_observer(new \tool_dataprivacy\manager_observer());
+        }
+
+        return $this->manager;
+    }
+
+    /**
+     * Fetch the limit for the maximum number of contexts to delete in one session.
+     *
+     * @return  int
+     */
+    protected function get_delete_limit() : int {
+        return self::DELETE_LIMIT;
+    }
+
+    /**
+     * Get the progress tracer.
+     *
+     * @return  \progress_trace
+     */
+    protected function get_progress() : \progress_trace {
+        if (null === $this->progresstracer) {
+            $this->set_progress(new \text_progress_trace());
+        }
+
+        return $this->progresstracer;
+    }
+
+    /**
+     * Set a specific tracer for the task.
+     *
+     * @param   \progress_trace $trace
+     * @return  $this
+     */
+    public function set_progress(\progress_trace $trace) : expired_contexts_manager {
+        $this->progresstracer = $trace;
+
+        return $this;
+    }
 }
diff --git a/admin/tool/dataprivacy/classes/expired_course_related_contexts.php b/admin/tool/dataprivacy/classes/expired_course_related_contexts.php
deleted file mode 100644 (file)
index f878edf..0000000
+++ /dev/null
@@ -1,135 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * Expired contexts manager for CONTEXT_COURSE, CONTEXT_MODULE and CONTEXT_BLOCK.
- *
- * @package    tool_dataprivacy
- * @copyright  2018 David Monllao
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-namespace tool_dataprivacy;
-
-use tool_dataprivacy\purpose;
-
-defined('MOODLE_INTERNAL') || die();
-
-/**
- * Expired contexts manager for CONTEXT_COURSE, CONTEXT_MODULE and CONTEXT_BLOCK.
- *
- * @copyright  2018 David Monllao
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class expired_course_related_contexts extends \tool_dataprivacy\expired_contexts_manager {
-
-    /**
-     * Course-related context levels.
-     *
-     * @return int[]
-     */
-    protected function get_context_levels() {
-        return [CONTEXT_MODULE, CONTEXT_BLOCK, CONTEXT_COURSE];
-    }
-
-    /**
-     * Returns a recordset with user context instances that are possibly expired (to be confirmed by get_recordset_callback).
-     *
-     * @return \stdClass[]
-     */
-    protected function get_expired_contexts() {
-        global $DB;
-
-        // Including context info + course end date + purposeid (this last one only if defined).
-        $fields = 'ctx.id AS id, ctxcourse.enddate AS courseenddate, dpctx.purposeid AS purposeid, ' .
-            \context_helper::get_preload_record_columns_sql('ctx');
-
-        // We want all contexts at course-dependant levels.
-        $parentpath = $DB->sql_concat('ctxcourse.path', "'/%'");
-
-        // This SQL query returns all course-dependant contexts (including the course context)
-        // which course end date already passed.
-        $sql = "SELECT $fields
-                  FROM {context} ctx
-                  JOIN (
-                        SELECT c.enddate, subctx.path
-                          FROM {context} subctx
-                          JOIN {course} c
-                            ON subctx.contextlevel = ? AND subctx.instanceid = c.id
-                         WHERE c.enddate < ? AND c.enddate > 0
-                       ) ctxcourse
-                    ON ctx.path LIKE {$parentpath} OR ctx.path = ctxcourse.path
-             LEFT JOIN {tool_dataprivacy_ctxinstance} dpctx
-                    ON dpctx.contextid = ctx.id
-             LEFT JOIN {tool_dataprivacy_ctxexpired} expiredctx
-                    ON ctx.id = expiredctx.contextid
-                 WHERE expiredctx.id IS NULL
-              ORDER BY ctx.contextlevel DESC, ctx.path";
-        $possiblyexpired = $DB->get_recordset_sql($sql, [CONTEXT_COURSE, time()]);
-
-        $expiredcontexts = [];
-        $excludedcontextids = [];
-        foreach ($possiblyexpired as $record) {
-
-            \context_helper::preload_from_record($record);
-
-            // No strict checking as the context may already be deleted (e.g. we just deleted a course,
-            // module contexts below it will not exist).
-            $context = \context::instance_by_id($record->id, false);
-            if (!$context) {
-                continue;
-            }
-
-            // We pass the value we just got from SQL so get_effective_context_purpose don't need to query
-            // the db again to retrieve it. If there is no tool_dataprovider_ctxinstance record
-            // $record->purposeid will be null which is ok as it would force get_effective_context_purpose
-            // to return the default purpose for the context context level (no db queries involved).
-            $purposevalue = $record->purposeid !== null ? $record->purposeid : context_instance::NOTSET;
-
-            // It should be cheap as system purposes and context level purposes will be retrieved from a cache most of the time.
-            $purpose = api::get_effective_context_purpose($context, $purposevalue);
-
-            $dt = new \DateTime();
-            $dt->setTimestamp($record->courseenddate);
-            $di = new \DateInterval($purpose->get('retentionperiod'));
-            $dt->add($di);
-
-            if (time() < $dt->getTimestamp()) {
-                // Exclude this context ID as it has not reached the retention period yet.
-                $excludedcontextids[] = $context->id;
-                continue;
-            }
-
-            // Check if this context has children that have not yet expired.
-            $hasunexpiredchildren = false;
-            $children = $context->get_child_contexts();
-            foreach ($children as $child) {
-                if (in_array($child->id, $excludedcontextids)) {
-                    $hasunexpiredchildren = true;
-                    break;
-                }
-            }
-            if ($hasunexpiredchildren) {
-                // Exclude this context ID as it has children that have not yet expired.
-                $excludedcontextids[] = $context->id;
-                continue;
-            }
-
-            $expiredcontexts[$context->id] = $context;
-        }
-
-        return $expiredcontexts;
-    }
-}
diff --git a/admin/tool/dataprivacy/classes/expired_user_contexts.php b/admin/tool/dataprivacy/classes/expired_user_contexts.php
deleted file mode 100644 (file)
index 924d565..0000000
+++ /dev/null
@@ -1,150 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * Expired contexts manager for CONTEXT_USER.
- *
- * @package    tool_dataprivacy
- * @copyright  2018 David Monllao
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-namespace tool_dataprivacy;
-
-use core_privacy\manager;
-
-defined('MOODLE_INTERNAL') || die();
-
-/**
- * Expired contexts manager for CONTEXT_USER.
- *
- * @copyright  2018 David Monllao
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class expired_user_contexts extends \tool_dataprivacy\expired_contexts_manager {
-
-    /**
-     * Only user level.
-     *
-     * @return int[]
-     */
-    protected function get_context_levels() {
-        return [CONTEXT_USER];
-    }
-
-    /**
-     * Returns the user context instances that are expired.
-     *
-     * @return \stdClass[]
-     */
-    protected function get_expired_contexts() {
-        global $DB;
-
-        // Including context info + last login timestamp.
-        $fields = 'ctx.id AS id, ' . \context_helper::get_preload_record_columns_sql('ctx');
-
-        $purpose = api::get_effective_contextlevel_purpose(CONTEXT_USER);
-
-        // Calculate what is considered expired according to the context level effective purpose (= now + retention period).
-        $expiredtime = new \DateTime();
-        $retention = new \DateInterval($purpose->get('retentionperiod'));
-        $expiredtime->sub($retention);
-
-        $sql = "SELECT $fields FROM {context} ctx
-                  JOIN {user} u ON ctx.contextlevel = ? AND ctx.instanceid = u.id
-                  LEFT JOIN {tool_dataprivacy_ctxexpired} expiredctx ON ctx.id = expiredctx.contextid
-                 WHERE u.lastaccess <= ? AND u.lastaccess > 0 AND expiredctx.id IS NULL
-                ORDER BY ctx.path, ctx.contextlevel ASC";
-        $possiblyexpired = $DB->get_recordset_sql($sql, [CONTEXT_USER, $expiredtime->getTimestamp()]);
-
-        $expiredcontexts = [];
-        foreach ($possiblyexpired as $record) {
-
-            \context_helper::preload_from_record($record);
-
-            // No strict checking as the context may already be deleted (e.g. we just deleted a course,
-            // module contexts below it will not exist).
-            $context = \context::instance_by_id($record->id, false);
-            if (!$context) {
-                continue;
-            }
-
-            if (is_siteadmin($context->instanceid)) {
-                continue;
-            }
-
-            $courses = enrol_get_users_courses($context->instanceid, false, ['enddate']);
-            foreach ($courses as $course) {
-                if (!$course->enddate) {
-                    // We can not know it what is going on here, so we prefer to be conservative.
-                    continue 2;
-                }
-
-                if ($course->enddate >= time()) {
-                    // Future or ongoing course.
-                    continue 2;
-                }
-            }
-
-            $expiredcontexts[$context->id] = $context;
-        }
-
-        return $expiredcontexts;
-    }
-
-    /**
-     * Deletes user data from the provided context.
-     *
-     * Overwritten to delete the user.
-     *
-     * @param manager $privacymanager
-     * @param expired_context $expiredctx
-     * @return \context|false
-     */
-    protected function delete_expired_context(manager $privacymanager, expired_context $expiredctx) {
-        $context = \context::instance_by_id($expiredctx->get('contextid'), IGNORE_MISSING);
-        if (!$context) {
-            api::delete_expired_context($expiredctx->get('contextid'));
-            return false;
-        }
-
-        if (!PHPUNIT_TEST) {
-            mtrace('Deleting context ' . $context->id . ' - ' .
-                shorten_text($context->get_context_name(true, true)));
-        }
-
-        // To ensure that all user data is deleted, instead of deleting by context, we run through and collect any stray
-        // contexts for the user that may still exist and call delete_data_for_user().
-        $user = \core_user::get_user($context->instanceid, '*', MUST_EXIST);
-        $approvedlistcollection = new \core_privacy\local\request\contextlist_collection($user->id);
-        $contextlistcollection = $privacymanager->get_contexts_for_userid($user->id);
-
-        foreach ($contextlistcollection as $contextlist) {
-            $approvedlistcollection->add_contextlist(new \core_privacy\local\request\approved_contextlist(
-                $user,
-                $contextlist->get_component(),
-                $contextlist->get_contextids()
-            ));
-        }
-
-        $privacymanager->delete_data_for_user($approvedlistcollection);
-        api::set_expired_context_status($expiredctx, expired_context::STATUS_CLEANED);
-
-        // Delete the user.
-        delete_user($user);
-
-        return $context;
-    }
-}
diff --git a/admin/tool/dataprivacy/classes/expiry_info.php b/admin/tool/dataprivacy/classes/expiry_info.php
new file mode 100644 (file)
index 0000000..508214f
--- /dev/null
@@ -0,0 +1,93 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Expiry Data.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace tool_dataprivacy;
+
+use core_privacy\manager;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Expiry Data.
+ *
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class expiry_info {
+
+    /** @var bool Whether this context is fully expired */
+    protected $isexpired = false;
+
+    /**
+     * Constructor for the expiry_info class.
+     *
+     * @param   bool    $isexpired Whether the retention period for this context has expired yet.
+     */
+    public function __construct(bool $isexpired) {
+        $this->isexpired = $isexpired;
+    }
+
+    /**
+     * Whether this context has 'fully' expired.
+     * That is to say that the default retention period has been reached, and that there are no unexpired roles.
+     *
+     * @return  bool
+     */
+    public function is_fully_expired() : bool {
+        return $this->isexpired;
+    }
+
+    /**
+     * Whether any part of this context has expired.
+     *
+     * @return  bool
+     */
+    public function is_any_expired() : bool {
+        if ($this->is_fully_expired()) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Merge this expiry_info object with another belonging to a child context in order to set the 'safest' heritage.
+     *
+     * It is not possible to delete any part of a context that is not deleted by a parent.
+     * So if a course's retention policy has been reached, then only parts where the children have also expired can be
+     * deleted.
+     *
+     * @param   expiry_info $child The child record to merge with.
+     * @return  $this
+     */
+    public function merge_with_child(expiry_info $child) : expiry_info {
+        if ($child->is_fully_expired()) {
+            return $this;
+        }
+
+        // If the child is not fully expired, then none of the parents can be either.
+        $this->isexpired = false;
+
+        return $this;
+    }
+}
index c3f9f65..3dd839e 100644 (file)
@@ -92,17 +92,30 @@ class external extends external_api {
         ]);
         $requestid = $params['requestid'];
 
-        // Validate context.
+        // Validate context and access to manage the registry.
         $context = context_user::instance($USER->id);
         self::validate_context($context);
 
         // Ensure the request exists.
         $select = 'id = :id AND (userid = :userid OR requestedby = :requestedby)';
         $params = ['id' => $requestid, 'userid' => $USER->id, 'requestedby' => $USER->id];
-        $requestexists = data_request::record_exists_select($select, $params);
+        $requests = data_request::get_records_select($select, $params);
+        $requestexists = count($requests) === 1;
 
         $result = false;
         if ($requestexists) {
+            $request = reset($requests);
+            $datasubject = $request->get('userid');
+
+            if ($datasubject !== $USER->id) {
+                // The user is not the subject. Check that they can cancel this request.
+                if (!api::can_create_data_request_for_user($datasubject)) {
+                    $forusercontext = \context_user::instance($datasubject);
+                    throw new required_capability_exception($forusercontext,
+                            'tool/dataprivacy:makedatarequestsforchildren', 'nopermissions', '');
+                }
+            }
+
             // TODO: Do we want a request to be non-cancellable past a certain point? E.g. When it's already approved/processing.
             $result = api::update_request_status($requestid, api::DATAREQUEST_STATUS_CANCELLED);
         } else {
@@ -257,9 +270,10 @@ class external extends external_api {
         ]);
         $requestid = $params['requestid'];
 
-        // Validate context.
+        // Validate context and access to manage the registry.
         $context = context_system::instance();
         self::validate_context($context);
+        api::check_can_manage_data_registry();
 
         $message = get_string('markedcomplete', 'tool_dataprivacy');
         // Update the data request record.
@@ -748,7 +762,9 @@ class external extends external_api {
             'jsonformdata' => $jsonformdata
         ]);
 
+        // Validate context and access to manage the registry.
         self::validate_context(\context_system::instance());
+        api::check_can_manage_data_registry();
 
         $serialiseddata = json_decode($params['jsonformdata']);
         $data = array();
@@ -816,6 +832,10 @@ class external extends external_api {
             'id' => $id
         ]);
 
+        // Validate context and access to manage the registry.
+        self::validate_context(\context_system::instance());
+        api::check_can_manage_data_registry();
+
         $result = api::delete_purpose($params['id']);
 
         return [
@@ -865,7 +885,9 @@ class external extends external_api {
             'jsonformdata' => $jsonformdata
         ]);
 
+        // Validate context and access to manage the registry.
         self::validate_context(\context_system::instance());
+        api::check_can_manage_data_registry();
 
         $serialiseddata = json_decode($params['jsonformdata']);
         $data = array();
@@ -933,6 +955,10 @@ class external extends external_api {
             'id' => $id
         ]);
 
+        // Validate context and access to manage the registry.
+        self::validate_context(\context_system::instance());
+        api::check_can_manage_data_registry();
+
         $result = api::delete_category($params['id']);
 
         return [
@@ -982,8 +1008,9 @@ class external extends external_api {
             'jsonformdata' => $jsonformdata
         ]);
 
-        // Extra permission checkings are delegated to api::set_contextlevel.
+        // Validate context and access to manage the registry.
         self::validate_context(\context_system::instance());
+        api::check_can_manage_data_registry();
 
         $serialiseddata = json_decode($params['jsonformdata']);
         $data = array();
@@ -997,7 +1024,6 @@ class external extends external_api {
             $contextlevel = api::set_contextlevel($validateddata);
         } else if ($errors = $mform->is_validated()) {
             $warnings[] = json_encode($errors);
-            throw new moodle_exception('generalerror');
         }
 
         if ($contextlevel) {
@@ -1052,8 +1078,9 @@ class external extends external_api {
             'jsonformdata' => $jsonformdata
         ]);
 
-        // Extra permission checkings are delegated to api::set_context_instance.
+        // Validate context and access to manage the registry.
         self::validate_context(\context_system::instance());
+        api::check_can_manage_data_registry();
 
         $serialiseddata = json_decode($params['jsonformdata']);
         $data = array();
@@ -1063,6 +1090,7 @@ class external extends external_api {
         $customdata = \tool_dataprivacy\form\context_instance::get_context_instance_customdata($context);
         $mform = new \tool_dataprivacy\form\context_instance(null, $customdata, 'post', '', null, true, $data);
         if ($validateddata = $mform->get_data()) {
+            api::check_can_manage_data_registry($validateddata->contextid);
             $context = api::set_context_instance($validateddata);
         } else if ($errors = $mform->is_validated()) {
             $warnings[] = json_encode($errors);
@@ -1192,9 +1220,9 @@ class external extends external_api {
         ]);
         $ids = $params['ids'];
 
-        // Validate context.
-        $context = context_system::instance();
-        self::validate_context($context);
+        // Validate context and access to manage the registry.
+        self::validate_context(\context_system::instance());
+        api::check_can_manage_data_registry();
 
         $result = true;
         if (!empty($ids)) {
@@ -1204,24 +1232,27 @@ class external extends external_api {
                 $expiredcontext = new expired_context($id);
                 $targetcontext = context_helper::instance_by_id($expiredcontext->get('contextid'));
 
-                // Fetch this context's child contexts. Make sure that all of the child contexts are flagged for deletion.
-                $childcontexts = $targetcontext->get_child_contexts();
-                foreach ($childcontexts as $child) {
-                    if ($expiredchildcontext = expired_context::get_record(['contextid' => $child->id])) {
-                        // Add this child context to the list for approval.
-                        $expiredcontextstoapprove[] = $expiredchildcontext;
-                    } else {
-                        // This context has not yet been flagged for deletion.
-                        $result = false;
-                        $message = get_string('errorcontexthasunexpiredchildren', 'tool_dataprivacy',
-                            $targetcontext->get_context_name(false));
-                        $warnings[] = [
-                            'item' => 'tool_dataprivacy_ctxexpired',
-                            'warningcode' => 'errorcontexthasunexpiredchildren',
-                            'message' => $message
-                        ];
-                        // Exit the process.
-                        break 2;
+                if (!$targetcontext instanceof \context_user) {
+                    // Fetch this context's child contexts. Make sure that all of the child contexts are flagged for deletion.
+                    // User context children do not need to be considered.
+                    $childcontexts = $targetcontext->get_child_contexts();
+                    foreach ($childcontexts as $child) {
+                        if ($expiredchildcontext = expired_context::get_record(['contextid' => $child->id])) {
+                            // Add this child context to the list for approval.
+                            $expiredcontextstoapprove[] = $expiredchildcontext;
+                        } else {
+                            // This context has not yet been flagged for deletion.
+                            $result = false;
+                            $message = get_string('errorcontexthasunexpiredchildren', 'tool_dataprivacy',
+                                $targetcontext->get_context_name(false));
+                            $warnings[] = [
+                                'item' => 'tool_dataprivacy_ctxexpired',
+                                'warningcode' => 'errorcontexthasunexpiredchildren',
+                                'message' => $message
+                            ];
+                            // Exit the process.
+                            break 2;
+                        }
                     }
                 }
 
@@ -1313,6 +1344,7 @@ class external extends external_api {
         // Validate context.
         $context = context_system::instance();
         self::validate_context($context);
+        api::check_can_manage_data_registry();
 
         // Set the context defaults.
         $result = api::set_context_defaults($contextlevel, $category, $purpose, $activity, $override);
@@ -1366,6 +1398,7 @@ class external extends external_api {
 
         $context = context_system::instance();
         self::validate_context($context);
+        api::check_can_manage_data_registry();
 
         $categories = api::get_categories();
         $options = data_registry_page::category_options($categories, $includenotset, $includeinherit);
index 55deeec..8a4f96b 100644 (file)
@@ -52,18 +52,10 @@ class delete_expired_contexts extends scheduled_task {
 
     /**
      * Run the task to delete context instances based on their retention periods.
-     *
      */
     public function execute() {
-        $manager = new \tool_dataprivacy\expired_course_related_contexts();
-        $deleted = $manager->delete();
-        if ($deleted > 0) {
-            mtrace($deleted . ' course-related contexts have been deleted');
-        }
-        $manager = new \tool_dataprivacy\expired_user_contexts();
-        $deleted = $manager->delete();
-        if ($deleted > 0) {
-            mtrace($deleted . ' user contexts have been deleted');
-        }
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($courses, $users) = $manager->process_approved_deletions();
+        mtrace("Processed deletions for {$courses} course contexts, and {$users} user contexts as expired");
     }
 }
index 1123067..f00b51a 100644 (file)
@@ -54,15 +54,8 @@ class expired_retention_period extends scheduled_task {
      * Run the task to flag context instances as expired.
      */
     public function execute() {
-        $manager = new \tool_dataprivacy\expired_course_related_contexts();
-        $flagged = $manager->flag_expired();
-        if ($flagged > 0) {
-            mtrace($flagged . ' course-related contexts have been flagged as expired');
-        }
-        $manager = new \tool_dataprivacy\expired_user_contexts();
-        $flagged = $manager->flag_expired();
-        if ($flagged > 0) {
-            mtrace($flagged . ' user contexts have been flagged as expired');
-        }
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($courses, $users) = $manager->flag_expired_contexts();
+        mtrace("Flagged {$courses} course contexts, and {$users} user contexts as expired");
     }
 }
index 2a2fb81..c0ea7ac 100644 (file)
@@ -24,7 +24,6 @@
 
 require_once('../../../config.php');
 require_once('lib.php');
-require_once('classes/api.php');
 require_once('createdatarequest_form.php');
 
 $manage = optional_param('manage', 0, PARAM_INT);
@@ -67,6 +66,14 @@ if ($mform->is_cancelled()) {
 
 // Data request submitted.
 if ($data = $mform->get_data()) {
+    if ($data->userid != $USER->id) {
+        if (!\tool_dataprivacy\api::can_manage_data_requests($USER->id)) {
+            // If not a DPO, only users with the capability to make data requests for the user should be allowed.
+            // (e.g. users with the Parent role, etc).
+            \tool_dataprivacy\api::require_can_create_data_request_for_user($data->userid);
+        }
+    }
+
     \tool_dataprivacy\api::create_data_request($data->userid, $data->type, $data->comments);
 
     if ($manage) {
index 5ab4dfd..0cbc55f 100644 (file)
@@ -248,6 +248,15 @@ $string['requesttypeexport'] = 'Export all of my personal data';
 $string['requesttypeexportshort'] = 'Export';
 $string['requesttypeothers'] = 'General inquiry';
 $string['requesttypeothersshort'] = 'Message';
+$string['requireallenddatesforuserdeletion'] = 'Consider courses without end date as active';
+$string['requireallenddatesforuserdeletion_desc'] = 'When calculating user expiry, several factors are considered:
+
+* the user\'s last login time is compared against the retention period for users; and
+* whether the user is actively enrolled in any courses.
+
+When checking the active enrolment of a corse, if the course has no end date then this setting is used to determine whether that course is considered active or not.
+
+If the course has no end date, and this setting is enabled, then the user cannot be deleted.';
 $string['requiresattention'] = 'Requires attention.';
 $string['requiresattentionexplanation'] = 'This plugin does not implement the Moodle privacy API. If this plugin stores any personal data it will not be able to be exported or deleted through Moodle\'s privacy system.';
 $string['resultdeleted'] = 'You recently requested to have your account and personal data in {$a} to be deleted. This process has been completed and you will no longer be able to log in.';
index b902d52..d3abf47 100644 (file)
@@ -60,6 +60,12 @@ if ($hassiteconfig) {
                     new lang_string('dporolemapping_desc', 'tool_dataprivacy'), null, $roles)
             );
         }
+
+        // When calculating user expiry, should courses which have no end date be considered.
+        $privacysettings->add(new admin_setting_configcheckbox('tool_dataprivacy/requireallenddatesforuserdeletion',
+                new lang_string('requireallenddatesforuserdeletion', 'tool_dataprivacy'),
+                new lang_string('requireallenddatesforuserdeletion_desc', 'tool_dataprivacy'),
+                1));
     }
 }
 
index 523a56b..6ddb7f1 100644 (file)
             {{#pix}}t/add, moodle, {{#str}}addcategory, tool_dataprivacy{{/str}}{{/pix}}
         </button>
     </div>
-    <table class="generaltable fullwidth">
+    <table class="table table-striped table-hover">
         <caption class="accesshide">{{#str}}categorieslist, tool_dataprivacy{{/str}}</caption>
         <thead>
             <tr>
                 <th scope="col">{{#str}}name{{/str}}</th>
-                <th scope="col">{{#str}}description{{/str}}</th>
+                <th scope="col" class="w-50">{{#str}}description{{/str}}</th>
                 <th scope="col">{{#str}}actions{{/str}}</th>
             </tr>
         </thead>
index fe25440..6408530 100644 (file)
             {{#pix}}t/add, moodle, {{#str}}addpurpose, tool_dataprivacy{{/str}}{{/pix}}
         </button>
     </div>
-    <table class="generaltable fullwidth">
+    <table class="table table-striped table-hover">
         <caption class="accesshide">{{#str}}purposeslist, tool_dataprivacy{{/str}}</caption>
         <thead>
             <tr>
-                <th scope="col" class="col-md-2">{{#str}}name{{/str}}</th>
-                <th scope="col" class="col-md-3">{{#str}}description{{/str}}</th>
-                <th scope="col" class="col-md-2">{{#str}}lawfulbases, tool_dataprivacy{{/str}}</th>
-                <th scope="col" class="col-md-2">{{#str}}sensitivedatareasons, tool_dataprivacy{{/str}}</th>
-                <th scope="col" class="col-md-1">{{#str}}retentionperiod, tool_dataprivacy{{/str}}</th>
-                <th scope="col" class="col-md-1">{{#str}}protected, tool_dataprivacy{{/str}}</th>
-                <th scope="col" class="col-md-1">{{#str}}actions{{/str}}</th>
+                <th scope="col" class="w-25">{{#str}}name{{/str}}</th>
+                <th scope="col">{{#str}}lawfulbases, tool_dataprivacy{{/str}}</th>
+                <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}}actions{{/str}}</th>
             </tr>
         </thead>
         <tbody>
             {{#purposes}}
             <tr data-purposeid="{{id}}">
-                <td class="col-md-2">{{{name}}}</td>
-                <td class="col-md-3">{{{description}}}</td>
-                <td class="col-md-2">
-                    <ul>
+                <td>
+                    <dl>
+                        <dt>
+                            {{{name}}}
+                        </dt>
+                        <dd>
+                            {{{description}}}
+                        </dd>
+                    </dl>
+                </td>
+                <td>
+                    <ul class="list-unstyled">
                         {{#formattedlawfulbases}}
                             <li>
                                 <span>{{name}}{{# pix }} i/info, core, {{description}} {{/ pix }}</span>
@@ -90,8 +97,8 @@
                         {{/formattedlawfulbases}}
                     </ul>
                 </td>
-                <td class="col-md-2">
-                    <ul>
+                <td>
+                    <ul class="list-unstyled">
                         {{#formattedsensitivedatareasons}}
                             <li>
                                 <span>{{name}}{{# pix }} i/info, core, {{description}} {{/ pix }}</span>
                         {{/formattedsensitivedatareasons}}
                     </ul>
                 </td>
-                <td class="col-md-1">{{formattedretentionperiod}}</td>
-                <td class="col-md-1">
+                <td>{{formattedretentionperiod}}</td>
+                <td>
                     {{#protected}}
                         {{#pix}}i/checked, core, {{#str}}yes{{/str}}{{/pix}}
                     {{/protected}}
                         {{#str}}no{{/str}}
                     {{/protected}}
                 </td>
-                <td class="col-md-1">
+                <td>
                     {{#actions}}
                         {{> core/action_menu}}
                     {{/actions}}
index 820611c..3ae2f1f 100644 (file)
@@ -46,16 +46,65 @@ global $CFG;
 class tool_dataprivacy_api_testcase extends advanced_testcase {
 
     /**
-     * setUp.
+     * Ensure that the check_can_manage_data_registry function fails cap testing when a user without capabilities is
+     * tested with the default context.
      */
-    public function setUp() {
+    public function test_check_can_manage_data_registry_admin() {
         $this->resetAfterTest();
+
+        $this->setAdminUser();
+        // Technically this actually returns void, but assertNull will suffice to avoid a pointless test.
+        $this->assertNull(api::check_can_manage_data_registry());
+    }
+
+    /**
+     * Ensure that the check_can_manage_data_registry function fails cap testing when a user without capabilities is
+     * tested with the default context.
+     */
+    public function test_check_can_manage_data_registry_without_cap_default() {
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        $this->expectException(required_capability_exception::class);
+        api::check_can_manage_data_registry();
+    }
+
+    /**
+     * Ensure that the check_can_manage_data_registry function fails cap testing when a user without capabilities is
+     * tested with the default context.
+     */
+    public function test_check_can_manage_data_registry_without_cap_system() {
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        $this->expectException(required_capability_exception::class);
+        api::check_can_manage_data_registry(\context_system::instance()->id);
+    }
+
+    /**
+     * Ensure that the check_can_manage_data_registry function fails cap testing when a user without capabilities is
+     * tested with the default context.
+     */
+    public function test_check_can_manage_data_registry_without_cap_own_user() {
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        $this->expectException(required_capability_exception::class);
+        api::check_can_manage_data_registry(\context_user::instance($user->id)->id);
     }
 
     /**
      * Test for api::update_request_status().
      */
     public function test_update_request_status() {
+        $this->resetAfterTest();
+
         $generator = new testing_data_generator();
         $s1 = $generator->create_user();
         $this->setUser($s1);
@@ -65,6 +114,26 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
 
         $requestid = $datarequest->get('id');
 
+        // Update with a comment.
+        $comment = 'This is an example of a comment';
+        $result = api::update_request_status($requestid, api::DATAREQUEST_STATUS_AWAITING_APPROVAL, 0, $comment);
+        $this->assertTrue($result);
+        $datarequest = new data_request($requestid);
+        $this->assertStringEndsWith($comment, $datarequest->get('dpocomment'));
+
+        // Update with a comment which will be trimmed.
+        $result = api::update_request_status($requestid, api::DATAREQUEST_STATUS_AWAITING_APPROVAL, 0, '  ');
+        $this->assertTrue($result);
+        $datarequest = new data_request($requestid);
+        $this->assertStringEndsWith($comment, $datarequest->get('dpocomment'));
+
+        // Update with a comment.
+        $secondcomment = '  - More comments -  ';
+        $result = api::update_request_status($requestid, api::DATAREQUEST_STATUS_AWAITING_APPROVAL, 0, $secondcomment);
+        $this->assertTrue($result);
+        $datarequest = new data_request($requestid);
+        $this->assertRegExp("/.*{$comment}.*{$secondcomment}/s", $datarequest->get('dpocomment'));
+
         // Update with a valid status.
         $result = api::update_request_status($requestid, api::DATAREQUEST_STATUS_DOWNLOAD_READY);
         $this->assertTrue($result);
@@ -82,6 +151,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
      * Test for api::get_site_dpos() when there are no users with the DPO role.
      */
     public function test_get_site_dpos_no_dpos() {
+        $this->resetAfterTest();
+
         $admin = get_admin();
 
         $dpos = api::get_site_dpos();
@@ -95,6 +166,9 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
      */
     public function test_get_site_dpos() {
         global $DB;
+
+        $this->resetAfterTest();
+
         $generator = new testing_data_generator();
         $u1 = $generator->create_user();
         $u2 = $generator->create_user();
@@ -130,6 +204,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
     public function test_get_assigned_privacy_officer_roles() {
         global $DB;
 
+        $this->resetAfterTest();
+
         // Erroneously set the manager roles as the PO, even if it doesn't have the managedatarequests capability yet.
         $managerroleid = $DB->get_field('role', 'id', array('shortname' => 'manager'));
         set_config('dporoles', $managerroleid, 'tool_dataprivacy');
@@ -171,6 +247,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
     public function test_approve_data_request() {
         global $DB;
 
+        $this->resetAfterTest();
+
         $generator = new testing_data_generator();
         $s1 = $generator->create_user();
         $u1 = $generator->create_user();
@@ -213,6 +291,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
     public function test_approve_data_request_not_yet_ready() {
         global $DB;
 
+        $this->resetAfterTest();
+
         $generator = new testing_data_generator();
         $s1 = $generator->create_user();
         $u1 = $generator->create_user();
@@ -243,6 +323,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
      * Test for api::approve_data_request() when called by a user who doesn't have the DPO role.
      */
     public function test_approve_data_request_non_dpo_user() {
+        $this->resetAfterTest();
+
         $generator = new testing_data_generator();
         $student = $generator->create_user();
         $teacher = $generator->create_user();
@@ -252,17 +334,14 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $datarequest = api::create_data_request($student->id, api::DATAREQUEST_TYPE_EXPORT);
 
         $requestid = $datarequest->get('id');
-
-        // Login as a user without DPO role.
-        $this->setUser($teacher);
-        $this->expectException(required_capability_exception::class);
-        api::approve_data_request($requestid);
     }
 
     /**
      * Test for api::can_contact_dpo()
      */
     public function test_can_contact_dpo() {
+        $this->resetAfterTest();
+
         // Default ('contactdataprotectionofficer' is disabled by default).
         $this->assertFalse(api::can_contact_dpo());
 
@@ -281,6 +360,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
     public function test_can_manage_data_requests() {
         global $DB;
 
+        $this->resetAfterTest();
+
         // No configured site DPOs yet.
         $admin = get_admin();
         $this->assertTrue(api::can_manage_data_requests($admin->id));
@@ -317,10 +398,120 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->assertFalse(api::can_manage_data_requests($nondpoincapable->id));
     }
 
+    /**
+     * Test that a user who has no capability to make any data requests for children cannot create data requests for any
+     * other user.
+     */
+    public function test_can_create_data_request_for_user_no() {
+        $this->resetAfterTest();
+
+        $parent = $this->getDataGenerator()->create_user();
+        $otheruser = $this->getDataGenerator()->create_user();
+
+        $this->setUser($parent);
+        $this->assertFalse(api::can_create_data_request_for_user($otheruser->id));
+    }
+
+    /**
+     * Test that a user who has the capability to make any data requests for one other user cannot create data requests
+     * for any other user.
+     */
+    public function test_can_create_data_request_for_user_some() {
+        $this->resetAfterTest();
+
+        $parent = $this->getDataGenerator()->create_user();
+        $child = $this->getDataGenerator()->create_user();
+        $otheruser = $this->getDataGenerator()->create_user();
+
+        $systemcontext = \context_system::instance();
+        $parentrole = $this->getDataGenerator()->create_role();
+        assign_capability('tool/dataprivacy:makedatarequestsforchildren', CAP_ALLOW, $parentrole, $systemcontext);
+        role_assign($parentrole, $parent->id, \context_user::instance($child->id));
+
+        $this->setUser($parent);
+        $this->assertFalse(api::can_create_data_request_for_user($otheruser->id));
+    }
+
+    /**
+     * Test that a user who has the capability to make any data requests for one other user cannot create data requests
+     * for any other user.
+     */
+    public function test_can_create_data_request_for_user_own_child() {
+        $this->resetAfterTest();
+
+        $parent = $this->getDataGenerator()->create_user();
+        $child = $this->getDataGenerator()->create_user();
+
+        $systemcontext = \context_system::instance();
+        $parentrole = $this->getDataGenerator()->create_role();
+        assign_capability('tool/dataprivacy:makedatarequestsforchildren', CAP_ALLOW, $parentrole, $systemcontext);
+        role_assign($parentrole, $parent->id, \context_user::instance($child->id));
+
+        $this->setUser($parent);
+        $this->assertTrue(api::can_create_data_request_for_user($child->id));
+    }
+
+    /**
+     * Test that a user who has no capability to make any data requests for children cannot create data requests for any
+     * other user.
+     */
+    public function test_require_can_create_data_request_for_user_no() {
+        $this->resetAfterTest();
+
+        $parent = $this->getDataGenerator()->create_user();
+        $otheruser = $this->getDataGenerator()->create_user();
+
+        $this->setUser($parent);
+        $this->expectException('required_capability_exception');
+        api::require_can_create_data_request_for_user($otheruser->id);
+    }
+
+    /**
+     * Test that a user who has the capability to make any data requests for one other user cannot create data requests
+     * for any other user.
+     */
+    public function test_require_can_create_data_request_for_user_some() {
+        $this->resetAfterTest();
+
+        $parent = $this->getDataGenerator()->create_user();
+        $child = $this->getDataGenerator()->create_user();
+        $otheruser = $this->getDataGenerator()->create_user();
+
+        $systemcontext = \context_system::instance();
+        $parentrole = $this->getDataGenerator()->create_role();
+        assign_capability('tool/dataprivacy:makedatarequestsforchildren', CAP_ALLOW, $parentrole, $systemcontext);
+        role_assign($parentrole, $parent->id, \context_user::instance($child->id));
+
+        $this->setUser($parent);
+        $this->expectException('required_capability_exception');
+        api::require_can_create_data_request_for_user($otheruser->id);
+    }
+
+    /**
+     * Test that a user who has the capability to make any data requests for one other user cannot create data requests
+     * for any other user.
+     */
+    public function test_require_can_create_data_request_for_user_own_child() {
+        $this->resetAfterTest();
+
+        $parent = $this->getDataGenerator()->create_user();
+        $child = $this->getDataGenerator()->create_user();
+
+        $systemcontext = \context_system::instance();
+        $parentrole = $this->getDataGenerator()->create_role();
+        assign_capability('tool/dataprivacy:makedatarequestsforchildren', CAP_ALLOW, $parentrole, $systemcontext);
+        role_assign($parentrole, $parent->id, \context_user::instance($child->id));
+
+        $this->setUser($parent);
+        $this->assertTrue(api::require_can_create_data_request_for_user($child->id));
+    }
+
     /**
      * Test for api::can_download_data_request_for_user()
      */
     public function test_can_download_data_request_for_user() {
+        $this->resetAfterTest();
+
         $generator = $this->getDataGenerator();
 
         // Three victims.
@@ -372,6 +563,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
      * Test for api::create_data_request()
      */
     public function test_create_data_request() {
+        $this->resetAfterTest();
+
         $generator = new testing_data_generator();
         $user = $generator->create_user();
         $comment = 'sample comment';
@@ -399,6 +592,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
     public function test_create_data_request_by_dpo() {
         global $USER;
 
+        $this->resetAfterTest();
+
         $generator = new testing_data_generator();
         $user = $generator->create_user();
         $comment = 'sample comment';
@@ -426,6 +621,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
     public function test_create_data_request_by_parent() {
         global $DB;
 
+        $this->resetAfterTest();
+
         $generator = new testing_data_generator();
         $user = $generator->create_user();
         $parent = $generator->create_user();
@@ -461,6 +658,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
      * Test for api::deny_data_request()
      */
     public function test_deny_data_request() {
+        $this->resetAfterTest();
+
         $generator = new testing_data_generator();
         $user = $generator->create_user();
         $comment = 'sample comment';
@@ -482,27 +681,6 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->assertTrue($result);
     }
 
-    /**
-     * Test for api::deny_data_request()
-     */
-    public function test_deny_data_request_without_permissions() {
-        $generator = new testing_data_generator();
-        $user = $generator->create_user();
-        $comment = 'sample comment';
-
-        // Login as user.
-        $this->setUser($user->id);
-
-        // Test data request creation.
-        $datarequest = api::create_data_request($user->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
-
-        // Login as a non-DPO user and try to call deny_data_request.
-        $user2 = $generator->create_user();
-        $this->setUser($user2);
-        $this->expectException(required_capability_exception::class);
-        api::deny_data_request($datarequest->get('id'));
-    }
-
     /**
      * Data provider for \tool_dataprivacy_api_testcase::test_get_data_requests().
      *
@@ -535,6 +713,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
      * @param int[] $statuses Status filters.
      */
     public function test_get_data_requests($usertype, $fetchall, $statuses) {
+        $this->resetAfterTest();
+
         $generator = new testing_data_generator();
         $user1 = $generator->create_user();
         $user2 = $generator->create_user();
@@ -667,6 +847,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
      * @param bool $expected The expected result.
      */
     public function test_has_ongoing_request($status, $expected) {
+        $this->resetAfterTest();
+
         $generator = new testing_data_generator();
         $user1 = $generator->create_user();
 
@@ -700,6 +882,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
     public function test_is_site_dpo() {
         global $DB;
 
+        $this->resetAfterTest();
+
         // No configured site DPOs yet.
         $admin = get_admin();
         $this->assertTrue(api::is_site_dpo($admin->id));
@@ -750,6 +934,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
      * @param string $comments The requestor's message to the DPO.
      */
     public function test_notify_dpo($byadmin, $type, $typestringid, $comments) {
+        $this->resetAfterTest();
+
         $generator = new testing_data_generator();
         $user1 = $generator->create_user();
         // Let's just use admin as DPO (It's the default if not set).
@@ -784,86 +970,13 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->assertContains(fullname($user1), $message->fullmessage);
     }
 
-    /**
-     * Test of creating purpose as a user without privileges.
-     */
-    public function test_create_purpose_non_dpo_user() {
-        $pleb = $this->getDataGenerator()->create_user();
-
-        $this->setUser($pleb);
-        $this->expectException(required_capability_exception::class);
-        api::create_purpose((object)[
-            'name' => 'aaa',
-            'description' => '<b>yeah</b>',
-            'descriptionformat' => 1,
-            'retentionperiod' => 'PT1M'
-        ]);
-    }
-
-    /**
-     * Test fetching of purposes as a user without privileges.
-     */
-    public function test_get_purposes_non_dpo_user() {
-        $pleb = $this->getDataGenerator()->create_user();
-        $this->setAdminUser();
-        api::create_purpose((object)[
-            'name' => 'bbb',
-            'description' => '<b>yeah</b>',
-            'descriptionformat' => 1,
-            'retentionperiod' => 'PT1M',
-            'lawfulbases' => 'gdpr_art_6_1_a'
-        ]);
-
-        $this->setUser($pleb);
-        $this->expectException(required_capability_exception::class);
-        api::get_purposes();
-    }
-
-    /**
-     * Test updating of purpose as a user without privileges.
-     */
-    public function test_update_purposes_non_dpo_user() {
-        $pleb = $this->getDataGenerator()->create_user();
-        $this->setAdminUser();
-        $purpose = api::create_purpose((object)[
-            'name' => 'bbb',
-            'description' => '<b>yeah</b>',
-            'descriptionformat' => 1,
-            'retentionperiod' => 'PT1M',
-            'lawfulbases' => 'gdpr_art_6_1_a'
-        ]);
-
-        $this->setUser($pleb);
-        $this->expectException(required_capability_exception::class);
-        $purpose->set('retentionperiod', 'PT2M');
-        api::update_purpose($purpose->to_record());
-    }
-
-    /**
-     * Test purpose deletion as a user without privileges.
-     */
-    public function test_delete_purpose_non_dpo_user() {
-        $pleb = $this->getDataGenerator()->create_user();
-        $this->setAdminUser();
-        $purpose = api::create_purpose((object)[
-            'name' => 'bbb',
-            'description' => '<b>yeah</b>',
-            'descriptionformat' => 1,
-            'retentionperiod' => 'PT1M',
-            'lawfulbases' => 'gdpr_art_6_1_a'
-        ]);
-
-        $this->setUser($pleb);
-        $this->expectException(required_capability_exception::class);
-        api::delete_purpose($purpose->get('id'));
-    }
-
     /**
      * Test data purposes CRUD actions.
      *
      * @return null
      */
     public function test_purpose_crud() {
+        $this->resetAfterTest();
 
         $this->setAdminUser();
 
@@ -899,86 +1012,13 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->assertCount(0, api::get_purposes());
     }
 
-    /**
-     * Test creation of data categories as a user without privileges.
-     */
-    public function test_create_category_non_dpo_user() {
-        $pleb = $this->getDataGenerator()->create_user();
-
-        $this->setUser($pleb);
-        $this->expectException(required_capability_exception::class);
-        api::create_category((object)[
-            'name' => 'bbb',
-            'description' => '<b>yeah</b>',
-            'descriptionformat' => 1
-        ]);
-    }
-
-    /**
-     * Test fetching of data categories as a user without privileges.
-     */
-    public function test_get_categories_non_dpo_user() {
-        $pleb = $this->getDataGenerator()->create_user();
-
-        $this->setAdminUser();
-        api::create_category((object)[
-            'name' => 'bbb',
-            'description' => '<b>yeah</b>',
-            'descriptionformat' => 1
-        ]);
-
-        // Back to a regular user.
-        $this->setUser($pleb);
-        $this->expectException(required_capability_exception::class);
-        api::get_categories();
-    }
-
-    /**
-     * Test updating of data category as a user without privileges.
-     */
-    public function test_update_category_non_dpo_user() {
-        $pleb = $this->getDataGenerator()->create_user();
-
-        $this->setAdminUser();
-        $category = api::create_category((object)[
-            'name' => 'bbb',
-            'description' => '<b>yeah</b>',
-            'descriptionformat' => 1
-        ]);
-
-        // Back to a regular user.
-        $this->setUser($pleb);
-        $this->expectException(required_capability_exception::class);
-        $category->set('name', 'yeah');
-        api::update_category($category->to_record());
-    }
-
-    /**
-     * Test deletion of data category as a user without privileges.
-     */
-    public function test_delete_category_non_dpo_user() {
-        $pleb = $this->getDataGenerator()->create_user();
-
-        $this->setAdminUser();
-        $category = api::create_category((object)[
-            'name' => 'bbb',
-            'description' => '<b>yeah</b>',
-            'descriptionformat' => 1
-        ]);
-
-        // Back to a regular user.
-        $this->setUser($pleb);
-        $this->expectException(required_capability_exception::class);
-        api::delete_category($category->get('id'));
-        $this->fail('Users shouldn\'t be allowed to manage categories by default');
-    }
-
     /**
      * Test data categories CRUD actions.
      *
      * @return null
      */
     public function test_category_crud() {
+        $this->resetAfterTest();
 
         $this->setAdminUser();
 
@@ -1018,6 +1058,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
     public function test_context_instances() {
         global $DB;
 
+        $this->resetAfterTest();
+
         $this->setAdminUser();
 
         list($purposes, $categories, $courses, $modules) = $this->add_purposes_and_categories();
@@ -1052,6 +1094,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
     public function test_contextlevel() {
         global $DB;
 
+        $this->resetAfterTest();
+
         $this->setAdminUser();
         list($purposes, $categories, $courses, $modules) = $this->add_purposes_and_categories();
 
@@ -1086,6 +1130,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
     public function test_effective_contextlevel_defaults() {
         $this->setAdminUser();
 
+        $this->resetAfterTest();
+
         list($purposes, $categories, $courses, $modules) = $this->add_purposes_and_categories();
 
         list($purposeid, $categoryid) = data_registry::get_effective_default_contextlevel_purpose_and_category(CONTEXT_SYSTEM);
@@ -1129,14 +1175,22 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->assertEquals($categories[0]->get('id'), $categoryid);
     }
 
+    public function test_get_effective_contextlevel_category() {
+        // Before setup, get_effective_contextlevel_purpose will return false.
+        $this->assertFalse(api::get_effective_contextlevel_category(CONTEXT_SYSTEM));
+    }
+
     /**
      * Test effective contextlevel return.
-     *
-     * @return null
      */
     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));
+
         list($purposes, $categories, $courses, $modules) = $this->add_purposes_and_categories();
 
         // Set the system context level to purpose 1.
@@ -1184,6 +1238,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
      * @return null
      */
     public function test_effective_context() {
+        $this->resetAfterTest();
+
         $this->setAdminUser();
 
         list($purposes, $categories, $courses, $modules) = $this->add_purposes_and_categories();
@@ -1264,59 +1320,13 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->assertEquals($categories[1]->get('id'), $category->get('id'));
     }
 
-    /**
-     * Tests the deletion of expired contexts.
-     *
-     * @return null
-     */
-    public function test_expired_context_deletion() {
-        global $DB;
-
-        $this->setAdminUser();
-
-        list($purposes, $categories, $courses, $modules) = $this->add_purposes_and_categories();
-
-        $course0context = \context_course::instance($courses[0]->id);
-        $course1context = \context_course::instance($courses[1]->id);
-
-        $expiredcontext0 = api::create_expired_context($course0context->id);
-        $this->assertEquals(1, $DB->count_records('tool_dataprivacy_ctxexpired'));
-        $expiredcontext1 = api::create_expired_context($course1context->id);
-        $this->assertEquals(2, $DB->count_records('tool_dataprivacy_ctxexpired'));
-
-        api::delete_expired_context($expiredcontext0->get('id'));
-        $this->assertEquals(1, $DB->count_records('tool_dataprivacy_ctxexpired'));
-    }
-
-    /**
-     * Tests the status of expired contexts.
-     *
-     * @return null
-     */
-    public function test_expired_context_status() {
-        global $DB;
-
-        $this->setAdminUser();
-
-        list($purposes, $categories, $courses, $modules) = $this->add_purposes_and_categories();
-
-        $course0context = \context_course::instance($courses[0]->id);
-
-        $expiredcontext = api::create_expired_context($course0context->id);
-
-        // Default status.
-        $this->assertEquals(expired_context::STATUS_EXPIRED, $expiredcontext->get('status'));
-
-        api::set_expired_context_status($expiredcontext, expired_context::STATUS_APPROVED);
-        $this->assertEquals(expired_context::STATUS_APPROVED, $expiredcontext->get('status'));
-    }
-
     /**
      * Creates test purposes and categories.
      *
      * @return null
      */
     protected function add_purposes_and_categories() {
+        $this->resetAfterTest();
 
         $purpose1 = api::create_purpose((object)['name' => 'p1', 'retentionperiod' => 'PT1H', 'lawfulbases' => 'gdpr_art_6_1_a']);
         $purpose2 = api::create_purpose((object)['name' => 'p2', 'retentionperiod' => 'PT2H', 'lawfulbases' => 'gdpr_art_6_1_b']);
@@ -1344,6 +1354,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
      * Test that delete requests filter out protected purpose contexts.
      */
     public function test_add_request_contexts_with_status_delete() {
+        $this->resetAfterTest();
+
         $data = $this->setup_test_add_request_contexts_with_status(api::DATAREQUEST_TYPE_DELETE);
         $contextids = $data->list->get_contextids();
 
@@ -1355,6 +1367,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
      * Test that export requests don't filter out protected purpose contexts.
      */
     public function test_add_request_contexts_with_status_export() {
+        $this->resetAfterTest();
+
         $data = $this->setup_test_add_request_contexts_with_status(api::DATAREQUEST_TYPE_EXPORT);
         $contextids = $data->list->get_contextids();
 
@@ -1404,7 +1418,7 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
      * @param bool $override Whether to override instances.
      */
     public function test_set_context_defaults($contextlevel, $inheritcategory, $inheritpurpose, $foractivity, $override) {
-        $this->setAdminUser();
+        $this->resetAfterTest();
 
         $generator = $this->getDataGenerator();
 
@@ -1527,6 +1541,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
      * @return      \stdClass
      */
     protected function setup_test_add_request_contexts_with_status($type) {
+        $this->resetAfterTest();
+
         $this->setAdminUser();
 
         // User under test.
index d480e57..37cb86b 100644 (file)
@@ -25,6 +25,9 @@
 use tool_dataprivacy\api;
 use tool_dataprivacy\data_registry;
 use tool_dataprivacy\expired_context;
+use tool_dataprivacy\purpose;
+use tool_dataprivacy\category;
+use tool_dataprivacy\contextlevel;
 
 defined('MOODLE_INTERNAL') || die();
 global $CFG;
@@ -39,198 +42,1459 @@ global $CFG;
 class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
 
     /**
-     * setUp.
+     * Setup the basics with the specified retention period.
+     *
+     * @param   string  $system Retention policy for the system.
+     * @param   string  $user Retention policy for users.
+     * @param   string  $course Retention policy for courses.
+     * @param   string  $activity Retention policy for activities.
      */
-    public function setUp() {
+    protected function setup_basics(string $system, string $user, string $course, string $activity = null) : array {
         $this->resetAfterTest();
-        $this->setAdminUser();
+
+        $purposes = [];
+        $purposes[] = $this->create_and_set_purpose_for_contextlevel($system, CONTEXT_SYSTEM);
+        $purposes[] = $this->create_and_set_purpose_for_contextlevel($user, CONTEXT_USER);
+        $purposes[] = $this->create_and_set_purpose_for_contextlevel($course, CONTEXT_COURSE);
+        if (null !== $activity) {
+            $purposes[] = $this->create_and_set_purpose_for_contextlevel($activity, CONTEXT_MODULE);
+        }
+
+        return $purposes;
     }
 
     /**
-     * Test expired users flagging and deletion.
+     * Create a retention period and set it for the specified context level.
      *
-     * @return null
+     * @param   string  $retention
+     * @param   int     $contextlevel
+     * @return  purpose
      */
-    public function test_expired_users() {
-        global $DB;
+    protected function create_and_set_purpose_for_contextlevel(string $retention, int $contextlevel) : purpose {
+        $purpose = new purpose(0, (object) [
+            'name' => 'Test purpose ' . rand(1, 1000),
+            'retentionperiod' => $retention,
+            'lawfulbases' => 'gdpr_art_6_1_a',
+        ]);
+        $purpose->create();
 
-        $purpose = api::create_purpose((object)['name' => 'p1', 'retentionperiod' => 'PT1H', 'lawfulbases' => 'gdpr_art_6_1_a']);
-        $cat = api::create_category((object)['name' => 'a']);
+        $cat = new category(0, (object) ['name' => 'Test category']);
+        $cat->create();
 
-        $record = (object)[
-            'purposeid' => $purpose->get('id'),
-            'categoryid' => $cat->get('id'),
-            'contextlevel' => CONTEXT_SYSTEM,
-        ];
-        api::set_contextlevel($record);
-        $record->contextlevel = CONTEXT_USER;
-        api::set_contextlevel($record);
-
-        $userdata = ['lastaccess' => '123'];
-        $user1 = $this->getDataGenerator()->create_user($userdata);
-        $user2 = $this->getDataGenerator()->create_user($userdata);
-        $user3 = $this->getDataGenerator()->create_user($userdata);
-        $user4 = $this->getDataGenerator()->create_user($userdata);
-        $user5 = $this->getDataGenerator()->create_user();
-
-        $course1 = $this->getDataGenerator()->create_course();
-        // Old course.
-        $course2 = $this->getDataGenerator()->create_course(['startdate' => '1', 'enddate' => '2']);
-        // Ongoing course.
-        $course3 = $this->getDataGenerator()->create_course(['startdate' => '1', 'enddate' => time() + YEARSECS]);
-
-        $this->getDataGenerator()->enrol_user($user1->id, $course1->id, 'student');
-        $this->getDataGenerator()->enrol_user($user2->id, $course2->id, 'student');
-        $this->getDataGenerator()->enrol_user($user3->id, $course2->id, 'student');
-        $this->getDataGenerator()->enrol_user($user4->id, $course3->id, 'student');
-
-        // Add an activity and some data for user 2.
-        $assignmod = $this->getDataGenerator()->create_module('assign', ['course' => $course2->id]);
-        $data = (object) [
-            'assignment' => $assignmod->id,
-            'userid' => $user2->id,
-            'timecreated' => time(),
-            'timemodified' => time(),
-            'status' => 'new',
-            'groupid' => 0,
-            'attemptnumber' => 0,
-            'latest' => 1,
-        ];
-        $DB->insert_record('assign_submission', $data);
-        // We should have one record in the assign submission table.
-        $this->assertEquals(1, $DB->count_records('assign_submission'));
+        if ($contextlevel <= CONTEXT_USER) {
+            $record = (object) [
+                'purposeid'     => $purpose->get('id'),
+                'categoryid'    => $cat->get('id'),
+                'contextlevel'  => $contextlevel,
+            ];
+            api::set_contextlevel($record);
+        } else {
+            list($purposevar, ) = data_registry::var_names_from_context(
+                    \context_helper::get_class_for_level(CONTEXT_COURSE)
+                );
+            set_config($purposevar, $purpose->get('id'), 'tool_dataprivacy');
+        }
+
+        return $purpose;
+    }
+
+    /**
+     * Ensure that a user with no lastaccess is not flagged for deletion.
+     */
+    public function test_flag_not_setup() {
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $context = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+    }
+
+    /**
+     * Ensure that a user with no lastaccess is not flagged for deletion.
+     */
+    public function test_flag_user_no_lastaccess() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user();
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $context = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+    }
+
+    /**
+     * Ensure that a user with a recent lastaccess is not flagged for deletion.
+     */
+    public function test_flag_user_recent_lastaccess() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time()]);
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $context = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+    }
+
+    /**
+     * Ensure that a user with a lastaccess in the past is flagged for deletion.
+     */
+    public function test_flag_user_past_lastaccess() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $context = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        // Although there is a block in the user context, everything in the user context is regarded as one.
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(1, $flaggedusers);
+    }
+
+    /**
+     * Ensure that a user with a lastaccess in the past but active enrolments is not flagged for deletion.
+     */
+    public function test_flag_user_past_lastaccess_still_enrolled() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $course = $this->getDataGenerator()->create_course(['startdate' => time(), 'enddate' => time() + YEARSECS]);
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        $otheruser = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($otheruser->id, $course->id, 'student');
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $context = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+    }
+
+    /**
+     * Ensure that a user with a lastaccess in the past and expired enrolments.
+     */
+    public function test_flag_user_past_lastaccess_unexpired_past_enrolment() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'P1Y');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - WEEKSECS]);
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        $otheruser = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($otheruser->id, $course->id, 'student');
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $context = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+    }
+
+    /**
+     * Ensure that a user with a lastaccess in the past and expired enrolments.
+     */
+    public function test_flag_user_past_lastaccess_expired_enrolled() {
+        $this->resetAfterTest();
+
+        $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]);
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        $otheruser = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($otheruser->id, $course->id, 'student');
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $context = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(1, $flaggedcourses);
+        $this->assertEquals(1, $flaggedusers);
+    }
+
+    /**
+     * Ensure that a user with a lastaccess in the past and enrolments without a course end date are respected
+     * correctly.
+     */
+    public function test_flag_user_past_lastaccess_missing_enddate_required() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $course = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        $otheruser = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($otheruser->id, $course->id, 'student');
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $context = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Ensure that course end dates are not required.
+        set_config('requireallenddatesforuserdeletion', 1, 'tool_dataprivacy');
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+    }
+
+    /**
+     * Ensure that a user with a lastaccess in the past and enrolments without a course end date are respected
+     * correctly when the end date is not required.
+     */
+    public function test_flag_user_past_lastaccess_missing_enddate_not_required() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $course = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        $otheruser = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($otheruser->id, $course->id, 'student');
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $context = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Ensure that course end dates are required.
+        set_config('requireallenddatesforuserdeletion', 0, 'tool_dataprivacy');
+
+        // 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);
+    }
+
+    /**
+     * Ensure that a user with a recent lastaccess is not flagged for deletion.
+     */
+    public function test_flag_user_recent_lastaccess_existing_record() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time()]);
+        $usercontext = \context_user::instance($user->id);
+
+        // Create an existing expired_context.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => $usercontext->id,
+                'status' => expired_context::STATUS_EXPIRED,
+            ]);
+        $expiredcontext->save();
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $context = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+
+        $this->expectException('dml_missing_record_exception');
+        new expired_context($expiredcontext->get('id'));
+    }
+
+    /**
+     * Ensure that a user with a recent lastaccess is not flagged for deletion.
+     */
+    public function test_flag_user_retention_changed() {
+        $this->resetAfterTest();
+
+        list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]);
+        $usercontext = \context_user::instance($user->id);
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $context = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(1, $flaggedusers);
+
+        $expiredcontext = expired_context::get_record(['contextid' => $usercontext->id]);
+        $this->assertNotFalse($expiredcontext);
+
+        // Increase the retention period to 5 years.
+        $userpurpose->set('retentionperiod', 'P5Y');
+        $userpurpose->save();
+
+        // Re-run the expiry job - the previously flagged user will be removed because the retention period has been increased.
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+
+        // The expiry record will now have been removed.
+        $this->expectException('dml_missing_record_exception');
+        new expired_context($expiredcontext->get('id'));
+    }
+
+    /**
+     * Ensure that a user with a historically expired expired block record child is cleaned up.
+     */
+    public function test_flag_user_historic_block_unapproved() {
+        $this->resetAfterTest();
+
+        list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]);
+        $usercontext = \context_user::instance($user->id);
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $blockcontext = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Create an existing expired_context which has not been approved for the block.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => $blockcontext->id,
+                'status' => expired_context::STATUS_EXPIRED,
+            ]);
+        $expiredcontext->save();
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(1, $flaggedusers);
+
+        $expiredblockcontext = expired_context::get_record(['contextid' => $blockcontext->id]);
+        $this->assertFalse($expiredblockcontext);
+
+        $expiredusercontext = expired_context::get_record(['contextid' => $usercontext->id]);
+        $this->assertNotFalse($expiredusercontext);
+    }
+
+    /**
+     * Ensure that a user with a block which has a default retention period which has not expired, is still expired.
+     */
+    public function test_flag_user_historic_unexpired_child() {
+        $this->resetAfterTest();
+
+        list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+        $blockpurpose = $this->create_and_set_purpose_for_contextlevel('P5Y', CONTEXT_BLOCK);
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]);
+        $usercontext = \context_user::instance($user->id);
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $blockcontext = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(1, $flaggedusers);
+
+        $expiredcontext = expired_context::get_record(['contextid' => $usercontext->id]);
+        $this->assertNotFalse($expiredcontext);
+    }
+
+    /**
+     * Ensure that a course with no end date is not flagged.
+     */
+    public function test_flag_course_no_enddate() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
+
+        $course = $this->getDataGenerator()->create_course();
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+    }
+
+    /**
+     * Ensure that a course with an end date in the distant past, but a child which is unexpired is not flagged.
+     */
+    public function test_flag_course_past_enddate_future_child() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'P5Y');
+
+        $course = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * YEARSECS),
+                'enddate' => time() - YEARSECS,
+            ]);
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+    }
+
+    /**
+     * Ensure that a course with an end date in the distant past is flagged.
+     */
+    public function test_flag_course_past_enddate() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
+
+        $course = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * YEARSECS),
+                'enddate' => time() - YEARSECS,
+            ]);
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(2, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+    }
+
+    /**
+     * Ensure that a course with an end date in the distant past is flagged.
+     */
+    public function test_flag_course_past_enddate_multiple() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
+
+        $course1 = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * YEARSECS),
+                'enddate' => time() - YEARSECS,
+            ]);
+        $forum1 = $this->getDataGenerator()->create_module('forum', ['course' => $course1->id]);
+
+        $course2 = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * YEARSECS),
+                'enddate' => time() - YEARSECS,
+            ]);
+        $forum2 = $this->getDataGenerator()->create_module('forum', ['course' => $course2->id]);
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(4, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+    }
+
+    /**
+     * Ensure that a course with an end date in the future is not flagged.
+     */
+    public function test_flag_course_future_enddate() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
+
+        $course = $this->getDataGenerator()->create_course(['enddate' => time() + YEARSECS]);
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+    }
+
+    /**
+     * Ensure that a course with an end date in the future is not flagged.
+     */
+    public function test_flag_course_recent_unexpired_enddate() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
+
+        $course = $this->getDataGenerator()->create_course(['enddate' => time() - 1]);
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+    }
+
+    /**
+     * Ensure that a site not setup will not process anything.
+     */
+    public function test_process_not_setup() {
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $usercontext = \context_user::instance($user->id);
+
+        // Create an existing expired_context.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => $usercontext->id,
+                'status' => expired_context::STATUS_EXPIRED,
+            ]);
+        $expiredcontext->save();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_all_users_in_context',
+            ])
+            ->getMock();
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_user');
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_all_users_in_context');
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+        $manager->set_progress(new \null_progress_trace());
+
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
+
+        $this->assertEquals(0, $processedcourses);
+        $this->assertEquals(0, $processedusers);
+    }
+
+    /**
+     * Ensure that a user with no lastaccess is not flagged for deletion.
+     */
+    public function test_process_none_approved() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $usercontext = \context_user::instance($user->id);
+
+        // Create an existing expired_context.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => $usercontext->id,
+                'status' => expired_context::STATUS_EXPIRED,
+            ]);
+        $expiredcontext->save();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_all_users_in_context',
+            ])
+            ->getMock();
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_user');
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_all_users_in_context');
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+        $manager->set_progress(new \null_progress_trace());
 
-        // Users without lastaccess are skipped as well as users enroled in courses with no end date.
-        $expired = new \tool_dataprivacy\expired_user_contexts();
-        $numexpired = $expired->flag_expired();
-        $this->assertEquals(2, $numexpired);
-        $this->assertEquals(2, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_EXPIRED]));
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
 
-        // Approve user2 to be deleted.
-        $user2ctx = \context_user::instance($user2->id);
-        $expiredctx = expired_context::get_record(['contextid' => $user2ctx->id]);
-        api::set_expired_context_status($expiredctx, expired_context::STATUS_APPROVED);
-        $this->assertEquals(1, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_APPROVED]));
+        $this->assertEquals(0, $processedcourses);
+        $this->assertEquals(0, $processedusers);
+    }
+
+    /**
+     * Ensure that a user with no lastaccess is not flagged for deletion.
+     */
+    public function test_process_no_context() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
+
+        // Create an existing expired_context.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => -1,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredcontext->save();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_all_users_in_context',
+            ])
+            ->getMock();
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_user');
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_all_users_in_context');
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+        $manager->set_progress(new \null_progress_trace());
+
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
+
+        $this->assertEquals(0, $processedcourses);
+        $this->assertEquals(0, $processedusers);
+
+        $this->expectException('dml_missing_record_exception');
+        new expired_context($expiredcontext->get('id'));
+    }
+
+    /**
+     * Ensure that a user context previously flagged as approved is removed.
+     */
+    public function test_process_user_context() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $usercontext = \context_user::instance($user->id);
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $blockcontext = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Create an existing expired_context.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => $usercontext->id,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredcontext->save();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_all_users_in_context',
+            ])
+            ->getMock();
+        $mockprivacymanager->expects($this->atLeastOnce())->method('delete_data_for_user');
+        $mockprivacymanager->expects($this->exactly(2))
+            ->method('delete_data_for_all_users_in_context')
+            ->withConsecutive(
+                [$blockcontext],
+                [$usercontext]
+            );
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+        $manager->set_progress(new \null_progress_trace());
+
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
+
+        $this->assertEquals(0, $processedcourses);
+        $this->assertEquals(1, $processedusers);
+
+        $updatedcontext = new expired_context($expiredcontext->get('id'));
+        $this->assertEquals(expired_context::STATUS_CLEANED, $updatedcontext->get('status'));
+
+        // Flag all expired contexts again.
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(0, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+
+        // Ensure that the deleted context record is still present.
+        $updatedcontext = new expired_context($expiredcontext->get('id'));
+        $this->assertEquals(expired_context::STATUS_CLEANED, $updatedcontext->get('status'));
+    }
+
+    /**
+     * Ensure that a course context previously flagged as approved is removed.
+     */
+    public function test_process_course_context() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
+
+        $course = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * YEARSECS),
+                'enddate' => time() - YEARSECS,
+            ]);
+        $coursecontext = \context_course::instance($course->id);
+
+        // Create an existing expired_context.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => $coursecontext->id,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredcontext->save();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_all_users_in_context',
+            ])
+            ->getMock();
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_user');
+        $mockprivacymanager->expects($this->once())->method('delete_data_for_all_users_in_context');
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+        $manager->set_progress(new \null_progress_trace());
+
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
+
+        $this->assertEquals(1, $processedcourses);
+        $this->assertEquals(0, $processedusers);
+
+        $updatedcontext = new expired_context($expiredcontext->get('id'));
+        $this->assertEquals(expired_context::STATUS_CLEANED, $updatedcontext->get('status'));
+    }
+
+    /**
+     * Ensure that a user context previously flagged as approved is not removed if the user then logs in.
+     */
+    public function test_process_user_context_logged_in_after_approval() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $usercontext = \context_user::instance($user->id);
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $context = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Create an existing expired_context.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => $usercontext->id,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredcontext->save();
+
+        // Now bump the user's last login time.
+        $this->setUser($user);
+        user_accesstime_log();
+        $this->setUser();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_all_users_in_context',
+            ])
+            ->getMock();
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_user');
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_all_users_in_context');
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+        $manager->set_progress(new \null_progress_trace());
+
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
+
+        $this->assertEquals(0, $processedcourses);
+        $this->assertEquals(0, $processedusers);
+
+        $this->expectException('dml_missing_record_exception');
+        new expired_context($expiredcontext->get('id'));
+    }
+
+    /**
+     * Ensure that a user context previously flagged as approved is not removed if the purpose has changed.
+     */
+    public function test_process_user_context_changed_after_approved() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $usercontext = \context_user::instance($user->id);
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $context = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Create an existing expired_context.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => $usercontext->id,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredcontext->save();
+
+        // Now make the user a site admin.
+        $admins = explode(',', get_config('moodle', 'siteadmins'));
+        $admins[] = $user->id;
+        set_config('siteadmins', implode(',', $admins));
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_all_users_in_context',
+            ])
+            ->getMock();
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_user');
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_all_users_in_context');
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+        $manager->set_progress(new \null_progress_trace());
+
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
+
+        $this->assertEquals(0, $processedcourses);
+        $this->assertEquals(0, $processedusers);
+
+        $this->expectException('dml_missing_record_exception');
+        new expired_context($expiredcontext->get('id'));
+    }
+
+    /**
+     * Ensure that a user with a historically expired expired block record child is cleaned up.
+     */
+    public function test_process_user_historic_block_unapproved() {
+        $this->resetAfterTest();
+
+        list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]);
+        $usercontext = \context_user::instance($user->id);
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $blockcontext = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Create an expired_context for the user.
+        $expiredusercontext = new expired_context(0, (object) [
+                'contextid' => $usercontext->id,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredusercontext->save();
+
+        // Create an existing expired_context which has not been approved for the block.
+        $expiredblockcontext = new expired_context(0, (object) [
+                'contextid' => $blockcontext->id,
+                'status' => expired_context::STATUS_EXPIRED,
+            ]);
+        $expiredblockcontext->save();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_all_users_in_context',
+            ])
+            ->getMock();
+        $mockprivacymanager->expects($this->atLeastOnce())->method('delete_data_for_user');
+        $mockprivacymanager->expects($this->exactly(2))
+            ->method('delete_data_for_all_users_in_context')
+            ->withConsecutive(
+                [$blockcontext],
+                [$usercontext]
+            );
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+        $manager->set_progress(new \null_progress_trace());
+
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
+
+        $this->assertEquals(0, $processedcourses);
+        $this->assertEquals(1, $processedusers);
+
+        $updatedcontext = new expired_context($expiredusercontext->get('id'));
+        $this->assertEquals(expired_context::STATUS_CLEANED, $updatedcontext->get('status'));
+    }
+
+    /**
+     * Ensure that a user with a block which has a default retention period which has not expired, is still expired.
+     */
+    public function test_process_user_historic_unexpired_child() {
+        $this->resetAfterTest();
+
+        list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
+        $blockpurpose = $this->create_and_set_purpose_for_contextlevel('P5Y', CONTEXT_BLOCK);
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]);
+        $usercontext = \context_user::instance($user->id);
+
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $blockcontext = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Create an expired_context for the user.
+        $expiredusercontext = new expired_context(0, (object) [
+                'contextid' => $usercontext->id,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredusercontext->save();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_all_users_in_context',
+            ])
+            ->getMock();
+        $mockprivacymanager->expects($this->atLeastOnce())->method('delete_data_for_user');
+        $mockprivacymanager->expects($this->exactly(2))
+            ->method('delete_data_for_all_users_in_context')
+            ->withConsecutive(
+                [$blockcontext],
+                [$usercontext]
+            );
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+        $manager->set_progress(new \null_progress_trace());
+
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
+
+        $this->assertEquals(0, $processedcourses);
+        $this->assertEquals(1, $processedusers);
+
+        $updatedcontext = new expired_context($expiredusercontext->get('id'));
+        $this->assertEquals(expired_context::STATUS_CLEANED, $updatedcontext->get('status'));
+    }
+
+    /**
+     * Ensure that a course context previously flagged as approved for deletion which now has an unflagged child, is
+     * updated.
+     */
+    public function test_process_course_context_updated() {
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
+
+        $course = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * YEARSECS),
+                'enddate' => time() - YEARSECS,
+            ]);
+        $coursecontext = \context_course::instance($course->id);
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+
+        // Create an existing expired_context.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => $coursecontext->id,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredcontext->save();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_all_users_in_context',
+            ])
+            ->getMock();
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_user');
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_all_users_in_context');
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+        $manager->set_progress(new \null_progress_trace());
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+
+        $coursepurpose = $purposes[2];
+        $coursepurpose->set('retentionperiod', 'P5Y');
+        $coursepurpose->save();
+
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
+
+        $this->assertEquals(0, $processedcourses);
+        $this->assertEquals(0, $processedusers);
 
-        // Delete expired contexts.
-        $deleted = $expired->delete();
-        $this->assertEquals(1, $deleted);
-        $this->assertEquals(1, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_EXPIRED]));
-        $this->assertEquals(1, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_CLEANED]));
+        $updatedcontext = new expired_context($expiredcontext->get('id'));
 
-        // No new records are generated.
-        $numexpired = $expired->flag_expired();
-        $this->assertEquals(0, $numexpired);
-        $this->assertEquals(2, $DB->count_records('tool_dataprivacy_ctxexpired'));
-        $deleted = $expired->delete();
-        $this->assertEquals(0, $deleted);
+        // No change - we just can't process it until the children have finished.
+        $this->assertEquals(expired_context::STATUS_APPROVED, $updatedcontext->get('status'));
+    }
+
+    /**
+     * Ensure that a course context previously flagged as approved for deletion which now has an unflagged child, is
+     * updated.
+     */
+    public function test_process_course_context_outstanding_children() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
+
+        $course = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * YEARSECS),
+                'enddate' => time() - YEARSECS,
+            ]);
+        $coursecontext = \context_course::instance($course->id);
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+
+        // Create an existing expired_context.
+        $expiredcontext = new expired_context(0, (object) [
+                'contextid' => $coursecontext->id,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredcontext->save();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_all_users_in_context',
+            ])
+            ->getMock();
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_user');
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_all_users_in_context');
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+        $manager->set_progress(new \null_progress_trace());
+
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
+
+        $this->assertEquals(0, $processedcourses);
+        $this->assertEquals(0, $processedusers);
+
+        $updatedcontext = new expired_context($expiredcontext->get('id'));
+
+        // No change - we just can't process it until the children have finished.
+        $this->assertEquals(expired_context::STATUS_APPROVED, $updatedcontext->get('status'));
+    }
 
-        // No user data left in mod_assign.
-        $this->assertEquals(0, $DB->count_records('assign_submission'));
+    /**
+     * Ensure that a course context previously flagged as approved for deletion which now has an unflagged child, is
+     * updated.
+     */
+    public function test_process_course_context_pending_children() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
+
+        $course = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * YEARSECS),
+                'enddate' => time() - YEARSECS,
+            ]);
+        $coursecontext = \context_course::instance($course->id);
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+        $forumcontext = \context_module::instance($cm->id);
+
+        // Create an existing expired_context for the course.
+        $expiredcoursecontext = new expired_context(0, (object) [
+                'contextid' => $coursecontext->id,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredcoursecontext->save();
+
+        // And for the forum.
+        $expiredforumcontext = new expired_context(0, (object) [
+                'contextid' => $forumcontext->id,
+                'status' => expired_context::STATUS_EXPIRED,
+            ]);
+        $expiredforumcontext->save();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_all_users_in_context',
+            ])
+            ->getMock();
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_user');
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_all_users_in_context');
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+        $manager->set_progress(new \null_progress_trace());
+
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
+
+        $this->assertEquals(0, $processedcourses);
+        $this->assertEquals(0, $processedusers);
+
+        $updatedcontext = new expired_context($expiredcoursecontext->get('id'));
+
+        // No change - we just can't process it until the children have finished.
+        $this->assertEquals(expired_context::STATUS_APPROVED, $updatedcontext->get('status'));
+    }
 
-        // The user is deleted.
-        $deleteduser = \core_user::get_user($user2->id, 'id, deleted', IGNORE_MISSING);
-        $this->assertEquals(1, $deleteduser->deleted);
+    /**
+     * Ensure that a course context previously flagged as approved for deletion which now has an unflagged child, is
+     * updated.
+     */
+    public function test_process_course_context_approved_children() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
+
+        $course = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * YEARSECS),
+                'enddate' => time() - YEARSECS,
+            ]);
+        $coursecontext = \context_course::instance($course->id);
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+        $forumcontext = \context_module::instance($cm->id);
+
+        // Create an existing expired_context for the course.
+        $expiredcoursecontext = new expired_context(0, (object) [
+                'contextid' => $coursecontext->id,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredcoursecontext->save();
+
+        // And for the forum.
+        $expiredforumcontext = new expired_context(0, (object) [
+                'contextid' => $forumcontext->id,
+                'status' => expired_context::STATUS_APPROVED,
+            ]);
+        $expiredforumcontext->save();
+
+        $mockprivacymanager = $this->getMockBuilder(\core_privacy\manager::class)
+            ->setMethods([
+                'delete_data_for_user',
+                'delete_data_for_all_users_in_context',
+            ])
+            ->getMock();
+        $mockprivacymanager->expects($this->never())->method('delete_data_for_user');
+        $mockprivacymanager->expects($this->exactly(2))
+            ->method('delete_data_for_all_users_in_context')
+            ->withConsecutive(
+                [$forumcontext],
+                [$coursecontext]
+            );
+
+        $manager = $this->getMockBuilder(\tool_dataprivacy\expired_contexts_manager::class)
+            ->setMethods(['get_privacy_manager'])
+            ->getMock();
+        $manager->set_progress(new \null_progress_trace());
+
+        $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
+
+        // Initially only the forum will be processed.
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
+
+        $this->assertEquals(1, $processedcourses);
+        $this->assertEquals(0, $processedusers);
+
+        $updatedcontext = new expired_context($expiredforumcontext->get('id'));
+        $this->assertEquals(expired_context::STATUS_CLEANED, $updatedcontext->get('status'));
+
+        // The course won't have been processed yet.
+        $updatedcontext = new expired_context($expiredcoursecontext->get('id'));
+        $this->assertEquals(expired_context::STATUS_APPROVED, $updatedcontext->get('status'));
+
+        // A subsequent run will cause the course to processed as it is no longer dependent upon the child contexts.
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
+
+        $this->assertEquals(1, $processedcourses);
+        $this->assertEquals(0, $processedusers);
+        $updatedcontext = new expired_context($expiredcoursecontext->get('id'));
+        $this->assertEquals(expired_context::STATUS_CLEANED, $updatedcontext->get('status'));
+    }
+
+    /**
+     * Test that the can_process_deletion function returns expected results.
+     *
+     * @dataProvider    can_process_deletion_provider
+     * @param       int     $status
+     * @param       bool    $expected
+     */
+    public function test_can_process_deletion($status, $expected) {
+        $purpose = new expired_context(0, (object) [
+            'status' => $status,
+
+            'contextid' => \context_system::instance()->id,
+        ]);
+
+        $this->assertEquals($expected, $purpose->can_process_deletion());
+    }
+
+    /**
+     * Data provider for the can_process_deletion tests.
+     *
+     * @return  array
+     */
+    public function can_process_deletion_provider() : array {
+        return [
+            'Pending' => [
+                expired_context::STATUS_EXPIRED,
+                false,
+            ],
+            'Approved' => [
+                expired_context::STATUS_APPROVED,
+                true,
+            ],
+            'Complete' => [
+                expired_context::STATUS_CLEANED,
+                false,
+            ],
+        ];
     }
 
     /**
-     * Test expired course and course stuff flagging and deletion.
+     * Test that the is_complete function returns expected results.
      *
-     * @return null
+     * @dataProvider        is_complete_provider
+     * @param       int     $status
+     * @param       bool    $expected
      */
-    public function test_expired_course_related_contexts() {
-        global $DB;
+    public function test_is_complete($status, $expected) {
+        $purpose = new expired_context(0, (object) [
+            'status' => $status,
+            'contextid' => \context_system::instance()->id,
+        ]);
 
-        $purpose1 = api::create_purpose((object)['name' => 'p1', 'retentionperiod' => 'PT1H', 'lawfulbases' => 'gdpr_art_6_1_a']);
-        $purpose2 = api::create_purpose((object)['name' => 'p1', 'retentionperiod' => 'P1Y', 'lawfulbases' => 'gdpr_art_6_1_b']);
-        $cat = api::create_category((object)['name' => 'a']);
+        $this->assertEquals($expected, $purpose->is_complete());
+    }
 
-        $record = (object)[
-            'purposeid' => $purpose1->get('id'),
-            'categoryid' => $cat->get('id'),
-            'contextlevel' => CONTEXT_SYSTEM,
+    /**
+     * Data provider for the is_complete tests.
+     *
+     * @return  array
+     */
+    public function is_complete_provider() : array {
+        return [
+            'Pending' => [
+                expired_context::STATUS_EXPIRED,
+                false,
+            ],
+            'Approved' => [
+                expired_context::STATUS_APPROVED,
+                false,
+            ],
+            'Complete' => [
+                expired_context::STATUS_CLEANED,
+                true,
+            ],
         ];
-        api::set_contextlevel($record);
-
-        list($purposevar, $categoryvar) = data_registry::var_names_from_context(
-            \context_helper::get_class_for_level(CONTEXT_COURSE)
-        );
-        set_config($purposevar, $purpose1->get('id'), 'tool_dataprivacy');
-
-        // A lot more time for modules.
-        list($purposevar, $categoryvar) = data_registry::var_names_from_context(
-            \context_helper::get_class_for_level(CONTEXT_MODULE)
-        );
-        set_config($purposevar, $purpose2->get('id'), 'tool_dataprivacy');
-
-        $course1 = $this->getDataGenerator()->create_course();
-
-        // Course finished last week (so purpose1 retention period does delete stuff but purpose2 retention period does not).
-        $dt = new \DateTime();
-        $di = new \DateInterval('P7D');
-        $dt->sub($di);
-
-        $course2 = $this->getDataGenerator()->create_course(['startdate' => '1', 'enddate' => $dt->getTimestamp()]);
-        $forum1 = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id));
-        $forum2 = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id));
-
-        // We want to override this last module instance purpose so we can test that modules are also
-        // returned as expired.
-        $forum2ctx = \context_module::instance($forum2->cmid);
-        $record = (object)[
-            'purposeid' => $purpose1->get('id'),
-            'categoryid' => $cat->get('id'),
-            'contextid' => $forum2ctx->id,
+    }
+
+    /**
+     * Ensure that any orphaned records are removed once the context has been removed.
+     */
+    public function test_orphaned_records_are_cleared() {
+        $this->resetAfterTest();
+
+        $this->setup_basics('PT1H', 'PT1H', 'PT1H', 'PT1H');
+
+        $course = $this->getDataGenerator()->create_course([
+                'startdate' => time() - (2 * YEARSECS),
+                'enddate' => time() - YEARSECS,
+            ]);
+        $context = \context_course::instance($course->id);
+
+        // Flag all expired contexts.
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        $manager->set_progress(new \null_progress_trace());
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+
+        $this->assertEquals(1, $flaggedcourses);
+        $this->assertEquals(0, $flaggedusers);
+
+        // Ensure that the record currently exists.
+        $expiredcontext = expired_context::get_record(['contextid' => $context->id]);
+        $this->assertNotFalse($expiredcontext);
+
+        // Approve it.
+        $expiredcontext->set('status', expired_context::STATUS_APPROVED)->save();
+
+        // Process deletions.
+        list($processedcourses, $processedusers) = $manager->process_approved_deletions();
+
+        $this->assertEquals(1, $processedcourses);
+        $this->assertEquals(0, $processedusers);
+
+        // Ensure that the record still exists.
+        $expiredcontext = expired_context::get_record(['contextid' => $context->id]);
+        $this->assertNotFalse($expiredcontext);
+
+        // Remove the actual course.
+        delete_course($course->id, false);
+
+        // The record will still exist until we flag it again.
+        $expiredcontext = expired_context::get_record(['contextid' => $context->id]);
+        $this->assertNotFalse($expiredcontext);
+
+        list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts();
+        $expiredcontext = expired_context::get_record(['contextid' => $context->id]);
+        $this->assertFalse($expiredcontext);
+    }
+
+    /**
+     * Ensure that the progres tracer works as expected out of the box.
+     */
+    public function test_progress_tracer_default() {
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+
+        $rc = new \ReflectionClass(\tool_dataprivacy\expired_contexts_manager::class);
+        $rcm = $rc->getMethod('get_progress');
+
+        $rcm->setAccessible(true);
+        $this->assertInstanceOf(\text_progress_trace::class, $rcm->invoke($manager));
+    }
+
+    /**
+     * Ensure that the progres tracer works as expected when given a specific traer.
+     */
+    public function test_progress_tracer_set() {
+        $manager = new \tool_dataprivacy\expired_contexts_manager();
+        $mytrace = new \null_progress_trace();
+        $manager->set_progress($mytrace);
+
+        $rc = new \ReflectionClass(\tool_dataprivacy\expired_contexts_manager::class);
+        $rcm = $rc->getMethod('get_progress');
+
+        $rcm->setAccessible(true);
+        $this->assertSame($mytrace, $rcm->invoke($manager));
+    }
+
+    /**
+     * Creates an HTML block on a user.
+     *
+     * @param   string  $title
+     * @param   string  $body
+     * @param   string  $format
+     * @return  \block_instance
+     */
+    protected function create_user_block($title, $body, $format) {
+        global $USER;
+
+        $configdata = (object) [
+            'title' => $title,
+            'text' => [
+                'itemid' => 19,
+                'text' => $body,
+                'format' => $format,
+            ],
         ];
-        api::set_context_instance($record);
-
-        // Ongoing course.
-        $course3 = $this->getDataGenerator()->create_course(['startdate' => '1', 'enddate' => time()]);
-        $forum3 = $this->getDataGenerator()->create_module('forum', array('course' => $course3->id));
-
-        $expired = new \tool_dataprivacy\expired_course_related_contexts();
-        $numexpired = $expired->flag_expired();
-
-        // Only 1 module has expired.
-        $this->assertEquals(1, $numexpired);
-        $this->assertEquals(1, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_EXPIRED]));
-
-        // Add a forum1 override to 1h retention period so both forum1 and course2 are also expired.
-        $forum1ctx = \context_module::instance($forum1->cmid);
-        $record->purposeid = $purpose1->get('id');
-        $record->contextid = $forum1ctx->id;
-        api::set_context_instance($record);
-        $numexpired = $expired->flag_expired();
-        $this->assertEquals(2, $numexpired);
-        $this->assertEquals(3, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_EXPIRED]));
-
-        // Approve forum1 to be deleted.
-        $expiredctx = expired_context::get_record(['contextid' => $forum1ctx->id]);
-        api::set_expired_context_status($expiredctx, expired_context::STATUS_APPROVED);
-
-        // Delete expired contexts.
-        $deleted = $expired->delete();
-        $this->assertEquals(1, $deleted);
-        $this->assertEquals(1, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_CLEANED]));
-
-        $expiredctx = expired_context::get_record(['contextid' => $forum2ctx->id]);
-        api::set_expired_context_status($expiredctx, expired_context::STATUS_APPROVED);
-
-        $course2ctx = \context_course::instance($course2->id);
-        $expiredctx = expired_context::get_record(['contextid' => $course2ctx->id]);
-        api::set_expired_context_status($expiredctx, expired_context::STATUS_APPROVED);
-
-        // Delete expired contexts.
-        $deleted = $expired->delete();
-        $this->assertEquals(2, $deleted);
-        $this->assertEquals(3, $DB->count_records('tool_dataprivacy_ctxexpired', ['status' => expired_context::STATUS_CLEANED]));
-
-        // No new records are generated.
-        $numexpired = $expired->flag_expired();
-        $this->assertEquals(0, $numexpired);
-        $this->assertEquals(3, $DB->count_records('tool_dataprivacy_ctxexpired'));
-        $deleted = $expired->delete();
-        $this->assertEquals(0, $deleted);
 
+        $this->create_block($this->construct_user_page($USER));
+        $block = $this->get_last_block_on_page($this->construct_user_page($USER));
+        $block = block_instance('html', $block->instance);
+        $block->instance_config_save((object) $configdata);
+
+        return $block;
+    }
+
+    /**
+     * Creates an HTML block on a page.
+     *
+     * @param \page $page Page
+     */
+    protected function create_block($page) {
+        $page->blocks->add_block_at_end_of_default_region('html');
+    }
+
+    /**
+     * Constructs a Page object for the User Dashboard.
+     *
+     * @param   \stdClass       $user User to create Dashboard for.
+     * @return  \moodle_page
+     */
+    protected function construct_user_page(\stdClass $user) {
+        $page = new \moodle_page();
+        $page->set_context(\context_user::instance($user->id));
+        $page->set_pagelayout('mydashboard');
+        $page->set_pagetype('my-index');
+        $page->blocks->load_blocks();
+        return $page;
+    }
+
+    /**
+     * Get the last block on the page.
+     *
+     * @param \page $page Page
+     * @return \block_html Block instance object
+     */
+    protected function get_last_block_on_page($page) {
+        $blocks = $page->blocks->get_blocks_for_region($page->blocks->get_default_region());
+        $block = end($blocks);
+
+        return $block;
     }
 }
index b768786..0db55d2 100644 (file)
@@ -40,71 +40,85 @@ use tool_dataprivacy\external;
  */
 class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
 
-    /** @var stdClass The user making the request. */
-    protected $requester;
-
-    /** @var int The data request ID. */
-    protected $requestid;
-
     /**
-     * Setup function- we will create a course and add an assign instance to it.
+     * Test for external::approve_data_request() with the user not logged in.
      */
-    protected function setUp() {
+    public function test_approve_data_request_not_logged_in() {
         $this->resetAfterTest();
 
         $generator = new testing_data_generator();
         $requester = $generator->create_user();
-
         $comment = 'sample comment';
 
-        // Login as user.
-        $this->setUser($requester->id);
-
         // Test data request creation.
+        $this->setUser($requester);
         $datarequest = api::create_data_request($requester->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
-        $this->requestid = $datarequest->get('id');
-        $this->requester = $requester;
 
         // Log out the user and set force login to true.
         $this->setUser();
-    }
 
-    /**
-     * Test for external::approve_data_request() with the user not logged in.
-     */
-    public function test_approve_data_request_not_logged_in() {
         $this->expectException(require_login_exception::class);
-        external::approve_data_request($this->requestid);
+        external::approve_data_request($datarequest->get('id'));
     }
 
     /**
      * Test for external::approve_data_request() with the user not having a DPO role.
      */
     public function test_approve_data_request_not_dpo() {
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
+        $requester = $generator->create_user();
+        $comment = 'sample comment';
+
+        // Test data request creation.
+        $this->setUser($requester);
+        $datarequest = api::create_data_request($requester->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+
         // Login as the requester.
-        $this->setUser($this->requester->id);
+        $this->setUser($requester);
         $this->expectException(required_capability_exception::class);
-        external::approve_data_request($this->requestid);
+        external::approve_data_request($datarequest->get('id'));
     }
 
     /**
      * Test for external::approve_data_request() for request that's not ready for approval
      */
     public function test_approve_data_request_not_waiting_for_approval() {
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
+        $requester = $generator->create_user();
+        $comment = 'sample comment';
+
+        // Test data request creation.
+        $this->setUser($requester);
+        $datarequest = api::create_data_request($requester->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+
         // Admin as DPO. (The default when no one's assigned as a DPO in the site).
         $this->setAdminUser();
         $this->expectException(moodle_exception::class);
-        external::approve_data_request($this->requestid);
+        external::approve_data_request($datarequest->get('id'));
     }
 
     /**
      * Test for external::approve_data_request()
      */
     public function test_approve_data_request() {
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
+        $requester = $generator->create_user();
+        $comment = 'sample comment';
+
+        // Test data request creation.
+        $this->setUser($requester);
+        $datarequest = api::create_data_request($requester->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+
         // Admin as DPO. (The default when no one's assigned as a DPO in the site).
         $this->setAdminUser();
-        api::update_request_status($this->requestid, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
-        $result = external::approve_data_request($this->requestid);
+        api::update_request_status($datarequest->get('id'), api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+        $result = external::approve_data_request($datarequest->get('id'));
         $return = (object) external_api::clean_returnvalue(external::approve_data_request_returns(), $result);
         $this->assertTrue($return->result);
         $this->assertEmpty($return->warnings);
@@ -114,10 +128,13 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * Test for external::approve_data_request() for a non-existent request ID.
      */
     public function test_approve_data_request_non_existent() {
+        $this->resetAfterTest();
+
         // Admin as DPO. (The default when no one's assigned as a DPO in the site).
         $this->setAdminUser();
-        api::update_request_status($this->requestid, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
-        $result = external::approve_data_request($this->requestid + 1);
+
+        $result = external::approve_data_request(1);
+
         $return = (object) external_api::clean_returnvalue(external::approve_data_request_returns(), $result);
         $this->assertFalse($return->result);
         $this->assertCount(1, $return->warnings);
@@ -129,13 +146,21 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * Test for external::cancel_data_request() of another user.
      */
     public function test_cancel_data_request_other_user() {
-        $generator = $this->getDataGenerator();
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
+        $requester = $generator->create_user();
         $otheruser = $generator->create_user();
+        $comment = 'sample comment';
 
-        // Login as another user.
+        // Test data request creation.
+        $this->setUser($requester);
+        $datarequest = api::create_data_request($requester->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+
+        // Login as other user.
         $this->setUser($otheruser);
 
-        $result = external::cancel_data_request($this->requestid);
+        $result = external::cancel_data_request($datarequest->get('id'));
         $return = (object) external_api::clean_returnvalue(external::approve_data_request_returns(), $result);
         $this->assertFalse($return->result);
         $this->assertCount(1, $return->warnings);
@@ -143,14 +168,107 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
         $this->assertEquals('errorrequestnotfound', $warning['warningcode']);
     }
 
+    /**
+     * Test cancellation of a request where you are the requester of another user's data.
+     */
+    public function test_cancel_data_request_other_user_as_requester() {
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
+        $requester = $generator->create_user();
+        $otheruser = $generator->create_user();
+        $comment = 'sample comment';
+
+        // Assign requester as otheruser'sparent.
+        $systemcontext = \context_system::instance();
+        $parentrole = $generator->create_role();
+        assign_capability('tool/dataprivacy:makedatarequestsforchildren', CAP_ALLOW, $parentrole, $systemcontext);
+        role_assign($parentrole, $requester->id, \context_user::instance($otheruser->id));
+
+        // Test data request creation.
+        $this->setUser($requester);
+        $datarequest = api::create_data_request($otheruser->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+
+        $result = external::cancel_data_request($datarequest->get('id'));
+        $return = (object) external_api::clean_returnvalue(external::approve_data_request_returns(), $result);
+        $this->assertTrue($return->result);
+        $this->assertEmpty($return->warnings);
+    }
+
+    /**
+     * Test cancellation of a request where you are the requester of another user's data.
+     */
+    public function test_cancel_data_request_requester_lost_permissions() {
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
+        $requester = $generator->create_user();
+        $otheruser = $generator->create_user();
+        $comment = 'sample comment';
+
+        // Assign requester as otheruser'sparent.
+        $systemcontext = \context_system::instance();
+        $parentrole = $generator->create_role();
+        assign_capability('tool/dataprivacy:makedatarequestsforchildren', CAP_ALLOW, $parentrole, $systemcontext);
+        role_assign($parentrole, $requester->id, \context_user::instance($otheruser->id));
+
+        $this->setUser($requester);
+        $datarequest = api::create_data_request($otheruser->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+
+        // Unassign the role.
+        role_unassign($parentrole, $requester->id, \context_user::instance($otheruser->id)->id);
+
+        // This user can no longer make the request.
+        $this->expectException(required_capability_exception::class);
+
+        $result = external::cancel_data_request($datarequest->get('id'));
+    }
+
+    /**
+     * Test cancellation of a request where you are the requester of another user's data.
+     */
+    public function test_cancel_data_request_other_user_as_child() {
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
+        $requester = $generator->create_user();
+        $otheruser = $generator->create_user();
+        $comment = 'sample comment';
+
+        // Assign requester as otheruser'sparent.
+        $systemcontext = \context_system::instance();
+        $parentrole = $generator->create_role();
+        assign_capability('tool/dataprivacy:makedatarequestsforchildren', CAP_ALLOW, $parentrole, $systemcontext);
+        role_assign($parentrole, $requester->id, \context_user::instance($otheruser->id));
+
+        // Test data request creation.
+        $this->setUser($otheruser);
+        $datarequest = api::create_data_request($otheruser->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+
+        $result = external::cancel_data_request($datarequest->get('id'));
+        $return = (object) external_api::clean_returnvalue(external::approve_data_request_returns(), $result);
+        $this->assertTrue($return->result);
+        $this->assertEmpty($return->warnings);
+    }
+
     /**
      * Test for external::cancel_data_request()
      */
     public function test_cancel_data_request() {
-        // Login as the requester.
-        $this->setUser($this->requester);
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
+        $requester = $generator->create_user();
+        $comment = 'sample comment';
+
+        // Test data request creation.
+        $this->setUser($requester);
+        $datarequest = api::create_data_request($requester->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+
+        // Test cancellation.
+        $this->setUser($requester);
+        $result = external::cancel_data_request($datarequest->get('id'));
 
-        $result = external::cancel_data_request($this->requestid);
         $return = (object) external_api::clean_returnvalue(external::approve_data_request_returns(), $result);
         $this->assertTrue($return->result);
         $this->assertEmpty($return->warnings);
@@ -160,10 +278,12 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * Test contact DPO.
      */
     public function test_contact_dpo() {
-        $generator = $this->getDataGenerator();
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
         $user = $generator->create_user();
-        $this->setUser($user);
 
+        $this->setUser($user);
         $message = 'Hello world!';
         $result = external::contact_dpo($message);
         $return = (object) external_api::clean_returnvalue(external::contact_dpo_returns(), $result);
@@ -175,10 +295,12 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * Test contact DPO with message containing invalid input.
      */
     public function test_contact_dpo_with_nasty_input() {
-        $generator = $this->getDataGenerator();
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
         $user = $generator->create_user();
-        $this->setUser($user);
 
+        $this->setUser($user);
         $this->expectException('invalid_parameter_exception');
         external::contact_dpo('de<>\\..scription');
     }
@@ -187,38 +309,77 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * Test for external::deny_data_request() with the user not logged in.
      */
     public function test_deny_data_request_not_logged_in() {
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
+        $requester = $generator->create_user();
+        $comment = 'sample comment';
+
+        // Test data request creation.
+        $this->setUser($requester);
+        $datarequest = api::create_data_request($requester->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+
+        // Log out.
+        $this->setUser();
         $this->expectException(require_login_exception::class);
-        external::deny_data_request($this->requestid);
+        external::deny_data_request($datarequest->get('id'));
     }
 
     /**
      * Test for external::deny_data_request() with the user not having a DPO role.
      */
     public function test_deny_data_request_not_dpo() {
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
+        $requester = $generator->create_user();
+        $comment = 'sample comment';
+
+        $this->setUser($requester);
+        $datarequest = api::create_data_request($requester->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+
         // Login as the requester.
-        $this->setUser($this->requester->id);
+        $this->setUser($requester);
         $this->expectException(required_capability_exception::class);
-        external::deny_data_request($this->requestid);
+        external::deny_data_request($datarequest->get('id'));
     }
 
     /**
      * Test for external::deny_data_request() for request that's not ready for approval
      */
     public function test_deny_data_request_not_waiting_for_approval() {
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
+        $requester = $generator->create_user();
+        $comment = 'sample comment';
+
+        $this->setUser($requester);
+        $datarequest = api::create_data_request($requester->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+
         // Admin as DPO. (The default when no one's assigned as a DPO in the site).
         $this->setAdminUser();
         $this->expectException(moodle_exception::class);
-        external::deny_data_request($this->requestid);
+        external::deny_data_request($datarequest->get('id'));
     }
 
     /**
      * Test for external::deny_data_request()
      */
     public function test_deny_data_request() {
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
+        $requester = $generator->create_user();
+        $comment = 'sample comment';
+
+        $this->setUser($requester);
+        $datarequest = api::create_data_request($requester->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+
         // Admin as DPO. (The default when no one's assigned as a DPO in the site).
         $this->setAdminUser();
-        api::update_request_status($this->requestid, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
-        $result = external::approve_data_request($this->requestid);
+        api::update_request_status($datarequest->get('id'), api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+        $result = external::approve_data_request($datarequest->get('id'));
         $return = (object) external_api::clean_returnvalue(external::deny_data_request_returns(), $result);
         $this->assertTrue($return->result);
         $this->assertEmpty($return->warnings);
@@ -228,10 +389,12 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * Test for external::deny_data_request() for a non-existent request ID.
      */
     public function test_deny_data_request_non_existent() {
+        $this->resetAfterTest();
+
         // Admin as DPO. (The default when no one's assigned as a DPO in the site).
         $this->setAdminUser();
-        api::update_request_status($this->requestid, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
-        $result = external::deny_data_request($this->requestid + 1);
+        $result = external::deny_data_request(1);
+
         $return = (object) external_api::clean_returnvalue(external::deny_data_request_returns(), $result);
         $this->assertFalse($return->result);
         $this->assertCount(1, $return->warnings);
@@ -243,34 +406,62 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * Test for external::get_data_request() with the user not logged in.
      */
     public function test_get_data_request_not_logged_in() {
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
+        $requester = $generator->create_user();
+        $comment = 'sample comment';
+
+        $this->setUser($requester);
+        $datarequest = api::create_data_request($requester->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+
+        $this->setUser();
         $this->expectException(require_login_exception::class);
-        external::get_data_request($this->requestid);
+        external::get_data_request($datarequest->get('id'));
     }
 
     /**
      * Test for external::get_data_request() with the user not having a DPO role.
      */
     public function test_get_data_request_not_dpo() {
-        $generator = $this->getDataGenerator();
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
+        $requester = $generator->create_user();
         $otheruser = $generator->create_user();
-        // Login as the requester.
+        $comment = 'sample comment';
+
+        $this->setUser($requester);
+        $datarequest = api::create_data_request($requester->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+
+        // Login as the otheruser.
         $this->setUser($otheruser);
         $this->expectException(required_capability_exception::class);
-        external::get_data_request($this->requestid);
+        external::get_data_request($datarequest->get('id'));
     }
 
     /**
      * Test for external::get_data_request()
      */
     public function test_get_data_request() {
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
+        $requester = $generator->create_user();
+        $comment = 'sample comment';
+
+        $this->setUser($requester);
+        $datarequest = api::create_data_request($requester->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+
         // Admin as DPO. (The default when no one's assigned as a DPO in the site).
         $this->setAdminUser();
-        $result = external::get_data_request($this->requestid);
+        $result = external::get_data_request($datarequest->get('id'));
+
         $return = (object) external_api::clean_returnvalue(external::get_data_request_returns(), $result);
         $this->assertEquals(api::DATAREQUEST_TYPE_EXPORT, $return->result['type']);
         $this->assertEquals('sample comment', $return->result['comments']);
-        $this->assertEquals($this->requester->id, $return->result['userid']);
-        $this->assertEquals($this->requester->id, $return->result['requestedby']);
+        $this->assertEquals($requester->id, $return->result['userid']);
+        $this->assertEquals($requester->id, $return->result['requestedby']);
         $this->assertEmpty($return->warnings);
     }
 
@@ -278,10 +469,12 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * Test for external::get_data_request() for a non-existent request ID.
      */
     public function test_get_data_request_non_existent() {
+        $this->resetAfterTest();
+
         // Admin as DPO. (The default when no one's assigned as a DPO in the site).
         $this->setAdminUser();
         $this->expectException(dml_missing_record_exception::class);
-        external::get_data_request($this->requestid + 1);
+        external::get_data_request(1);
     }
 
     /**
@@ -289,6 +482,8 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * when called by a user that doesn't have the manage registry capability.
      */
     public function test_set_context_defaults_no_capability() {
+        $this->resetAfterTest();
+
         $generator = $this->getDataGenerator();
         $user = $generator->create_user();
         $this->setUser($user);
@@ -307,6 +502,8 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * @param bool $override Whether to override instances.
      */
     public function test_set_context_defaults($modulelevel, $override) {
+        $this->resetAfterTest();
+
         $this->setAdminUser();
         $generator = $this->getDataGenerator();
 
@@ -364,9 +561,11 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * when called by a user that doesn't have the manage registry capability.
      */
     public function test_get_category_options_no_capability() {
-        $generator = $this->getDataGenerator();
-        $user = $generator->create_user();
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
         $this->setUser($user);
+
         $this->expectException(required_capability_exception::class);
         external::get_category_options(true, true);
     }
@@ -391,6 +590,7 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * @param bool $includenotset Whether "Not set" would be included to the options.
      */
     public function test_get_category_options($includeinherit, $includenotset) {
+        $this->resetAfterTest();
         $this->setAdminUser();
 
         // Prepare our expected options.
@@ -436,6 +636,7 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * when called by a user that doesn't have the manage registry capability.
      */
     public function test_get_purpose_options_no_capability() {
+        $this->resetAfterTest();
         $generator = $this->getDataGenerator();
         $user = $generator->create_user();
         $this->setUser($user);
@@ -451,6 +652,7 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * @param bool $includenotset Whether "Not set" would be included to the options.
      */
     public function test_get_purpose_options($includeinherit, $includenotset) {
+        $this->resetAfterTest();
         $this->setAdminUser();
 
         // Prepare our expected options.
@@ -518,6 +720,7 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * @param bool $nodefaults Whether to fetch only activities that don't have defaults.
      */
     public function test_get_activity_options($inheritcategory, $inheritpurpose, $nodefaults) {
+        $this->resetAfterTest();
         $this->setAdminUser();
 
         $category = api::create_category((object)['name' => 'Test category']);
@@ -565,21 +768,25 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * Test for external::bulk_approve_data_requests().
      */
     public function test_bulk_approve_data_requests() {
-        $generator = new testing_data_generator();
-        $requester1 = $generator->create_user();
-        $comment1 = 'sample comment';
-        // Login as requester2.
-        $this->setUser($requester1->id);
-        // Create delete data request.
-        $datarequest1 = api::create_data_request($requester1->id, api::DATAREQUEST_TYPE_DELETE, $comment1);
+        $this->resetAfterTest();
 
+        // Create delete data requests.
+        $requester1 = $this->getDataGenerator()->create_user();
+        $this->setUser($requester1->id);
+        $datarequest1 = api::create_data_request($requester1->id, api::DATAREQUEST_TYPE_DELETE, 'Example comment');
         $requestid1 = $datarequest1->get('id');
-        $requestid2 = $this->requestid;
 
+        $requester2 = $this->getDataGenerator()->create_user();
+        $this->setUser($requester2->id);
+        $datarequest2 = api::create_data_request($requester2->id, api::DATAREQUEST_TYPE_DELETE, 'Example comment');
+        $requestid2 = $datarequest2->get('id');
+
+        // Approve the requests.
         $this->setAdminUser();
         api::update_request_status($requestid1, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
         api::update_request_status($requestid2, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
         $result = external::bulk_approve_data_requests([$requestid1, $requestid2]);
+
         $return = (object) external_api::clean_returnvalue(external::bulk_approve_data_requests_returns(), $result);
         $this->assertTrue($return->result);
         $this->assertEmpty($return->warnings);
@@ -589,48 +796,100 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * Test for external::bulk_approve_data_requests() for a non-existent request ID.
      */
     public function test_bulk_approve_data_requests_non_existent() {
-        $generator = new testing_data_generator();
-        $requester1 = $generator->create_user();
-        $comment1 = 'sample comment';
-        // Login as requester2.
-        $this->setUser($requester1->id);
-        // Create delete data request.
-        $datarequest1 = api::create_data_request($requester1->id, api::DATAREQUEST_TYPE_DELETE, $comment1);
-
-        $requestid1 = $datarequest1->get('id');
-        $requestid2 = $this->requestid;
+        $this->resetAfterTest();
 
         $this->setAdminUser();
-        api::update_request_status($requestid1, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
-        api::update_request_status($requestid2, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
-        $result = external::bulk_approve_data_requests([$requestid1 + 1, $requestid2]);
+
+        $result = external::bulk_approve_data_requests([42]);
+
         $return = (object) external_api::clean_returnvalue(external::bulk_approve_data_requests_returns(), $result);
         $this->assertFalse($return->result);
         $this->assertCount(1, $return->warnings);
         $warning = reset($return->warnings);
         $this->assertEquals('errorrequestnotfound', $warning['warningcode']);
-        $this->assertEquals($requestid1 + 1, $warning['item']);
+        $this->assertEquals(42, $warning['item']);
+    }
+
+    /**
+     * Test for external::bulk_deny_data_requests() for a user without permission to deny requests.
+     */
+    public function test_bulk_approve_data_requests_no_permission() {
+        $this->resetAfterTest();
+
+        // Create delete data requests.
+        $requester1 = $this->getDataGenerator()->create_user();
+        $this->setUser($requester1->id);
+        $datarequest1 = api::create_data_request($requester1->id, api::DATAREQUEST_TYPE_DELETE, 'Example comment');
+        $requestid1 = $datarequest1->get('id');
+
+        $requester2 = $this->getDataGenerator()->create_user();
+        $this->setUser($requester2->id);
+        $datarequest2 = api::create_data_request($requester2->id, api::DATAREQUEST_TYPE_DELETE, 'Example comment');
+        $requestid2 = $datarequest2->get('id');
+
+        $this->setAdminUser();
+        api::update_request_status($requestid1, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+        api::update_request_status($requestid2, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+
+        // Approve the requests.
+        $uut = $this->getDataGenerator()->create_user();
+        $this->setUser($uut);
+
+        $this->expectException(required_capability_exception::class);
+        $result = external::bulk_approve_data_requests([$requestid1, $requestid2]);
+    }
+
+    /**
+     * Test for external::bulk_deny_data_requests() for a user without permission to deny requests.
+     */
+    public function test_bulk_approve_data_requests_own_request() {
+        $this->resetAfterTest();
+
+        // Create delete data requests.
+        $requester1 = $this->getDataGenerator()->create_user();
+        $this->setUser($requester1->id);
+        $datarequest1 = api::create_data_request($requester1->id, api::DATAREQUEST_TYPE_DELETE, 'Example comment');
+        $requestid1 = $datarequest1->get('id');
+
+        $requester2 = $this->getDataGenerator()->create_user();
+        $this->setUser($requester2->id);
+        $datarequest2 = api::create_data_request($requester2->id, api::DATAREQUEST_TYPE_DELETE, 'Example comment');
+        $requestid2 = $datarequest2->get('id');
+
+        $this->setAdminUser();
+        api::update_request_status($requestid1, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+        api::update_request_status($requestid2, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+
+        // Deny the requests.
+        $this->setUser($requester1);
+
+        $this->expectException(required_capability_exception::class);
+        $result = external::bulk_approve_data_requests([$requestid1]);
     }
 
     /**
      * Test for external::bulk_deny_data_requests().
      */
     public function test_bulk_deny_data_requests() {
-        $generator = new testing_data_generator();
-        $requester1 = $generator->create_user();
-        $comment1 = 'sample comment';
-        // Login as requester2.
-        $this->setUser($requester1->id);
-        // Create delete data request.
-        $datarequest1 = api::create_data_request($requester1->id, api::DATAREQUEST_TYPE_DELETE, $comment1);
+        $this->resetAfterTest();
 
+        // Create delete data requests.
+        $requester1 = $this->getDataGenerator()->create_user();
+        $this->setUser($requester1->id);
+        $datarequest1 = api::create_data_request($requester1->id, api::DATAREQUEST_TYPE_DELETE, 'Example comment');
         $requestid1 = $datarequest1->get('id');
-        $requestid2 = $this->requestid;
 
+        $requester2 = $this->getDataGenerator()->create_user();
+        $this->setUser($requester2->id);
+        $datarequest2 = api::create_data_request($requester2->id, api::DATAREQUEST_TYPE_DELETE, 'Example comment');
+        $requestid2 = $datarequest2->get('id');
+
+        // Deny the requests.
         $this->setAdminUser();
         api::update_request_status($requestid1, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
         api::update_request_status($requestid2, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
         $result = external::bulk_deny_data_requests([$requestid1, $requestid2]);
+
         $return = (object) external_api::clean_returnvalue(external::bulk_approve_data_requests_returns(), $result);
         $this->assertTrue($return->result);
         $this->assertEmpty($return->warnings);
@@ -640,26 +899,73 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
      * Test for external::bulk_deny_data_requests() for a non-existent request ID.
      */
     public function test_bulk_deny_data_requests_non_existent() {
-        $generator = new testing_data_generator();
-        $requester1 = $generator->create_user();
-        $comment1 = 'sample comment';
-        // Login as requester2.
-        $this->setUser($requester1->id);
-        // Create delete data request.
-        $datarequest1 = api::create_data_request($requester1->id, api::DATAREQUEST_TYPE_DELETE, $comment1);
-
-        $requestid1 = $datarequest1->get('id');
-        $requestid2 = $this->requestid;
+        $this->resetAfterTest();
 
         $this->setAdminUser();
-        api::update_request_status($requestid1, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
-        api::update_request_status($requestid2, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
-        $result = external::bulk_deny_data_requests([$requestid1 + 1, $requestid2]);
+        $result = external::bulk_deny_data_requests([42]);
         $return = (object) external_api::clean_returnvalue(external::bulk_approve_data_requests_returns(), $result);
+
         $this->assertFalse($return->result);
         $this->assertCount(1, $return->warnings);
         $warning = reset($return->warnings);
         $this->assertEquals('errorrequestnotfound', $warning['warningcode']);
-        $this->assertEquals($requestid1 + 1, $warning['item']);
+        $this->assertEquals(42, $warning['item']);
+    }
+
+    /**
+     * Test for external::bulk_deny_data_requests() for a user without permission to deny requests.
+     */
+    public function test_bulk_deny_data_requests_no_permission() {
+        $this->resetAfterTest();
+
+        // Create delete data requests.
+        $requester1 = $this->getDataGenerator()->create_user();
+        $this->setUser($requester1->id);
+        $datarequest1 = api::create_data_request($requester1->id, api::DATAREQUEST_TYPE_DELETE, 'Example comment');
+        $requestid1 = $datarequest1->get('id');
+
+        $requester2 = $this->getDataGenerator()->create_user();
+        $this->setUser($requester2->id);
+        $datarequest2 = api::create_data_request($requester2->id, api::DATAREQUEST_TYPE_DELETE, 'Example comment');
+        $requestid2 = $datarequest2->get('id');
+
+        $this->setAdminUser();
+        api::update_request_status($requestid1, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+        api::update_request_status($requestid2, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+
+        // Deny the requests.
+        $uut = $this->getDataGenerator()->create_user();
+        $this->setUser($uut);
+
+        $this->expectException(required_capability_exception::class);
+        $result = external::bulk_deny_data_requests([$requestid1, $requestid2]);
+    }
+
+    /**
+     * Test for external::bulk_deny_data_requests() for a user cannot approve their own request.
+     */
+    public function test_bulk_deny_data_requests_own_request() {
+        $this->resetAfterTest();
+
+        // Create delete data requests.
+        $requester1 = $this->getDataGenerator()->create_user();
+        $this->setUser($requester1->id);
+        $datarequest1 = api::create_data_request($requester1->id, api::DATAREQUEST_TYPE_DELETE, 'Example comment');
+        $requestid1 = $datarequest1->get('id');
+
+        $requester2 = $this->getDataGenerator()->create_user();
+        $this->setUser($requester2->id);
+        $datarequest2 = api::create_data_request($requester2->id, api::DATAREQUEST_TYPE_DELETE, 'Example comment');
+        $requestid2 = $datarequest2->get('id');
+
+        $this->setAdminUser();
+        api::update_request_status($requestid1, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+        api::update_request_status($requestid2, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+
+        // Deny the requests.
+        $this->setUser($requester1);
+
+        $this->expectException(required_capability_exception::class);
+        $result = external::bulk_deny_data_requests([$requestid1]);
     }
 }
index a65e257..ad971e5 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die;
 
-$plugin->version   = 2018091100;
+$plugin->version   = 2018092500;
 $plugin->requires  = 2018050800;        // Moodle 3.5dev (Build 2018031600) and upwards.
 $plugin->component = 'tool_dataprivacy';
index f55b29d..2bc5e61 100644 (file)
@@ -28,7 +28,6 @@ require('../../../config.php');
 require_once('locallib.php');
 require_once('database_export_form.php');
 
-require_login();
 admin_externalpage_setup('tooldbexport');
 
 // Create form.
index aa8b5b3..4423a0a 100644 (file)
@@ -28,7 +28,6 @@ require('../../../config.php');
 require_once('locallib.php');
 require_once('database_transfer_form.php');
 
-require_login();
 admin_externalpage_setup('tooldbtransfer');
 
 // Create the form.
index 1e5b147..5422739 100644 (file)
@@ -39,9 +39,6 @@
 
     $solution = optional_param('solution', 0, PARAM_PLUGIN);
 
-    require_login();
-    require_capability('moodle/site:config', context_system::instance());
-
     $site = get_site();
 
     echo $OUTPUT->header();
index a3cec32..c304d9b 100644 (file)
@@ -29,9 +29,6 @@ admin_externalpage_setup('toolhttpsreplace');
 
 $context = context_system::instance();
 
-require_login();
-require_capability('moodle/site:config', $context);
-
 $PAGE->set_context($context);
 $PAGE->set_url(new moodle_url('/admin/tool/httpsreplace/index.php'));
 $PAGE->set_title(get_string('pageheader', 'tool_httpsreplace'));
index 12d5acb..6d2d963 100644 (file)
@@ -32,9 +32,6 @@ admin_externalpage_setup('toolhttpsreplace');
 
 $context = context_system::instance();
 
-require_login();
-require_capability('moodle/site:config', $context);
-
 $PAGE->set_context($context);
 $PAGE->set_url(new moodle_url('/admin/tool/httpsreplace/index.php'));
 $PAGE->set_title(get_string('pageheader', 'tool_httpsreplace'));
index 6e99834..d17c476 100644 (file)
@@ -32,8 +32,6 @@ admin_externalpage_setup('toolinnodb');
 
 $confirm = optional_param('confirm', 0, PARAM_BOOL);
 
-require_login();
-require_capability('moodle/site:config', context_system::instance());
 
 echo $OUTPUT->header();
 echo $OUTPUT->heading('Convert all MySQL tables from MYISAM to InnoDB');
index 21a470e..5b5d8fb 100644 (file)
@@ -25,9 +25,6 @@
 require_once('../../../../../config.php');
 require_once($CFG->dirroot . '/lib/adminlib.php');
 
-require_login();
-$context = context_system::instance();
-require_capability('moodle/site:config', $context);
 require_sesskey();
 
 navigation_node::override_active_url(new moodle_url('/admin/settings.php', array('section' => 'logsettingdatabase')));
index e914cef..13f21a9 100644 (file)
@@ -33,7 +33,6 @@ $status = optional_param('status', 0, PARAM_BOOL);
 
 // Validate course id.
 if (empty($courseid)) {
-    require_login();
     $context = context_system::instance();
     $coursename = format_string($SITE->fullname, true, array('context' => $context));
     $PAGE->set_context($context);
index 2f98bf4..ce017f6 100644 (file)
@@ -41,7 +41,6 @@ $ignore = optional_param('ignore', '', PARAM_RAW);
 $reset = optional_param('reset', '', PARAM_RAW);
 $id = optional_param('id', '', PARAM_INT);
 
-require_login();
 admin_externalpage_setup('toolspamcleaner');
 
 // Delete one user
index 93e3bbd..e2522ba 100644 (file)
@@ -29,9 +29,7 @@ require_once($CFG->libdir.'/adminlib.php');
 
 $action = optional_param('action', '', PARAM_ALPHANUMEXT);
 
-$syscontext = context_system::instance();
 
-require_login();
 admin_externalpage_setup('toolunsuproles'); // checks permissions specified in settings.php
 
 if ($action === 'delete') {
index 496b52d..9a60af3 100644 (file)
@@ -39,7 +39,6 @@ $previewrows = optional_param('previewrows', 10, PARAM_INT);
 core_php_time_limit::raise(60*60); // 1 hour should be enough
 raise_memory_limit(MEMORY_HUGE);
 
-require_login();
 admin_externalpage_setup('tooluploaduser');
 require_capability('moodle/site:uploadusers', context_system::instance());
 
index 03091aa..a1d0d3d 100644 (file)
@@ -36,8 +36,6 @@ define ('PIX_FILE_SKIPPED', 2);
 
 admin_externalpage_setup('tooluploaduserpictures');
 
-require_login();
-
 require_capability('tool/uploaduser:uploaduserpictures', context_system::instance());
 
 $site = get_site();
index 8feef19..4dd9210 100644 (file)
@@ -50,8 +50,6 @@ if (!isset($SESSION->xmldb)) {
 // Some previous checks
 $site = get_site();
 
-require_login();
-require_capability('moodle/site:config', context_system::instance());
 
 // Body of the script, based on action, we delegate the work
 $action = optional_param ('action', 'main_view', PARAM_ALPHAEXT);
index 9bfc65b..91dd0c4 100644 (file)
@@ -8,7 +8,6 @@ require_once($CFG->libdir.'/adminlib.php');
 
 $confirm = optional_param('confirm', 0, PARAM_BOOL);
 
-require_login();
 admin_externalpage_setup('userbulk');
 require_capability('moodle/user:update', context_system::instance());
 
index 474903a..1b8b8ea 100644 (file)
@@ -8,7 +8,6 @@ require_once($CFG->libdir.'/adminlib.php');
 
 $confirm = optional_param('confirm', 0, PARAM_BOOL);
 
-require_login();
 admin_externalpage_setup('userbulk');
 require_capability('moodle/user:delete', context_system::instance());
 
index 08f5b32..babede0 100644 (file)
@@ -30,7 +30,6 @@ require_once($CFG->dirroot.'/user/profile/lib.php');
 
 $dataformat = optional_param('dataformat', '', PARAM_ALPHA);
 
-require_login();
 admin_externalpage_setup('userbulk');
 require_capability('moodle/user:update', context_system::instance());
 
index 203f69c..a8e522a 100644 (file)
@@ -9,7 +9,6 @@ require_once($CFG->libdir.'/adminlib.php');
 
 $confirm = optional_param('confirm', 0, PARAM_BOOL);
 
-require_login();
 admin_externalpage_setup('userbulk');
 require_capability('moodle/user:update', context_system::instance());
 
index 008367b..adf90ff 100644 (file)
@@ -7,7 +7,6 @@ require_once('user_message_form.php');
 $msg     = optional_param('msg', '', PARAM_CLEANHTML);
 $confirm = optional_param('confirm', 0, PARAM_BOOL);
 
-require_login();
 admin_externalpage_setup('userbulk');
 require_capability('moodle/site:manageallmessaging', context_system::instance());
 
index f15f7c2..b4d246a 100644 (file)
@@ -42,7 +42,6 @@ if ($node && $newnode) {
     $newnode->make_active();
 }
 
-require_capability('moodle/site:config', context_system::instance());
 
 $tokenlisturl = new moodle_url("/" . $CFG->admin . "/settings.php", array('section' => 'webservicetokens'));
 
index c0eef4b..8d7a0fa 100644 (file)
@@ -32,9 +32,6 @@ if (!core_component::is_valid_plugin_name('auth', $auth)) {
     $auth = '';
 }
 
-require_login();
-require_capability('moodle/site:config', context_system::instance());
-
 navigation_node::override_active_url(new moodle_url('/admin/settings.php', array('section'=>'manageauths')));
 admin_externalpage_setup('authtestsettings');
 
index b596f15..4b9013f 100644 (file)
@@ -41,7 +41,7 @@ class block_myprofile extends block_base {
      * block initializations
      */
     public function init() {
-        $this->title   = get_string('pluginname', 'block_myprofile');
+        $this->title = get_string('pluginname', 'block_myprofile');
     }
 
     /**
@@ -50,128 +50,22 @@ class block_myprofile extends block_base {
      * @return object
      */
     public function get_content() {
-        global $CFG, $USER, $DB, $OUTPUT, $PAGE;
 
         if ($this->content !== NULL) {
             return $this->content;
         }
 
         if (!isloggedin() or isguestuser()) {
-            return '';      // Never useful unless you are logged in as real users
+            // Only real users can access myprofile block.
+            return;
         }
 
-        $this->content = new stdClass;
-        $this->content->text = '';
-        $this->content->footer = '';
-
-        $course = $this->page->course;
-
-        if (!isset($this->config->display_picture) || $this->config->display_picture == 1) {
-            $this->content->text .= '<div class="myprofileitem picture">';
-            $this->content->text .= $OUTPUT->user_picture($USER, array('courseid'=>$course->id, 'size'=>'100', 'class'=>'profilepicture'));  // The new class makes CSS easier
-            $this->content->text .= '</div>';
-        }
-
-        $this->content->text .= '<div class="myprofileitem fullname">'.fullname($USER).'</div>';
-
-        if(!isset($this->config->display_country) || $this->config->display_country == 1) {
-            $countries = get_string_manager()->get_list_of_countries(true);
-            if (isset($countries[$USER->country])) {
-                $this->content->text .= '<div class="myprofileitem country">';
-                $this->content->text .= get_string('country') . ': ' . $countries[$USER->country];
-                $this->content->text .= '</div>';
-            }
-        }
-
-        if(!isset($this->config->display_city) || $this->config->display_city == 1) {
-            $this->content->text .= '<div class="myprofileitem city">';
-            $this->content->text .= get_string('city') . ': ' . format_string($USER->city);
-            $this->content->text .= '</div>';
-        }
-
-        if(!isset($this->config->display_email) || $this->config->display_email == 1) {
-            $this->content->text .= '<div class="myprofileitem email">';
-            $this->content->text .= obfuscate_mailto($USER->email, '');
-            $this->content->text .= '</div>';
-        }
-
-        if(!empty($this->config->display_icq) && !empty($USER->icq)) {
-            $this->content->text .= '<div class="myprofileitem icq">';
-            $this->content->text .= 'ICQ: ' . s($USER->icq);
-            $this->content->text .= '</div>';
-        }
-
-        if(!empty($this->config->display_skype) && !empty($USER->skype)) {
-            $this->content->text .= '<div class="myprofileitem skype">';
-            $this->content->text .= 'Skype: ' . s($USER->skype);
-            $this->content->text .= '</div>';
-        }
-
-        if(!empty($this->config->display_yahoo) && !empty($USER->yahoo)) {
-            $this->content->text .= '<div class="myprofileitem yahoo">';
-            $this->content->text .= 'Yahoo: ' . s($USER->yahoo);
-            $this->content->text .= '</div>';
-        }
-
-        if(!empty($this->config->display_aim) && !empty($USER->aim)) {
-            $this->content->text .= '<div class="myprofileitem aim">';
-            $this->content->text .= 'AIM: ' . s($USER->aim);
-            $this->content->text .= '</div>';
-        }
+        $renderable = new \block_myprofile\output\myprofile($this->config);
+        $renderer = $this->page->get_renderer('block_myprofile');
 
-        if(!empty($this->config->display_msn) && !empty($USER->msn)) {
-            $this->content->text .= '<div class="myprofileitem msn">';
-            $this->content->text .= 'MSN: ' . s($USER->msn);
-            $this->content->text .= '</div>';
-        }
-
-        if(!empty($this->config->display_phone1) && !empty($USER->phone1)) {
-            $this->content->text .= '<div class="myprofileitem phone1">';
-            $this->content->text .= get_string('phone1').': ' . s($USER->phone1);
-            $this->content->text .= '</div>';
-        }
-
-        if(!empty($this->config->display_phone2) && !empty($USER->phone2)) {
-            $this->content->text .= '<div class="myprofileitem phone2">';
-            $this->content->text .= get_string('phone2').': ' . s($USER->phone2);
-            $this->content->text .= '</div>';
-        }
-
-        if(!empty($this->config->display_institution) && !empty($USER->institution)) {
-            $this->content->text .= '<div class="myprofileitem institution">';
-            $this->content->text .= format_string($USER->institution);
-            $this->content->text .= '</div>';
-        }
-
-        if(!empty($this->config->display_address) && !empty($USER->address)) {
-            $this->content->text .= '<div class="myprofileitem address">';
-            $this->content->text .= format_string($USER->address);
-            $this->content->text .= '</div>';
-        }
-
-        if(!empty($this->config->display_firstaccess) && !empty($USER->firstaccess)) {
-            $this->content->text .= '<div class="myprofileitem firstaccess">';
-            $this->content->text .= get_string('firstaccess').': ' . userdate($USER->firstaccess);
-            $this->content->text .= '</div>';
-        }
-
-        if(!empty($this->config->display_lastaccess) && !empty($USER->lastaccess)) {
-            $this->content->text .= '<div class="myprofileitem lastaccess">';
-            $this->content->text .= get_string('lastaccess').': ' . userdate($USER->lastaccess);
-            $this->content->text .= '</div>';
-        }
-
-        if(!empty($this->config->display_currentlogin) && !empty($USER->currentlogin)) {
-            $this->content->text .= '<div class="myprofileitem currentlogin">';
-            $this->content->text .= get_string('login').': ' . userdate($USER->currentlogin);
-            $this->content->text .= '</div>';
-        }
-
-        if(!empty($this->config->display_lastip) && !empty($USER->lastip)) {
-            $this->content->text .= '<div class="myprofileitem lastip">';
-            $this->content->text .= 'IP: ' . $USER->lastip;
-            $this->content->text .= '</div>';
-        }
+        $this->content = new stdClass();
+        $this->content->text = $renderer->render($renderable);
+        $this->content->footer = '';
 
         return $this->content;
     }
diff --git a/blocks/myprofile/classes/output/myprofile.php b/blocks/myprofile/classes/output/myprofile.php
new file mode 100644 (file)
index 0000000..714c9d0
--- /dev/null
@@ -0,0 +1,141 @@
+<?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 containing data for myprofile block.
+ *
+ * @package    block_myprofile
+ * @copyright  2018 Mihail Geshoski <mihail@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace block_myprofile\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+use renderable;
+use renderer_base;
+use templatable;
+
+/**
+ * Class containing data for myprofile block.
+ *
+ * @copyright  2018 Mihail Geshoski <mihail@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class myprofile implements renderable, templatable {
+
+    /**
+     * @var object An object containing the configuration information for the current instance of this block.
+     */
+    protected $config;
+
+    /**
+     * Constructor.
+     *
+     * @param object $config An object containing the configuration information for the current instance of this block.
+     */
+    public function __construct($config) {
+        $this->config = $config;
+    }
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param \renderer_base $output
+     * @return stdClass
+     */
+    public function export_for_template(renderer_base $output) {
+        global $USER, $OUTPUT;
+
+        $data = new \stdClass();
+
+        if (!isset($this->config->display_picture) || $this->config->display_picture == 1) {
+            $data->userpicture = $OUTPUT->user_picture($USER, array('class' => 'userpicture'));
+        }
+
+        $data->userfullname = fullname($USER);
+
+        if (!isset($this->config->display_country) || $this->config->display_country == 1) {
+            $countries = get_string_manager()->get_list_of_countries(true);
+            if (isset($countries[$USER->country])) {
+                $data->usercountry = $countries[$USER->country];
+            }
+        }
+
+        if (!isset($this->config->display_city) || $this->config->display_city == 1) {
+            $data->usercity = $USER->city;
+        }
+
+        if (!isset($this->config->display_email) || $this->config->display_email == 1) {
+            $data->useremail = obfuscate_mailto($USER->email, '');
+        }
+
+        if (!empty($this->config->display_icq) && !empty($USER->icq)) {
+            $data->usericq = s($USER->icq);
+        }
+
+        if (!empty($this->config->display_skype) && !empty($USER->skype)) {
+            $data->userskype = s($USER->skype);
+        }
+
+        if (!empty($this->config->display_yahoo) && !empty($USER->yahoo)) {
+            $data->useryahoo = s($USER->yahoo);
+        }
+
+        if (!empty($this->config->display_aim) && !empty($USER->aim)) {
+            $data->useraim = s($USER->aim);
+        }
+
+        if (!empty($this->config->display_msn) && !empty($USER->msn)) {
+            $data->usermsn = s($USER->msn);
+        }
+
+        if (!empty($this->config->display_phone1) && !empty($USER->phone1)) {
+            $data->userphone1 = s($USER->phone1);
+        }
+
+        if (!empty($this->config->display_phone2) && !empty($USER->phone2)) {
+            $data->userphone2 = s($USER->phone2);
+        }
+
+        if (!empty($this->config->display_institution) && !empty($USER->institution)) {
+            $data->userinstitution = format_string($USER->institution);
+        }
+
+        if (!empty($this->config->display_address) && !empty($USER->address)) {
+            $data->useraddress = format_string($USER->address);
+        }
+
+        if (!empty($this->config->display_firstaccess) && !empty($USER->firstaccess)) {
+            $data->userfirstaccess = userdate($USER->firstaccess);
+        }
+
+        if (!empty($this->config->display_lastaccess) && !empty($USER->lastaccess)) {
+            $data->userlastaccess = userdate($USER->lastaccess);
+        }
+
+        if (!empty($this->config->display_currentlogin) && !empty($USER->currentlogin)) {
+            $data->usercurrentlogin = userdate($USER->currentlogin);
+        }
+
+        if (!empty($this->config->display_lastip) && !empty($USER->lastip)) {
+            $data->userlastip = $USER->lastip;
+        }
+
+        return $data;
+    }
+}
diff --git a/blocks/myprofile/classes/output/renderer.php b/blocks/myprofile/classes/output/renderer.php
new file mode 100644 (file)
index 0000000..4d6ab1f
--- /dev/null
@@ -0,0 +1,49 @@
+<?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/>.
+
+/**
+ * myprofile block rendrer
+ *
+ * @package    block_myprofile
+ * @copyright  2018 Mihail Geshoski <mihail@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace block_myprofile\output;
+
+defined('MOODLE_INTERNAL') || die;
+
+use plugin_renderer_base;
+
+/**
+ * myprofile block renderer
+ *
+ * @package    block_myprofile
+ * @copyright  2018 Mihail Geshoski <mihail@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class renderer extends plugin_renderer_base {
+
+    /**
+     * Return the main content for the block myprofile.
+     *
+     * @param myprofile $myprofile The myprofile renderable
+     * @return string HTML string
+     */
+    public function render_myprofile(myprofile $myprofile) {
+        return $this->render_from_template('block_myprofile/myprofile', $myprofile->export_for_template($this));
+    }
+}
index ee3944e..03675b4 100644 (file)
@@ -1,14 +1,28 @@
 .block_myprofile img.profilepicture {
-    height: 100px;
-    width: 100px;
+    height: 50px;
+    width: 50px;
 }
 
 .block_myprofile .myprofileitem.fullname {
     font-size: 1.5em;
     font-weight: bold;
+    margin-bottom: 0.5rem;
 }
 
 .block_myprofile .myprofileitem.edit {
     text-align: right;
 }
 
+.block_myprofile .content {
+    display: flex;
+}
+
+.block_myprofile .myprofileitem.picture img {
+    width: 50px;
+    height: 50px;
+    margin-right: 1rem;
+}
+
+.block_myprofile .myprofileitem span {
+    font-weight: bold;
+}
diff --git a/blocks/myprofile/templates/myprofile.mustache b/blocks/myprofile/templates/myprofile.mustache
new file mode 100644 (file)
index 0000000..e132b0d
--- /dev/null
@@ -0,0 +1,179 @@
+{{!
+    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 block_myprofile/myprofile
+
+    This template renders the content of the myprofile block.
+
+    Classes required for JS:
+        * none
+
+        Data attributes required for JS:
+        * none
+
+        Context variables required for this template:
+        * userfullname
+
+        Optional context variables for this template:
+        * userpicture
+        * usercountry
+        * usercity
+        * useremail
+        * usericq
+        * userskype
+        * useryahoo
+        * useraim
+        * usermsn
+        * userphone1
+        * userphone2
+        * userinstitution
+        * useraddress
+        * userfirstaccess
+        * userlastaccess
+        * usercurrentlogin
+        * userlastip
+
+    Example context (json):
+    {
+        "userpicture": "<img src='http://example.com/image.png' title='Picture of John Doe'>",
+        "userfullname": "John Doe",
+        "usercountry": "Australia",
+        "usercity": "Perth",
+        "useremail": "<a href=''>john.doe@example.com</a>",
+        "usericq": "12345",
+        "userskype": "john.doe",
+        "useryahoo": "12345",
+        "useraim": "12345",
+        "usermsn": "12345",
+        "userphone1": "123456789",
+        "userphone2": "123456789",
+        "userinstitution": "Institution",
+        "useraddress": "Address",
+        "userfirstaccess": "Friday, 6 July 2018, 9:03 AM",
+        "userlastaccess": "Wednesday, 26 September 2018, 8:05 AM",
+        "usercurrentlogin": "Wednesday, 26 September 2018, 7:17 AM",
+        "userlastip": "0:0:0:0:0:0:0:1"
+    }
+}}
+<div>
+    {{#userpicture}}
+    <div class="myprofileitem picture">
+        {{{ userpicture }}}
+    </div>
+    {{/userpicture}}
+</div>
+<div class="w-100 no-overflow">
+    <div class="myprofileitem fullname">
+        {{ userfullname }}
+    </div>
+    {{#usercountry}}
+    <div class="myprofileitem country">
+        <span>{{#str}} country {{/str}}:</span>
+        {{ usercountry }}
+    </div>
+    {{/usercountry}}
+    {{#usercity}}
+    <div class="myprofileitem city">
+         <span>{{#str}} city {{/str}}:</span>
+         {{ usercity }}
+    </div>
+    {{/usercity}}
+    {{#useremail}}
+        <div class="myprofileitem city">
+             <span>{{#str}} email {{/str}}:</span>
+             {{{ useremail }}}
+        </div>
+    {{/useremail}}
+    {{#usericq}}
+    <div class="myprofileitem icq">
+         <span>ICQ:</span>
+         {{ usericq }}
+    </div>
+    {{/usericq}}
+    {{#userskype}}
+    <div class="myprofileitem skype">
+         <span>Skype:</span>
+         {{ userskype }}
+    </div>
+    {{/userskype}}
+    {{#useryahoo}}
+    <div class="myprofileitem yahoo">
+         <span>Yahoo:</span>
+         {{ useryahoo }}
+    </div>
+    {{/useryahoo}}
+    {{#useraim}}
+    <div class="myprofileitem aim">
+         <span>AIM:</span>
+         {{ useraim }}
+    </div>
+    {{/useraim}}
+    {{#usermsn}}
+    <div class="myprofileitem msn">
+         <span>MSN:</span>
+         {{ usermsn }}
+    </div>
+    {{/usermsn}}
+    {{#userphone1}}
+    <div class="myprofileitem phone1">
+         <span>{{#str}} phone1 {{/str}}:</span>
+         {{ userphone1 }}
+    </div>
+    {{/userphone1}}
+    {{#userphone2}}
+    <div class="myprofileitem phone2">
+         <span>{{#str}} phone2 {{/str}}:</span>
+         {{ userphone2 }}
+    </div>
+    {{/userphone2}}
+    {{#userinstitution}}
+    <div class="myprofileitem institution">
+        <span>{{#str}} institution {{/str}}:</span>
+        {{ userinstitution }}
+    </div>
+    {{/userinstitution}}
+    {{#useraddress}}
+    <div class="myprofileitem address">
+        <span>{{#str}} address {{/str}}:</span>
+        {{ useraddress }}
+    </div>
+    {{/useraddress}}
+    {{#userfirstaccess}}
+    <div class="myprofileitem firstaccess">
+         <span>{{#str}} firstaccess {{/str}}: </span>
+         {{ userfirstaccess }}
+    </div>
+    {{/userfirstaccess}}
+    {{#userlastaccess}}
+    <div class="myprofileitem lastaccess">
+         <span>{{#str}} lastaccess {{/str}}:</span>
+         {{ userlastaccess }}
+    </div>
+    {{/userlastaccess}}
+    {{#usercurrentlogin}}
+    <div class="myprofileitem currentlogin">
+         <span>{{#str}} login {{/str}}:</span>
+         {{ usercurrentlogin }}
+    </div>
+    {{/usercurrentlogin}}
+    {{#userlastip}}
+    <div class="myprofileitem lastip">
+         <span>IP:</span>
+         {{ userlastip }}
+    </div>
+    {{/userlastip}}
+</div>
diff --git a/blog/classes/external.php b/blog/classes/external.php
new file mode 100644 (file)
index 0000000..e199d35
--- /dev/null
@@ -0,0 +1,291 @@
+<?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 is the external API for blogs.
+ *
+ * @package    core_blog
+ * @copyright  2018 Juan Leyva
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_blog;
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir .'/externallib.php');
+require_once($CFG->dirroot .'/blog/lib.php');
+require_once($CFG->dirroot .'/blog/locallib.php');
+
+use external_api;
+use external_function_parameters;
+use external_value;
+use external_single_structure;
+use external_multiple_structure;
+use external_warnings;
+use context_system;
+use context_course;
+use moodle_exception;
+use core_blog\external\post_exporter;
+
+/**
+ * This is the external API for blogs.
+ *
+ * @copyright  2018 Juan Leyva
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class external extends external_api {
+
+    /**
+     * Validate access to the blog and the filters to apply when listing entries.
+     *
+     * @param  array  $rawwsfilters array containing the filters in WS format
+     * @return array  context, filters to apply and the calculated courseid and user
+     * @since  Moodle 3.6
+     */
+    protected static function validate_access_and_filters($rawwsfilters) {
+        global $CFG;
+
+        if (empty($CFG->enableblogs)) {
+            throw new moodle_exception('blogdisable', 'blog');
+        }
+
+        // Init filters.
+        $filterstype = array(
+            'courseid' => PARAM_INT,
+            'groupid' => PARAM_INT,
+            'userid' => PARAM_INT,
+            'tagid' => PARAM_INT,
+            'tag' => PARAM_NOTAGS,
+            'cmid' => PARAM_INT,
+            'entryid' => PARAM_INT,
+            'search' => PARAM_RAW
+        );
+        $filters = array(
+            'courseid' => null,
+            'groupid' => null,
+            'userid' => null,
+            'tagid' => null,
+            'tag' => null,
+            'cmid' => null,
+            'entryid' => null,
+            'search' => null
+        );
+
+        foreach ($rawwsfilters as $filter) {
+            $name = trim($filter['name']);
+            if (!isset($filterstype[$name])) {
+                throw new moodle_exception('errorinvalidparam', 'webservice', '', $name);
+            }
+            $filters[$name] = clean_param($filter['value'], $filterstype[$name]);
+        }
+
+        // Do not overwrite here the filters, blog_get_headers and blog_listing will take care of that.
+        list($courseid, $userid) = blog_validate_access($filters['courseid'], $filters['cmid'], $filters['groupid'],
+            $filters['entryid'], $filters['userid']);
+
+        if ($courseid && $courseid != SITEID) {
+            $context = context_course::instance($courseid);
+            self::validate_context($context);
+        } else {
+            $context = context_system::instance();
+            if ($CFG->bloglevel == BLOG_GLOBAL_LEVEL) {
+                // Everybody can see anything - no login required unless site is locked down using forcelogin.
+                if ($CFG->forcelogin) {
+                    self::validate_context($context);
+                }
+            } else {
+                self::validate_context($context);
+            }
+        }
+        // Courseid and userid may not be the same that the ones in $filters.
+        return array($context, $filters, $courseid, $userid);
+    }
+
+    /**
+     * Returns description of get_entries() parameters.
+     *
+     * @return external_function_parameters
+     * @since  Moodle 3.6
+     */
+    public static function get_entries_parameters() {
+        return new external_function_parameters(
+            array(
+                'filters' => new external_multiple_structure (
+                    new external_single_structure(
+                        array(
+                            'name' => new external_value(PARAM_ALPHA,
+                                'The expected keys (value format) are:
+                                tag      PARAM_NOTAGS blog tag
+                                tagid    PARAM_INT    blog tag id
+                                userid   PARAM_INT    blog author (userid)
+                                cmid    PARAM_INT    course module id
+                                entryid  PARAM_INT    entry id
+                                groupid  PARAM_INT    group id
+                                courseid PARAM_INT    course id
+                                search   PARAM_RAW    search term
+                                '
+                            ),
+                            'value' => new external_value(PARAM_RAW, 'The value of the filter.')
+                        )
+                    ), 'Parameters to filter blog listings.', VALUE_DEFAULT, array()
+                ),
+                'page' => new external_value(PARAM_INT, 'The blog page to return.', VALUE_DEFAULT, 0),
+                'perpage' => new external_value(PARAM_INT, 'The number of posts to return per page.', VALUE_DEFAULT, 10),
+            )
+        );
+    }
+
+    /**
+     * Return blog entries.
+     *
+     * @param array $filters the parameters to filter the blog listing
+     * @param int $page the blog page to return
+     * @param int $perpage the number of posts to return per page
+     * @return array with the blog entries and warnings
+     * @since  Moodle 3.6
+     */
+    public static function get_entries($filters = array(), $page = 0, $perpage = 10) {
+        global $PAGE;
+
+        $warnings = array();
+        $params = self::validate_parameters(self::get_entries_parameters(),
+            array('filters' => $filters, 'page' => $page, 'perpage' => $perpage));
+
+        list($context, $filters, $courseid, $userid) = self::validate_access_and_filters($params['filters']);
+
+        $PAGE->set_context($context); // Needed by internal APIs.
+        $output = $PAGE->get_renderer('core');
+
+        // Get filters.
+        $blogheaders = blog_get_headers($filters['courseid'], $filters['groupid'], $filters['userid'], $filters['tagid'],
+            $filters['tag'], $filters['cmid'], $filters['entryid'], $filters['search']);
+        $bloglisting = new \blog_listing($blogheaders['filters']);
+
+        $page  = $params['page'];
+        $limit = empty($params['perpage']) ? get_user_preferences('blogpagesize', 10) : $params['perpage'];
+        $start = $page * $limit;
+        $entries = $bloglisting->get_entries($start, $limit);
+        $totalentries = $bloglisting->count_entries();
+
+        $exportedentries = array();
+        foreach ($entries as $entry) {
+            $exporter = new post_exporter($entry, array('context' => $context));
+            $exportedentries[] = $exporter->export($output);
+        }
+        return array(
+            'warnings' => $warnings,
+            'entries' => $exportedentries,
+            'totalentries' => $totalentries,
+        );
+    }
+
+    /**
+     * Returns description of get_entries() result value.
+     *
+     * @return external_description
+     * @since  Moodle 3.6
+     */
+    public static function get_entries_returns() {
+        return new external_single_structure(
+            array(
+                'entries' => new external_multiple_structure(
+                    post_exporter::get_read_structure()
+                ),
+                'totalentries' => new external_value(PARAM_INT, 'The total number of entries found.'),
+                'warnings' => new external_warnings(),
+            )
+        );
+    }
+
+    /**
+     * Returns description of view_entries() parameters.
+     *
+     * @return external_function_parameters
+     * @since  Moodle 3.6
+     */
+    public static function view_entries_parameters() {
+        return new external_function_parameters(
+            array(
+                'filters' => new external_multiple_structure (
+                    new external_single_structure(
+                        array(
+                            'name' => new external_value(PARAM_ALPHA,
+                                'The expected keys (value format) are:
+                                tag      PARAM_NOTAGS blog tag
+                                tagid    PARAM_INT    blog tag id
+                                userid   PARAM_INT    blog author (userid)
+                                cmid     PARAM_INT    course module id
+                                entryid  PARAM_INT    entry id
+                                groupid  PARAM_INT    group id
+                                courseid PARAM_INT    course id
+                                search   PARAM_RAW    search term
+                                '
+                            ),
+                            'value' => new external_value(PARAM_RAW, 'The value of the filter.')
+                        )
+                    ), 'Parameters used in the filter of view_entries.', VALUE_DEFAULT, array()
+                ),
+            )
+        );
+    }
+
+    /**
+     * Trigger the blog_entries_viewed event.
+     *
+     * @param array $filters the parameters used in the filter of get_entries
+     * @return array with status result and warnings
+     * @since  Moodle 3.6
+     */
+    public static function view_entries($filters = array()) {
+
+        $warnings = array();
+        $params = self::validate_parameters(self::view_entries_parameters(), array('filters' => $filters));
+
+        list($context, $filters, $courseid, $userid) = self::validate_access_and_filters($params['filters']);
+
+        $eventparams = array(
+            'other' => array('entryid' => $filters['entryid'], 'tagid' => $filters['tagid'], 'userid' => $userid,
+                'modid' => $filters['cmid'], 'groupid' => $filters['groupid'], 'search' => $filters['search']
+            )
+        );
+        if (!empty($userid)) {
+            $eventparams['relateduserid'] = $userid;
+        }
+        $eventparams['other']['courseid'] = ($courseid === SITEID) ? 0 : $courseid;
+        $event = \core\event\blog_entries_viewed::create($eventparams);
+        $event->trigger();
+
+        return array(
+            'warnings' => $warnings,
+            'status' => true,
+        );
+    }
+
+    /**
+     * Returns description of view_entries() result value.
+     *
+     * @return external_description
+     * @since  Moodle 3.6
+     */
+    public static function view_entries_returns() {
+        return new external_single_structure(
+            array(
+                'status' => new external_value(PARAM_BOOL, 'status: true if success'),
+                'warnings' => new external_warnings(),
+            )
+        );
+    }
+}
diff --git a/blog/classes/external/post_exporter.php b/blog/classes/external/post_exporter.php
new file mode 100644 (file)
index 0000000..3d69299
--- /dev/null
@@ -0,0 +1,185 @@
+<?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 exporting a blog post (entry).
+ *
+ * @package    core_blog
+ * @copyright  2018 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_blog\external;
+defined('MOODLE_INTERNAL') || die();
+
+use core\external\exporter;
+use external_util;
+use external_files;
+use renderer_base;
+use context_system;
+
+/**
+ * Class for exporting a blog post (entry).
+ *
+ * @copyright  2018 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class post_exporter extends exporter {
+
+    /**
+     * Return the list of properties.
+     *
+     * @return array list of properties
+     */
+    protected static function define_properties() {
+        return array(
+            'id' => array(
+                'type' => PARAM_INT,
+                'null' => NULL_ALLOWED,
+                'description' => 'Post/entry id.',
+            ),
+            'module' => array(
+                'type' => PARAM_ALPHANUMEXT,
+                'null' => NULL_NOT_ALLOWED,
+                'description' => 'Where it was published the post (blog, blog_external...).',
+            ),
+            'userid' => array(
+                'type' => PARAM_INT,
+                'null' => NULL_NOT_ALLOWED,
+                'default' => 0,
+                'description' => 'Post author.',
+            ),
+            'courseid' => array(
+                'type' => PARAM_INT,
+                'null' => NULL_NOT_ALLOWED,
+                'default' => 0,
+                'description' => 'Course where the post was created.',
+            ),
+            'groupid' => array(
+                'type' => PARAM_INT,
+                'null' => NULL_NOT_ALLOWED,
+                'default' => 0,
+                'description' => 'Group post was created for.',
+            ),
+            'moduleid' => array(
+                'type' => PARAM_INT,
+                'null' => NULL_NOT_ALLOWED,
+                'default' => 0,
+                'description' => 'Module id where the post was created (not used anymore).',
+            ),
+            'coursemoduleid' => array(
+                'type' => PARAM_INT,
+                'null' => NULL_NOT_ALLOWED,
+                'default' => 0,
+                'description' => 'Course module id where the post was created.',
+            ),
+            'subject' => array(
+                'type' => PARAM_TEXT,
+                'null' => NULL_NOT_ALLOWED,
+                'description' => 'Post subject.',
+            ),
+            'summary' => array(
+                'type' => PARAM_RAW,
+                'null' => NULL_ALLOWED,
+                'description' => 'Post summary.',
+            ),
+            'content' => array(
+                'type' => PARAM_RAW,
+                'null' => NULL_ALLOWED,
+                'description' => 'Post content.',
+            ),
+            'uniquehash' => array(
+                'type' => PARAM_RAW,
+                'null' => NULL_NOT_ALLOWED,
+                'description' => 'Post unique hash.',
+            ),
+            'rating' => array(
+                'type' => PARAM_INT,
+                'null' => NULL_NOT_ALLOWED,
+                'default' => 0,
+                'description' => 'Post rating.',
+            ),
+            'format' => array(
+                'type' => PARAM_INT,
+                'null' => NULL_NOT_ALLOWED,
+                'default' => 0,
+                'description' => 'Post content format.',
+            ),
+            'summaryformat' => array(
+                'choices' => array(FORMAT_HTML, FORMAT_MOODLE, FORMAT_PLAIN, FORMAT_MARKDOWN),
+                'type' => PARAM_INT,
+                'default' => FORMAT_MOODLE,
+                'description' => 'Format for the summary field.',
+            ),
+            'attachment' => array(
+                'type' => PARAM_RAW,
+                'null' => NULL_ALLOWED,
+                'description' => 'Post atachment.',
+            ),
+            'publishstate' => array(
+                'type' => PARAM_ALPHA,
+                'null' => NULL_NOT_ALLOWED,
+                'default' => 'draft',
+                'description' => 'Post publish state.',
+            ),
+            'lastmodified' => array(
+                'type' => PARAM_INT,
+                'null' => NULL_NOT_ALLOWED,
+                'default' => 0,
+                'description' => 'When it was last modified.',
+            ),
+            'created' => array(
+                'type' => PARAM_INT,
+                'null' => NULL_NOT_ALLOWED,
+                'default' => 0,
+                'description' => 'When it was created.',
+            ),
+            'usermodified' => array(
+                'type' => PARAM_INT,
+                'null' => NULL_ALLOWED,
+                'description' => 'User that updated the post.',
+            ),
+        );
+    }
+
+    protected static function define_related() {
+        return array(
+            'context' => 'context'
+        );
+    }
+
+    protected static function define_other_properties() {
+        return array(
+            'summaryfiles' => array(
+                'type' => external_files::get_properties_for_exporter(),
+                'multiple' => true
+            ),
+            'attachmentfiles' => array(
+                'type' => external_files::get_properties_for_exporter(),
+                'multiple' => true,
+                'optional' => true
+            ),
+        );
+    }
+
+    protected function get_other_values(renderer_base $output) {
+        $context = context_system::instance(); // Files always on site context.
+
+        $values['summaryfiles'] = external_util::get_area_files($context->id, 'blog', 'post', $this->data->id);
+        $values['attachmentfiles'] = external_util::get_area_files($context->id, 'blog', 'attachment', $this->data->id);
+
+        return $values;
+    }
+}
index 664d70a..a7b8c97 100644 (file)
@@ -71,8 +71,6 @@ if (isset($userid) && empty($courseid) && empty($modid)) {
 }
 $PAGE->set_context($context);
 
-$sitecontext = context_system::instance();
-
 if (isset($userid) && $USER->id == $userid) {
     $blognode = $PAGE->navigation->find('siteblog', null);
     if ($blognode) {
@@ -108,125 +106,20 @@ if (empty($CFG->enableblogs)) {
     print_error('blogdisable', 'blog');
 }
 
-// Add courseid if modid or groupid is specified: This is used for navigation and title.
-if (!empty($modid) && empty($courseid)) {
-    $courseid = $DB->get_field('course_modules', 'course', array('id' => $modid));
-}
-
-if (!empty($groupid) && empty($courseid)) {
-    $courseid = $DB->get_field('groups', 'courseid', array('id' => $groupid));
-}
-
-
-if (!$userid && has_capability('moodle/blog:view', $sitecontext) && $CFG->bloglevel > BLOG_USER_LEVEL) {
-    if ($entryid) {
-        if (!$entryobject = $DB->get_record('post', array('id' => $entryid))) {
-            print_error('nosuchentry', 'blog');
-        }
-        $userid = $entryobject->userid;
-    }
-} else if (!$userid) {
-    $userid = $USER->id;
-}
-
-if (!empty($modid)) {
-    if ($CFG->bloglevel < BLOG_SITE_LEVEL) {
-        print_error(get_string('nocourseblogs', 'blog'));
-    }
-    if (!$mod = $DB->get_record('course_modules', array('id' => $modid))) {
-        print_error(get_string('invalidmodid', 'blog'));
-    }
-    $courseid = $mod->course;
-}
-
-if ((empty($courseid) ? true : $courseid == SITEID) && empty($userid)) {
-    if ($CFG->bloglevel < BLOG_SITE_LEVEL) {
-        print_error('siteblogdisable', 'blog');
-    }
-    if (!has_capability('moodle/blog:view', $sitecontext)) {
-        print_error('cannotviewsiteblog', 'blog');
-    }
-
-    $COURSE = $DB->get_record('course', array('format' => 'site'));
-    $courseid = $COURSE->id;
-}
-
-if (!empty($courseid)) {
-    if (!$course = $DB->get_record('course', array('id' => $courseid))) {
-        print_error('invalidcourseid');
-    }
-
-    $courseid = $course->id;
-    require_login($course);
-
-    if (!has_capability('moodle/blog:view', $sitecontext)) {
-        print_error('cannotviewcourseblog', 'blog');
-    }
-} else {
-    $coursecontext = context_course::instance(SITEID);
-}
-
-if (!empty($groupid)) {
-    if ($CFG->bloglevel < BLOG_SITE_LEVEL) {
-        print_error('groupblogdisable', 'blog');
-    }
-
-    if (! $group = groups_get_group($groupid)) {
-        print_error(get_string('invalidgroupid', 'blog'));
-    }
+list($courseid, $userid) = blog_validate_access($courseid, $modid, $groupid, $entryid, $userid);
 
-    if (!$course = $DB->get_record('course', array('id' => $group->courseid))) {
-        print_error('invalidcourseid');
-    }
+$courseid = (empty($courseid)) ? SITEID : $courseid;
 
-    $coursecontext = context_course::instance($course->id);
-    $courseid = $course->id;
+if ($courseid != SITEID) {
+    $course = get_course($courseid);
     require_login($course);
-
- &nb