Merge branch 'MDL-62395-master' of git://github.com/junpataleta/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 9 Oct 2018 21:06:28 +0000 (23:06 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 9 Oct 2018 21:06:28 +0000 (23:06 +0200)
154 files changed:
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/metadata_registry.php
admin/tool/dataprivacy/classes/output/renderer.php
admin/tool/dataprivacy/classes/output/summary_page.php [new file with mode: 0644]
admin/tool/dataprivacy/classes/task/delete_expired_contexts.php
admin/tool/dataprivacy/classes/task/expired_retention_period.php
admin/tool/dataprivacy/createdatarequest.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/dataprivacy/lib.php
admin/tool/dataprivacy/settings.php
admin/tool/dataprivacy/summary.php [new file with mode: 0644]
admin/tool/dataprivacy/templates/summary.mustache [new file with mode: 0644]
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
auth/classes/output/login.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]
blocks/timeline/tests/behat/block_timeline_dates.feature
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
course/classes/list_element.php
course/classes/output/activity_navigation.php
course/externallib.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/upgrade.txt
grade/edit/tree/lib.php
install/lang/el/error.php
install/lang/el/install.php
lang/en/admin.php
lang/en/deprecated.txt
lang/en/message.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/badgeslib.php
lib/behat/behat_base.php
lib/classes/event/message_contact_blocked.php
lib/classes/event/message_contact_unblocked.php
lib/classes/event/message_user_blocked.php [new file with mode: 0644]
lib/classes/event/message_user_unblocked.php [new file with mode: 0644]
lib/db/install.xml
lib/db/messages.php
lib/db/services.php
lib/db/upgrade.php
lib/deprecatedlib.php
lib/editor/tinymce/module.js
lib/jquery/readme_moodle.txt
lib/moodlelib.php
lib/outputrenderers.php
lib/requirejs/readme_moodle.txt
lib/tests/moodlelib_test.php
lib/upgrade.txt
message/amd/build/message_area_profile.min.js
message/amd/src/message_area_profile.js
message/classes/api.php
message/classes/output/messagearea/messages.php
message/classes/privacy/provider.php
message/externallib.php
message/lib.php
message/pendingcontactrequests.php [new file with mode: 0644]
message/tests/api_test.php
message/tests/behat/manage_contacts.feature
message/tests/events_test.php
message/tests/externallib_test.php
message/tests/messagelib_test.php
message/tests/privacy_provider_test.php
message/upgrade.txt
mod/assign/locallib.php
mod/assign/submission/onlinetext/classes/privacy/provider.php
mod/assign/tests/locallib_test.php
mod/forum/lib.php
mod/forum/styles.css
mod/forum/tests/behat/posts_ordering_blog.feature
mod/forum/upgrade.txt
mod/imscp/lib.php
mod/imscp/tests/lib_test.php
mod/label/lib.php
mod/label/tests/lib_test.php
mod/lesson/lib.php
mod/lesson/tests/lib_test.php
mod/lti/classes/local/ltiservice/resource_base.php
mod/quiz/attemptlib.php
mod/quiz/locallib.php
mod/quiz/report/grading/tests/behat/grading.feature
mod/quiz/report/overview/tests/behat/basic.feature
mod/quiz/report/reportlib.php
mod/quiz/report/responses/tests/behat/basic.feature
mod/quiz/report/statistics/lang/en/quiz_statistics.php
mod/quiz/report/statistics/statistics_table.php
mod/quiz/report/statistics/tests/behat/basic.feature [new file with mode: 0644]
mod/quiz/report/statistics/tests/statistics_table_test.php [new file with mode: 0644]
mod/quiz/tests/behat/attempt_basic.feature
mod/quiz/tests/behat/behat_mod_quiz.php
mod/quiz/tests/behat/completion_condition_attempts_used.feature
mod/quiz/tests/behat/completion_condition_passing_grade.feature
mod/quiz/tests/behat/preview.feature
mod/quiz/tests/behat/quiz_reset.feature
mod/quiz/tests/generator/lib.php
mod/quiz/tests/reportlib_test.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
mod/workshop/form/numerrors/edit_form.php
mod/workshop/form/numerrors/lang/en/workshopform_numerrors.php
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
question/behaviour/interactive/behaviour.php
question/classes/statistics/questions/all_calculated_for_qubaid_condition.php
question/classes/statistics/questions/calculated_question_summary.php [new file with mode: 0644]
question/tests/calculated_question_summary_test.php [new file with mode: 0644]
question/tests/generator/lib.php
question/type/essay/question.php
question/type/missingtype/question.php
question/type/numerical/question.php
question/type/questionbase.php
question/type/shortanswer/question.php
question/type/truefalse/question.php
repository/dropbox/pix/icon.png
repository/dropbox/pix/icon.svg [new file with mode: 0644]
theme/boost/classes/output/core_renderer.php
version.php

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 e29174a..96c9e55 100644 (file)
@@ -80,9 +80,11 @@ class metadata_registry {
                 // Check if the interface is deprecated.
                 if (!$manager->is_empty_subsystem($component)) {
                     $classname = $manager->get_provider_classname_for_component($component);
-                    $componentclass = new $classname();
-                    if ($componentclass instanceof \core_privacy\local\deprecated) {
-                        $internaldata['deprecated'] = true;
+                    if (class_exists($classname)) {
+                        $componentclass = new $classname();
+                        if ($componentclass instanceof \core_privacy\local\deprecated) {
+                            $internaldata['deprecated'] = true;
+                        }
                     }
                 }
 
index 09bcb1c..341bc8a 100644 (file)
@@ -139,4 +139,15 @@ class renderer extends plugin_renderer_base {
         $data = $page->export_for_template($this);
         return parent::render_from_template('tool_dataprivacy/data_deletion', $data);
     }
+
+    /**
+     * Render the user data retention summary page.
+     *
+     * @param  summary_page $page
+     * @return string html for the page.
+     */
+    public function render_summary_page(summary_page $page) {
+        $data = $page->export_for_template($this);
+        return parent::render_from_template('tool_dataprivacy/summary', $data);
+    }
 }
diff --git a/admin/tool/dataprivacy/classes/output/summary_page.php b/admin/tool/dataprivacy/classes/output/summary_page.php
new file mode 100644 (file)
index 0000000..4b93b82
--- /dev/null
@@ -0,0 +1,132 @@
+<?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/>.
+
+/**
+ * Summary page renderable.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Adrian Greeve
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace tool_dataprivacy\output;
+defined('MOODLE_INTERNAL') || die();
+
+use renderable;
+use renderer_base;
+use templatable;
+
+
+/**
+ * Class containing the summary page renderable.
+ *
+ * @copyright  2018 Adrian Greeve
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class summary_page implements renderable, templatable {
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param renderer_base $output
+     * @return array
+     */
+    public function export_for_template(renderer_base $output) {
+        $contextlevels = [
+            'contextlevelname10' => CONTEXT_SYSTEM,
+            'contextlevelname30' => CONTEXT_USER,
+            'contextlevelname40' => CONTEXT_COURSECAT,
+            'contextlevelname50' => CONTEXT_COURSE,
+            'contextlevelname70' => CONTEXT_MODULE,
+            'contextlevelname80' => CONTEXT_BLOCK
+        ];
+
+        $data = [];
+        $context = \context_system::instance();
+
+        foreach ($contextlevels as $levelname => $level) {
+            $classname = \context_helper::get_class_for_level($level);
+            list($purposevar, $categoryvar) = \tool_dataprivacy\data_registry::var_names_from_context($classname);
+            $purposeid = get_config('tool_dataprivacy', $purposevar);
+            $categoryid = get_config('tool_dataprivacy', $categoryvar);
+
+            $section = [];
+            $section['contextname'] = get_string($levelname, 'tool_dataprivacy');
+
+            if (empty($purposeid)) {
+                list($purposeid, $categoryid) =
+                        \tool_dataprivacy\data_registry::get_effective_default_contextlevel_purpose_and_category($level);
+            }
+            if ($purposeid == -1) {
+                $purposeid = 0;
+            }
+            $purpose = new \tool_dataprivacy\purpose($purposeid);
+            $export = new \tool_dataprivacy\external\purpose_exporter($purpose, ['context' => $context]);
+            $purposedata = $export->export($output);
+            $section['purpose'] = $purposedata;
+
+            if (empty($categoryid)) {
+                list($purposeid, $categoryid) =
+                        \tool_dataprivacy\data_registry::get_effective_default_contextlevel_purpose_and_category($level);
+            }
+            if ($categoryid == -1) {
+                $categoryid = 0;
+            }
+            $category = new \tool_dataprivacy\category($categoryid);
+            $export = new \tool_dataprivacy\external\category_exporter($category, ['context' => $context]);
+            $categorydata = $export->export($output);
+            $section['category'] = $categorydata;
+            $data['contexts'][] = $section;
+        }
+
+        // Get activity module plugin info.
+        $pluginmanager = \core_plugin_manager::instance();
+        $modplugins = $pluginmanager->get_enabled_plugins('mod');
+
+        foreach ($modplugins as $name) {
+            $classname = \context_helper::get_class_for_level($contextlevels['contextlevelname70']);
+            list($purposevar, $categoryvar) = \tool_dataprivacy\data_registry::var_names_from_context($classname, $name);
+            $categoryid = get_config('tool_dataprivacy', $categoryvar);
+            $purposeid = get_config('tool_dataprivacy', $purposevar);
+            if ($categoryid === false && $purposeid === false) {
+                // If no purpose and category has been set for this plugin, then there's no need to show this on the list.
+                continue;
+            }
+
+            $section = [];
+            $section['contextname'] = $pluginmanager->plugin_name('mod_' . $name);
+
+            if ($purposeid == -1) {
+                $purposeid = 0;
+            }
+            $purpose = new \tool_dataprivacy\purpose($purposeid);
+            $export = new \tool_dataprivacy\external\purpose_exporter($purpose, ['context' => $context]);
+            $purposedata = $export->export($output);
+            $section['purpose'] = $purposedata;
+
+            if ($categoryid == -1) {
+                $categoryid = 0;
+            }
+            $category = new \tool_dataprivacy\category($categoryid);
+            $export = new \tool_dataprivacy\external\category_exporter($category, ['context' => $context]);
+            $categorydata = $export->export($output);
+            $section['category'] = $categorydata;
+
+            $data['contexts'][] = $section;
+        }
+
+        return $data;
+    }
+}
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 7b8aa51..0cbc55f 100644 (file)
@@ -76,6 +76,8 @@ $string['dataprivacy:downloadownrequest'] = 'Download your own exported data';
 $string['dataprivacy:downloadallrequests'] = 'Download exported data for everyone';
 $string['dataregistry'] = 'Data registry';
 $string['dataregistryinfo'] = 'The data registry enables categories (types of data) and purposes (the reasons for processing data) to be set for all content on the site - from users and courses down to activities and blocks. For each purpose, a retention period may be set. When a retention period has expired, the data is flagged and listed for deletion, awaiting admin confirmation.';
+$string['dataretentionexplanation'] = 'This summary shows the default categories and purposes for retaining user\'s information on this system. Certain areas of the system may have more specific categories and purposes than those listed here.';
+$string['dataretentionsummary'] = 'Data retention summary';
 $string['datarequestcreatedforuser'] = 'Data request created for {$a}';
 $string['datarequestemailsubject'] = 'Data request: {$a}';
 $string['datarequests'] = 'Data requests';
@@ -246,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.';
@@ -276,6 +287,7 @@ $string['statuspending'] = 'Pending';
 $string['statusrejected'] = 'Rejected';
 $string['subjectscope'] = 'Subject scope';
 $string['subjectscope_help'] = 'The subject scope lists the roles which may be assigned in this context.';
+$string['summary'] = 'Registry configuration summary';
 $string['user'] = 'User';
 $string['viewrequest'] = 'View the request';
 $string['visible'] = 'Expand all';
index fbeb61d..a9712d1 100644 (file)
@@ -65,6 +65,11 @@ function tool_dataprivacy_myprofile_navigation(tree $tree, $user, $iscurrentuser
         $category->add_node($node);
     }
 
+    $summaryurl = new moodle_url('/admin/tool/dataprivacy/summary.php');
+    $summarynode = new core_user\output\myprofile\node('privacyandpolicies', 'retentionsummary',
+            get_string('dataretentionsummary', 'tool_dataprivacy'), null, $summaryurl);
+    $category->add_node($summarynode);
+
     // Add the Privacy category to the tree if it's not empty and it doesn't exist.
     $nodes = $category->nodes;
     if (!empty($nodes)) {
@@ -77,6 +82,20 @@ function tool_dataprivacy_myprofile_navigation(tree $tree, $user, $iscurrentuser
     return false;
 }
 
+/**
+ * Callback to add footer elements.
+ *
+ * @return string HTML footer content
+ */
+function tool_dataprivacy_standard_footer_html() {
+
+    $url = new moodle_url('/admin/tool/dataprivacy/summary.php');
+    $output = html_writer::link($url, get_string('dataretentionsummary', 'tool_dataprivacy'));
+    $output = html_writer::div($output, 'summaryfooter');
+
+    return $output;
+}
+
 /**
  * Fragment to add a new purpose.
  *
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));
     }
 }
 
diff --git a/admin/tool/dataprivacy/summary.php b/admin/tool/dataprivacy/summary.php
new file mode 100644 (file)
index 0000000..3b99ee3
--- /dev/null
@@ -0,0 +1,41 @@
+<?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/>.
+
+/**
+ * Prints the compliance data registry main page.
+ *
+ * @copyright 2018 onwards Adrian Greeve <adriangreeve.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
+ * @package tool_dataprivacy
+ */
+
+require_once(__DIR__ . '/../../../config.php');
+require_once($CFG->dirroot . '/' . $CFG->admin . '/tool/dataprivacy/lib.php');
+
+$url = new moodle_url('/' . $CFG->admin . '/tool/dataprivacy/summary.php');
+$title = get_string('summary', 'tool_dataprivacy');
+
+$context = \context_system::instance();
+$PAGE->set_url($url);
+$PAGE->set_context($context);
+$PAGE->set_title($title);
+$PAGE->set_heading($SITE->fullname);
+
+$output = $PAGE->get_renderer('tool_dataprivacy');
+echo $output->header();
+$summarypage = new \tool_dataprivacy\output\summary_page();
+echo $output->render($summarypage);
+echo $output->footer();
diff --git a/admin/tool/dataprivacy/templates/summary.mustache b/admin/tool/dataprivacy/templates/summary.mustache
new file mode 100644 (file)
index 0000000..22c0221
--- /dev/null
@@ -0,0 +1,123 @@
+{{!
+    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 tool_dataprivacy/summary
+
+    Summary
+
+    Classes required for JS:
+
+    Data attributes required for JS:
+
+    Context variables required for this template:
+
+    Example context (json):
+    {
+
+        "contexts": [
+            {
+                "contextname": "Site",
+                "category":
+                {
+                    "name": "Test category",
+                    "description": "<p>Description for category</p>"
+                },
+                "purpose":
+                {
+                    "name": "Test purpose",
+                    "description": "<p>Description for purpose</p>",
+                    "lawfulbases": "gdpr_art_6_1_c",
+                    "sensitivedatareasons": "gdpr_art_9_2_f",
+                    "formattedlawfulbases": [
+                        {
+                            "name": "Lawful base 1(a)",
+                            "description": "We need your information"
+                        },
+                        {
+                            "name": "Lawful base 1(b)",
+                            "description": "We really do need your information"
+                        }
+                    ],
+                    "formattedsensitivedatareasons": [
+                        {
+                            "name": "Sensitive data reason number 1",
+                            "description": "Number 1"
+                        },
+                        {
+                            "name": "Sensitive data reason number 1",
+                            "description": "Number 2"
+                        }
+                    ],
+                    "formattedretentionperiod": "10 Years"
+                }
+            }
+        ]
+    }
+}}
+<h2>{{#str}}dataretentionsummary, tool_dataprivacy{{/str}}</h2>
+<p>{{#str}}dataretentionexplanation, tool_dataprivacy{{/str}}</p>
+<div>
+    {{#contexts}}
+        <div class="card mb-3">
+            <div class="card-header"><h3>{{contextname}}</h3></div>
+            <div class="card-body p-l-2 p-r-2">
+
+                {{#category.name}}
+                <h4>{{#str}}category, tool_dataprivacy{{/str}}</h4>
+                <dl>
+                    <dt>{{category.name}}</dt>
+                    <dd>{{{category.description}}}</dd>
+                </dl>
+                <hr />
+                {{/category.name}}
+                <h4>{{#str}}purpose, tool_dataprivacy{{/str}}</h4>
+                <dl>
+                    <dt>{{purpose.name}}</dt>
+                    <dd>{{{purpose.description}}}</dd>
+                    <dt>{{#str}}retentionperiod, tool_dataprivacy{{/str}}</dt>
+                    <dd>{{purpose.formattedretentionperiod}}</dd>
+                </dl>
+                {{#purpose.lawfulbases}}
+                <table class="table table-bordered">
+                    <thead><tr><th colspan="2">{{#str}}lawfulbases, tool_dataprivacy{{/str}}</th></tr></thead>
+                    <tbody>
+                    {{#purpose.formattedlawfulbases}}
+                        <tr>
+                            <td>{{name}}</td>
+                            <td>{{description}}</td>
+                        </tr>
+                    {{/purpose.formattedlawfulbases}}
+                    </tbody>
+                </table>
+                {{/purpose.lawfulbases}}
+                {{#purpose.sensitivedatareasons}}
+                <table class="table table-bordered">
+                    <thead><tr><th colspan="2">{{#str}}sensitivedatareasons, tool_dataprivacy{{/str}}</th></tr></thead>
+                    <tbody>
+                    {{#purpose.formattedsensitivedatareasons}}
+                        <tr>
+                            <td>{{name}}</td>
+                            <td>{{description}}</td>
+                        </tr>
+                    {{/purpose.formattedsensitivedatareasons}}
+                    </tbody>
+                </table>
+                {{/purpose.sensitivedatareasons}}
+            </div>
+        </div>
+    {{/contexts}}
+</div>
\ No newline at end of file
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 9ec9c32..973c7d7 100644 (file)
@@ -85,7 +85,11 @@ class login implements renderable, templatable {
         $this->canloginasguest = $CFG->guestloginbutton and !isguestuser();
         $this->canloginbyemail = !empty($CFG->authloginviaemail);
         $this->cansignup = $CFG->registerauth == 'email' || !empty($CFG->registerauth);
-        $this->cookieshelpicon = new help_icon('cookiesenabled', 'core');
+        if ($CFG->rememberusername == 0) {
+            $this->cookieshelpicon = new help_icon('cookiesenabledonlysession', 'core');
+        } else {
+            $this->cookieshelpicon = new help_icon('cookiesenabled', 'core');
+        }
 
         $this->autofocusform = !empty($CFG->loginpageautofocus);
         $this->rememberusername = isset($CFG->rememberusername) and $CFG->rememberusername == 2;
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>
index 6aaf269..22c04ec 100644 (file)
@@ -64,7 +64,7 @@ Feature: The timeline block allows users to see upcoming activities
     And I should see "Test feedback 3 closes" in the "Timeline" "block"
     And I should not see "Test choice 2 closes" in the "Timeline" "block"
     And I should not see "Test feedback 2 closes" in the "Timeline" "block"
-    And I click on "[data-region='paging-bar'] [data-control='next']" "css_element" in the "Timeline" "block"
+    And I click on "[data-region='paging-bar'] [data-control='next'] [data-region='page-link']" "css_element" in the "Timeline" "block"
     And I should see "Test feedback 2 closes" in the "Timeline" "block"
     And I should not see "Test assign 1 is due" in the "Timeline" "block"
     And I should not see "Test feedback 1 closes" in the "Timeline" "block"
diff --git a/blog/classes/external.php b/blog/classes/external.php
new file mode 100644 (file)
index 0000000..9333d2b
--- /dev/null
@@ -0,0 +1,180 @@
+<?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 {
+
+    /**
+     * 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 $CFG, $DB, $PAGE;
+
+        $warnings = array();
+        $params = self::validate_parameters(self::get_entries_parameters(),
+            array('filters' => $filters, 'page' => $page, 'perpage' => $perpage));
+
+        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 ($params['filters'] 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);
+            }
+        }
+        $PAGE->set_context($context); // Needed by internal APIs.
+
+        // 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();
+        $output = $PAGE->get_renderer('core');
+        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(),
+            )
+        );
+    }
+}
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);
-
-    if (!has_capability('moodle/blog:view', $sitecontext)) {
-        print_error(get_string('cannotviewcourseorgroupblog', 'blog'));
-    }
-
-    if (groups_get_course_groupmode($course) == SEPARATEGROUPS && !has_capability('moodle/site:accessallgroups', $coursecontext)) {
-        if (!groups_is_member($groupid)) {
-            print_error('notmemberofgroup');
-        }
-    }
 }
 
 if (!empty($userid)) {
-    if ($CFG->bloglevel < BLOG_USER_LEVEL) {
-        print_error('blogdisable', 'blog');
-    }
-
-    if (!$user = $DB->get_record('user', array('id' => $userid))) {
-        print_error('invaliduserid');
-    }
-
-    if ($user->deleted) {
-        echo $OUTPUT->header();
-        echo $OUTPUT->heading(get_string('userdeleted'));
-        echo $OUTPUT->footer();
-        die;
-    }
-
-    if ($USER->id == $userid) {
-        if (!has_capability('moodle/blog:create', $sitecontext)
-          && !has_capability('moodle/blog:view', $sitecontext)) {
-            print_error('donothaveblog', 'blog');
-        }
-    } else {
-        if (!has_capability('moodle/blog:view', $sitecontext) || !blog_user_can_view_user_entry($userid)) {
-            print_error('cannotviewcourseblog', 'blog');
-        }
-
-        $PAGE->navigation->extend_for_user($user);
-    }
+    $user = core_user::get_user($userid);
+    $PAGE->navigation->extend_for_user($user);
 }
 
-$courseid = (empty($courseid)) ? SITEID : $courseid;
-
-
 $blogheaders = blog_get_headers();
 
 $rsscontext = null;
index b81c645..f575ff7 100644 (file)
@@ -628,20 +628,29 @@ function blog_get_options_for_module($module, $user=null) {
  * It uses the current URL to build these variables.
  * A number of mutually exclusive use cases are used to structure this function.
  *
+ * @param  int $courseid   course id the the blog is associated to (can be null).
+ * @param  int $groupid    group id to filter blogs I can see (can be null)
+ * @param  int $userid     blog author id (can be null)
+ * @param  int $tagid      tag id to filter (can be null)
+ * @param  string $tag     tag name to filter (can be null)
+ * @param  int $modid      module id the blog is associated to (can be null).
+ * @param  int $entryid    blog entry id to filter(can be null)
+ * @param  string $search  string to search (can be null)
  * @return array
  */
-function blog_get_headers($courseid=null, $groupid=null, $userid=null, $tagid=null) {
+function blog_get_headers($courseid=null, $groupid=null, $userid=null, $tagid=null, $tag=null, $modid=null, $entryid=null,
+        $search = null) {
     global $CFG, $PAGE, $DB, $USER;
 
     $id       = optional_param('id', null, PARAM_INT);
-    $tag      = optional_param('tag', null, PARAM_NOTAGS);
+    $tag      = optional_param('tag', $tag, PARAM_NOTAGS);
     $tagid    = optional_param('tagid', $tagid, PARAM_INT);
     $userid   = optional_param('userid', $userid, PARAM_INT);
-    $modid    = optional_param('modid', null, PARAM_INT);
-    $entryid  = optional_param('entryid', null, PARAM_INT);
+    $modid    = optional_param('modid', $modid, PARAM_INT);
+    $entryid  = optional_param('entryid', $entryid, PARAM_INT);
     $groupid  = optional_param('groupid', $groupid, PARAM_INT);
     $courseid = optional_param('courseid', $courseid, PARAM_INT);
-    $search   = optional_param('search', null, PARAM_RAW);
+    $search   = optional_param('search', $search, PARAM_RAW);
     $action   = optional_param('action', null, PARAM_ALPHA);
     $confirm  = optional_param('confirm', false, PARAM_BOOL);
 
@@ -1166,3 +1175,131 @@ function blog_get_tagged_posts($tag, $exclusivemode = false, $fromctx = 0, $ctx
     $rv->exclusiveurl = null;
     return $rv;
 }
+
+/**
+ * Validate the access to a blog.
+ *
+ * @param  int $courseid course id the the blog is associated to (can be null).
+ * @param  int $modid    module id the blog is associated to (can be null).
+ * @param  int $groupid  group id to filter blogs I can see (can be null)
+ * @param  int $entryid  blog entry id (can be null)
+ * @param  int $userid   blog author id (can be null)
+ * @return array with the calculated course and id
+ * @since  Moodle 3.6
+ */
+function blog_validate_access($courseid, $modid, $groupid, $entryid, $userid) {
+    global $CFG, $DB, $USER, $COURSE;
+
+    $sitecontext = context_system::instance();
+
+    // 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;
+
+        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'));
+        }
+
+        if (!$course = $DB->get_record('course', array('id' => $group->courseid))) {
+            print_error('invalidcourseid');
+        }
+
+        $coursecontext = context_course::instance($course->id);
+        $courseid = $course->id;
+
+        if (!has_capability('moodle/blog:view', $sitecontext)) {
+            print_error(get_string('cannotviewcourseorgroupblog', 'blog'));
+        }
+
+        if (groups_get_course_groupmode($course) == SEPARATEGROUPS &&
+                !has_capability('moodle/site:accessallgroups', $coursecontext)) {
+
+            if (!groups_is_member($groupid)) {
+                print_error('notmemberofgroup');
+            }
+        }
+    }
+
+    if (!empty($userid)) {
+        if ($CFG->bloglevel < BLOG_USER_LEVEL) {
+            print_error('blogdisable', 'blog');
+        }
+
+        if (!$user = $DB->get_record('user', array('id' => $userid))) {
+            print_error('invaliduserid');
+        }
+
+        if ($user->deleted) {
+            print_error('userdeleted');
+        }
+
+        if ($USER->id == $userid) {
+            if (!has_capability('moodle/blog:create', $sitecontext)
+              && !has_capability('moodle/blog:view', $sitecontext)) {
+                print_error('donothaveblog', 'blog');
+            }
+        } else {
+            if (!has_capability('moodle/blog:view', $sitecontext) || !blog_user_can_view_user_entry($userid)) {
+                print_error('cannotviewcourseblog', 'blog');
+            }
+        }
+    }
+    return array($courseid, $userid);
+}
diff --git a/blog/tests/external_test.php b/blog/tests/external_test.php
new file mode 100644 (file)
index 0000000..016865c
--- /dev/null
@@ -0,0 +1,565 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for blog external API.
+ *
+ * @package    core_blog
+ * @copyright  2018 Juan Leyva
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/blog/locallib.php');
+require_once($CFG->dirroot . '/blog/lib.php');
+
+/**
+ * Unit tests for blog external API.
+ *
+ * @package    core_blog
+ * @copyright  2018 Juan Leyva
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_blog_external_testcase extends advanced_testcase {
+
+    private $courseid;
+    private $cmid;
+    private $userid;
+    private $groupid;
+    private $tagid;
+    private $postid;
+
+    protected function setUp() {
+        global $DB, $CFG;
+        parent::setUp();
+
+        $this->resetAfterTest();
+
+        // Create default course.
+        $course = $this->getDataGenerator()->create_course(array('category' => 1, 'shortname' => 'ANON'));
+        $this->assertNotEmpty($course);
+        $page = $this->getDataGenerator()->create_module('page', array('course' => $course->id));
+        $this->assertNotEmpty($page);
+
+        // Create default user.
+        $user = $this->getDataGenerator()->create_user(array(
+                'username' => 'testuser',
+                'firstname' => 'Jimmy',
+                'lastname' => 'Kinnon'
+        ));
+        // Enrol user.
+        $this->getDataGenerator()->enrol_user($user->id, $course->id);
+
+        $group = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group->id, 'userid' => $user->id));
+
+        // Create default post.
+        $post = new stdClass();
+        $post->userid = $user->id;
+        $post->courseid = $course->id;
+        $post->groupid = $group->id;
+        $post->content = 'test post content text';
+        $post->module = 'blog';
+        $post->id = $DB->insert_record('post', $post);
+
+        core_tag_tag::set_item_tags('core', 'post', $post->id, context_user::instance($user->id), array('tag1'));
+        $tagid = $DB->get_field('tag', 'id', array('name' => 'tag1'));
+
+        // Grab important ids.
+        $this->courseid = $course->id;
+        $this->cmid = $page->cmid;
+        $this->userid  = $user->id;
+        $this->groupid  = $group->id;
+        $this->tagid  = $tagid;
+        $this->postid = $post->id;
+        $this->publishstate = 'site';   // To be override in tests.
+
+        // Set default blog level.
+        $CFG->bloglevel = BLOG_SITE_LEVEL;
+    }
+
+    /**
+     * Get global public entries even for not authenticated users.
+     * We get the entry since is public.
+     */
+    public function test_get_public_entries_global_level_by_non_logged_users() {
+        global $CFG, $DB;
+
+        $CFG->bloglevel = BLOG_GLOBAL_LEVEL;
+        $CFG->forcelogin = 0;
+        // Set current entry global.
+        $DB->set_field('post', 'publishstate', 'public', array('id' => $this->postid));
+
+        $result = core_blog\external::get_entries();
+        $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
+        $this->assertCount(1, $result['entries']);
+        $this->assertEquals($this->postid, $result['entries'][0]['id']);
+    }
+
+    /**
+     * Get global public entries even for not authenticated users in closed site.
+     */
+    public function test_get_public_entries_global_level_by_non_logged_users_closed_site() {
+        global $CFG, $DB;
+
+        $CFG->bloglevel = BLOG_GLOBAL_LEVEL;
+        $CFG->forcelogin = 1;
+        // Set current entry global.
+        $DB->set_field('post', 'publishstate', 'public', array('id' => $this->postid));
+
+        $this->expectException('moodle_exception');
+        core_blog\external::get_entries();
+    }
+
+    /**
+     * Get global public entries for guest users.
+     * We get the entry since is public.
+     */
+    public function test_get_public_entries_global_level_by_guest_users() {
+        global $CFG, $DB;
+
+        $CFG->bloglevel = BLOG_GLOBAL_LEVEL;
+        // Set current entry global.
+        $DB->set_field('post', 'publishstate', 'public', array('id' => $this->postid));
+
+        $this->setGuestUser();
+        $result = core_blog\external::get_entries();
+        $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
+        $this->assertCount(1, $result['entries']);
+        $this->assertEquals($this->postid, $result['entries'][0]['id']);
+    }
+
+    /**
+     * Get global not public entries even for not authenticated users withouth being authenticated.
+     * We don't get any because they are not public (restricted to site users).
+     */
+    public function test_get_not_public_entries_global_level_by_non_logged_users() {
+        global $CFG;
+
+        $CFG->bloglevel = BLOG_GLOBAL_LEVEL;
+
+        $result = core_blog\external::get_entries();
+        $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
+        $this->assertCount(0, $result['entries']);
+    }
+
+    /**
+     * Get global not public entries users being guest.
+     * We don't get any because they are not public (restricted to real site users).
+     */
+    public function test_get_not_public_entries_global_level_by_guest_user() {
+        global $CFG;
+
+        $CFG->bloglevel = BLOG_GLOBAL_LEVEL;
+
+        $this->setGuestUser();
+        $result = core_blog\external::get_entries();
+        $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
+        $this->assertCount(0, $result['entries']);
+    }
+
+    /**
+     * Get site not public entries for not authenticated users.
+     * We don't get any because they are not public (restricted to real site users).
+     */
+    public function test_get_not_public_entries_site_level_by_non_logged_users() {
+        $this->expectException('require_login_exception'); // In this case we get a security exception.
+        $result = core_blog\external::get_entries();
+    }
+
+    /**
+     * Get site not public entries for guest users.
+     * We don't get any because they are not public (restricted to real site users).
+     */
+    public function test_get_not_public_entries_site_level_by_guest_users() {
+
+        $this->setGuestUser();
+        $result = core_blog\external::get_entries();
+        $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
+        $this->assertCount(0, $result['entries']);
+    }
+
+    /**
+     * Get site entries at site level by system users.
+     */
+    public function test_get_site_entries_site_level_by_normal_users() {
+
+        $this->setUser($this->userid);
+        $result = core_blog\external::get_entries();
+        $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
+        $this->assertCount(1, $result['entries']);
+        $this->assertEquals($this->postid, $result['entries'][0]['id']);
+    }
+
+    /**
+     * Get draft site entries by authors.
+     */
+    public function test_get_draft_entries_site_level_by_author_users() {
+        global $DB;
+
+        // Set current entry global.
+        $DB->set_field('post', 'publishstate', 'draft', array('id' => $this->postid));
+
+        $this->setUser($this->userid);
+        $result = core_blog\external::get_entries();
+        $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
+        $this->assertCount(1, $result['entries']);
+        $this->assertEquals($this->postid, $result['entries'][0]['id']);
+    }
+
+    /**
+     * Get draft site entries by not authors.
+     */
+    public function test_get_draft_entries_site_level_by_not_author_users() {
+        global $DB;
+
+        // Set current entry global.
+        $DB->set_field('post', 'publishstate', 'draft', array('id' => $this->postid));
+        $user = $this->getDataGenerator()->create_user();
+
+        $this->setUser($user);
+        $result = core_blog\external::get_entries();
+        $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
+        $this->assertCount(0, $result['entries']);
+    }
+
+    /**
+     * Get draft site entries by admin.
+     */
+    public function test_get_draft_entries_site_level_by_admin_users() {
+        global $DB;
+
+        // Set current entry global.
+        $DB->set_field('post', 'publishstate', 'draft', array('id' => $this->postid));
+        $user = $this->getDataGenerator()->create_user();
+
+        $this->setAdminUser();
+        $result = core_blog\external::get_entries();
+        $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
+        $this->assertCount(1, $result['entries']);
+        $this->assertEquals($this->postid, $result['entries'][0]['id']);
+    }
+
+    /**
+     * Get draft user entries by authors.
+     */
+    public function test_get_draft_entries_user_level_by_author_users() {
+        global $CFG, $DB;
+
+        $CFG->bloglevel = BLOG_USER_LEVEL;
+        // Set current entry global.
+        $DB->set_field('post', 'publishstate', 'draft', array('id' => $this->postid));
+
+        $this->setUser($this->userid);
+        $result = core_blog\external::get_entries();
+        $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
+        $this->assertCount(1, $result['entries']);
+        $this->assertEquals($this->postid, $result['entries'][0]['id']);
+    }
+
+    /**
+     * Get draft user entries by not authors.
+     */
+    public function test_get_draft_entries_user_level_by_not_author_users() {
+        global $CFG, $DB;
+
+        $CFG->bloglevel = BLOG_USER_LEVEL;
+        // Set current entry global.
+        $DB->set_field('post', 'publishstate', 'draft', array('id' => $this->postid));
+        $user = $this->getDataGenerator()->create_user();
+
+        $this->setUser($user);
+        $result = core_blog\external::get_entries();
+        $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
+        $this->assertCount(0, $result['entries']);
+    }
+
+    /**
+     * Get draft user entries by admin.
+     */
+    public function test_get_draft_entries_user_level_by_admin_users() {
+        global $CFG, $DB;
+
+        $CFG->bloglevel = BLOG_USER_LEVEL;
+        // Set current entry global.
+        $DB->set_field('post', 'publishstate', 'draft', array('id' => $this->postid));
+        $user = $this->getDataGenerator()->create_user();
+
+        $this->setAdminUser();
+        $result = core_blog\external::get_entries();
+        $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
+        $this->assertCount(1, $result['entries']);
+        $this->assertEquals($this->postid, $result['entries'][0]['id']);
+    }
+
+    /**
+     * Test get all entries including testing pagination.
+     */
+    public function test_get_all_entries_including_pagination() {
+        global $DB, $USER;
+
+        $DB->set_field('post', 'publishstate', 'site', array('id' => $this->postid));
+
+        // Create another entry.
+        $this->setAdminUser();
+        $newpost = new stdClass();
+        $newpost->userid = $USER->id;
+        $newpost->content = 'test post content text';
+        $newpost->module = 'blog';
+        $newpost->publishstate = 'site';
+        $newpost->created = time() + HOURSECS;
+        $newpost->lastmodified = time() + HOURSECS;
+        $newpost->id = $DB->insert_record('post', $newpost);
+
+        $this->setUser($this->userid);
+        $result = core_blog\external::get_entries();
+        $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
+        $this->assertCount(2, $result['entries']);
+        $this->assertEquals(2, $result['totalentries']);
+
+        $result = core_blog\external::get_entries(array(), 0, 1);
+        $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
+        $this->assertCount(1, $result['entries']);
+        $this->assertEquals(2, $result['totalentries']);
+        $this->assertEquals($newpost->id, $result['entries'][0]['id']);
+
+        $result = core_blog\external::get_entries(array(), 1, 1);
+        $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
+