Merge branch 'MDL-63919-master' of git://github.com/mihailges/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Fri, 9 Nov 2018 02:51:48 +0000 (10:51 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Fri, 9 Nov 2018 02:51:48 +0000 (10:51 +0800)
86 files changed:
admin/settings/courses.php
admin/tool/cohortroles/classes/privacy/provider.php
admin/tool/cohortroles/tests/privacy_test.php
admin/tool/dataprivacy/classes/api.php
admin/tool/dataprivacy/classes/data_request.php
admin/tool/dataprivacy/classes/event/user_deleted_observer.php [new file with mode: 0644]
admin/tool/dataprivacy/classes/expired_contexts_manager.php
admin/tool/dataprivacy/classes/expired_user_contexts.php [new file with mode: 0644]
admin/tool/dataprivacy/classes/local/helper.php
admin/tool/dataprivacy/classes/output/data_requests_table.php
admin/tool/dataprivacy/classes/privacy/provider.php
admin/tool/dataprivacy/classes/task/delete_existing_deleted_users.php
admin/tool/dataprivacy/datarequests.php
admin/tool/dataprivacy/db/events.php [new file with mode: 0644]
admin/tool/dataprivacy/db/upgrade.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/dataprivacy/mydatarequests.php
admin/tool/dataprivacy/resubmitrequest.php [new file with mode: 0644]
admin/tool/dataprivacy/settings.php
admin/tool/dataprivacy/tests/api_test.php
admin/tool/dataprivacy/tests/data_request_test.php [new file with mode: 0644]
admin/tool/dataprivacy/tests/expired_contexts_test.php
admin/tool/dataprivacy/tests/task_test.php
admin/tool/dataprivacy/tests/user_deleted_observer_test.php [new file with mode: 0644]
admin/tool/mobile/classes/api.php
admin/tool/mobile/tests/externallib_test.php
auth/classes/output/login.php
auth/ldap/auth.php
auth/shibboleth/index.php
auth/shibboleth/index_form.html
auth/shibboleth/login.php
auth/upgrade.txt
blocks/login/block_login.php
blocks/starredcourses/db/services.php
config-dist.php
lang/en/moodle.php
lib/classes/session/manager.php
lib/db/services.php
lib/db/upgrade.php
lib/lessphp/Visitor/toCSS.php
lib/lessphp/moodle_readme.txt
lib/moodlelib.php
lib/outputrenderers.php
lib/templates/course_header_image.mustache [deleted file]
lib/templates/loginform.mustache
lib/testing/generator/data_generator.php
lib/tests/authlib_test.php
login/change_password_form.php
login/index.php
login/token.php
message/classes/api.php
message/classes/helper.php
message/externallib.php
message/index.php
message/output/popup/amd/build/notification_popover_controller.min.js
message/output/popup/amd/src/notification_popover_controller.js
message/output/popup/tests/behat/message_popover_preferences.feature
message/output/popup/tests/behat/message_popover_unread.feature
message/output/popup/tests/behat/notification_popover_preferences.feature
message/output/popup/tests/behat/notification_popover_unread.feature
message/tests/api_test.php
message/tests/behat/delete_all_messages.feature
message/tests/behat/delete_messages.feature
message/tests/behat/manage_contacts.feature
message/tests/behat/reply_message.feature
message/tests/behat/search_messages.feature
message/tests/behat/search_users.feature
message/tests/behat/update_messaging_preferences.feature
message/tests/behat/view_messages.feature
message/tests/externallib_test.php
message/upgrade.txt
mod/assign/renderer.php
question/type/ddmarker/styles.css
search/engine/solr/classes/engine.php
theme/boost/scss/moodle/blocks.scss
theme/boost/scss/moodle/course.scss
theme/boost/style/moodle.css
theme/boost/templates/core/loginform.mustache
theme/boost/templates/core_form/element-date_time_selector-inline.mustache
theme/bootstrapbase/less/moodle/blocks.less
theme/bootstrapbase/less/moodle/course.less
theme/bootstrapbase/style/moodle.css
user/tests/behat/delete_users.feature
user/tests/behat/view_full_profile.feature
version.php
webservice/externallib.php

index 40063a6..f23bf5f 100644 (file)
@@ -121,9 +121,6 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
     $temp->add(new admin_setting_configselect('moodlecourse/showreports', new lang_string('showreports'), '', 0,
         array(0 => new lang_string('no'), 1 => new lang_string('yes'))));
 
-    $temp->add(new admin_setting_configcheckbox('moodlecourse/showcourseimages', get_string('showcourseimages'),
-        get_string('showcourseimages_desc'), 1));
-
     // Files and uploads.
     $temp->add(new admin_setting_heading('filesanduploadshdr', new lang_string('filesanduploads'), ''));
 
index 2cdfca6..3cd324e 100644 (file)
@@ -80,15 +80,25 @@ class provider implements
     public static function get_contexts_for_userid(int $userid) : contextlist {
         $contextlist = new contextlist();
 
-        // Retrieve the User context associated with tool_cohortroles records.
-        $sql = "SELECT DISTINCT c.id
-                  FROM {context} c
-                  JOIN {tool_cohortroles} cr ON cr.userid = c.instanceid AND c.contextlevel = :contextuser
-                 WHERE cr.userid = :userid";
+        // When we process user deletions and expiries, we always delete from the user context.
+        // As a result the cohort role assignments would be deleted, which has a knock-on effect with courses
+        // as roles may change and data may be removed earlier than it should be.
+
+        // Retrieve the context associated with tool_cohortroles records.
+        $sql = "SELECT DISTINCT c.contextid
+                  FROM {tool_cohortroles} tc
+                  JOIN {cohort} c
+                       ON tc.cohortid = c.id
+                  JOIN {context} ctx
+                       ON ctx.id = c.contextid
+                 WHERE tc.userid = :userid
+                       AND (ctx.contextlevel = :contextlevel1
+                           OR ctx.contextlevel = :contextlevel2)";
 
         $params = [
-            'contextuser' => CONTEXT_USER,
-            'userid'       => $userid
+            'userid'        => $userid,
+            'contextlevel1' => CONTEXT_SYSTEM,
+            'contextlevel2' => CONTEXT_COURSECAT
         ];
 
         $contextlist->add_from_sql($sql, $params);
@@ -104,24 +114,29 @@ class provider implements
     public static function get_users_in_context(userlist $userlist) {
         $context = $userlist->get_context();
 
-        // We should process user data from the system context.
         // When we process user deletions and expiries, we always delete from the user context.
         // As a result the cohort role assignments would be deleted, which has a knock-on effect with courses
         // as roles may change and data may be removed earlier than it should be.
-        if (!$context instanceof \context_system) {
-            return;
-        }
 
-        $params = [
-            'contextid' => $context->id
+        $allowedcontextlevels = [
+            CONTEXT_SYSTEM,
+            CONTEXT_COURSECAT
         ];
 
+        if (!in_array($context->contextlevel, $allowedcontextlevels)) {
+            return;
+        }
+
         $sql = "SELECT tc.userid as userid
                   FROM {tool_cohortroles} tc
                   JOIN {cohort} c
                        ON tc.cohortid = c.id
                  WHERE c.contextid = :contextid";
 
+        $params = [
+            'contextid' => $context->id
+        ];
+
         $userlist->add_from_sql('userid', $sql, $params);
     }
 
@@ -133,24 +148,28 @@ class provider implements
     public static function export_user_data(approved_contextlist $contextlist) {
         global $DB;
 
-        // If the user has tool_cohortroles data, then only the User context should be present so get the first context.
-        $contexts = $contextlist->get_contexts();
-        if (count($contexts) == 0) {
-            return;
-        }
-        $context = reset($contexts);
+        // Remove contexts different from SYSTEM or COURSECAT.
+        $contextids = array_reduce($contextlist->get_contexts(), function($carry, $context) {
+            if ($context->contextlevel == CONTEXT_SYSTEM || $context->contextlevel == CONTEXT_COURSECAT) {
+                $carry[] = $context->id;
+            }
+            return $carry;
+        }, []);
 
-        // Sanity check that context is at the User context level, then get the userid.
-        if ($context->contextlevel !== CONTEXT_USER) {
+        if (empty($contextids)) {
             return;
         }
-        $userid = $context->instanceid;
+
+        $userid = $contextlist->get_user()->id;
+
+        list($contextsql, $contextparams) = $DB->get_in_or_equal($contextids, SQL_PARAMS_NAMED);
 
         // Retrieve the tool_cohortroles records created for the user.
-        $sql = 'SELECT cr.id as cohortroleid,
+        $sql = "SELECT cr.id as cohortroleid,
                        c.name as cohortname,
                        c.idnumber as cohortidnumber,
                        c.description as cohortdescription,
+                       c.contextid as contextid,
                        r.shortname as roleshortname,
                        cr.userid as userid,
                        cr.timecreated as timecreated,
@@ -158,13 +177,13 @@ class provider implements
                   FROM {tool_cohortroles} cr
                   JOIN {cohort} c ON c.id = cr.cohortid
                   JOIN {role} r ON r.id = cr.roleid
-                 WHERE cr.userid = :userid';
+                 WHERE cr.userid = :userid
+                       AND c.contextid {$contextsql}";
 
-        $params = [
-            'userid' => $userid
-        ];
+        $params = ['userid' => $userid] + $contextparams;
 
         $cohortroles = $DB->get_records_sql($sql, $params);
+
         foreach ($cohortroles as $cohortrole) {
             // The tool_cohortroles data export is organised in:
             // {User Context}/Cohort roles management/{cohort name}/{role shortname}/data.json.
@@ -184,6 +203,8 @@ class provider implements
                 'timemodified' => transform::datetime($cohortrole->timemodified)
             ];
 
+            $context = \context::instance_by_id($cohortrole->contextid);
+
             writer::with_context($context)->export_data($subcontext, $data);
         }
     }
@@ -196,14 +217,24 @@ class provider implements
     public static function delete_data_for_all_users_in_context(\context $context) {
         global $DB;
 
-        // Sanity check that context is at the User context level, then get the userid.
-        if ($context->contextlevel !== CONTEXT_USER) {
+        // When we process user deletions and expiries, we always delete from the user context.
+        // As a result the cohort role assignments would be deleted, which has a knock-on effect with courses
+        // as roles may change and data may be removed earlier than it should be.
+
+        $allowedcontextlevels = [
+            CONTEXT_SYSTEM,
+            CONTEXT_COURSECAT
+        ];
+
+        if (!in_array($context->contextlevel, $allowedcontextlevels)) {
             return;
         }
-        $userid = $context->instanceid;
 
-        // Delete the tool_cohortroles records created for the userid.
-        $DB->delete_records('tool_cohortroles', ['userid' => $userid]);
+        $cohortids = $DB->get_fieldset_select('cohort', 'id', 'contextid = :contextid',
+            ['contextid' => $context->id]);
+
+        // Delete the tool_cohortroles records created in the specific context.
+        $DB->delete_records_list('tool_cohortroles', 'cohortid', $cohortids);
     }
 
     /**
@@ -214,15 +245,42 @@ class provider implements
     public static function delete_data_for_users(approved_userlist $userlist) {
         global $DB;
 
-        $context = $userlist->get_context();
-
-        // We should process user data from the system context.
         // When we process user deletions and expiries, we always delete from the user context.
         // As a result the cohort role assignments would be deleted, which has a knock-on effect with courses
         // as roles may change and data may be removed earlier than it should be.
-        if ($context instanceof \context_system) {
-            $DB->delete_records_list('tool_cohortroles', 'userid', $userlist->get_userids());
+
+        $userids = $userlist->get_userids();
+
+        if (empty($userids)) {
+            return;
         }
+
+        $context = $userlist->get_context();
+
+        $allowedcontextlevels = [
+            CONTEXT_SYSTEM,
+            CONTEXT_COURSECAT
+        ];
+
+        if (!in_array($context->contextlevel, $allowedcontextlevels)) {
+            return;
+        }
+
+        $cohortids = $DB->get_fieldset_select('cohort', 'id', 'contextid = :contextid',
+            ['contextid' => $context->id]);
+
+        if (empty($cohortids)) {
+            return;
+        }
+
+        list($cohortsql, $cohortparams) = $DB->get_in_or_equal($cohortids, SQL_PARAMS_NAMED);
+        list($usersql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
+
+        $params = $cohortparams + $userparams;
+        $select = "cohortid {$cohortsql} AND userid {$usersql}";
+
+        // Delete the tool_cohortroles records created in the specific context for an approved list of users.
+        $DB->delete_records_select('tool_cohortroles', $select, $params);
     }
 
     /**
@@ -233,21 +291,38 @@ class provider implements
     public static function delete_data_for_user(approved_contextlist $contextlist) {
         global $DB;
 
-        // If the user has tool_cohortroles data, then only the User context should be present so get the first context.
-        $contexts = $contextlist->get_contexts();
-        if (count($contexts) == 0) {
+        // When we process user deletions and expiries, we always delete from the user context.
+        // As a result the cohort role assignments would be deleted, which has a knock-on effect with courses
+        // as roles may change and data may be removed earlier than it should be.
+
+        // Remove contexts different from SYSTEM or COURSECAT.
+        $contextids = array_reduce($contextlist->get_contexts(), function($carry, $context) {
+            if ($context->contextlevel == CONTEXT_SYSTEM || $context->contextlevel == CONTEXT_COURSECAT) {
+                $carry[] = $context->id;
+            }
+            return $carry;
+        }, []);
+
+        if (empty($contextids)) {
             return;
         }
-        $context = reset($contexts);
 
-        // Sanity check that context is at the User context level, then get the userid.
-        if ($context->contextlevel !== CONTEXT_USER) {
+        $userid = $contextlist->get_user()->id;
+
+        list($contextsql, $contextparams) =  $DB->get_in_or_equal($contextids, SQL_PARAMS_NAMED);
+        $selectcontext = "contextid {$contextsql}";
+        // Get the cohorts in the specified contexts.
+        $cohortids = $DB->get_fieldset_select('cohort', 'id', $selectcontext, $contextparams);
+
+        if (empty($cohortids)) {
             return;
         }
-        $userid = $context->instanceid;
+
+        list($cohortsql, $cohortparams) =  $DB->get_in_or_equal($cohortids, SQL_PARAMS_NAMED);
+        $selectcohort = "cohortid {$cohortsql} AND userid = :userid";
+        $params = ['userid' => $userid] + $cohortparams;
 
         // Delete the tool_cohortroles records created for the userid.
-        $DB->delete_records('tool_cohortroles', ['userid' => $userid]);
+        $DB->delete_records_select('tool_cohortroles', $selectcohort, $params);
     }
-
 }
index 26f99d3..6399314 100644 (file)
@@ -58,22 +58,36 @@ class tool_cohortroles_privacy_testcase extends \core_privacy\tests\provider_tes
         $this->setUser($user);
         $this->setAdminUser();
 
-        $nocohortroles = 3;
-        $this->setup_test_scenario_data($user->id, $nocohortroles);
+        // Create course category.
+        $coursecategory = $this->getDataGenerator()->create_category();
+        $coursecategoryctx = \context_coursecat::instance($coursecategory->id);
+        $systemctx = context_system::instance();
+        // Create course.
+        $course = $this->getDataGenerator()->create_course();
+        $coursectx = \context_course::instance($course->id);
+
+        $this->setup_test_scenario_data($user->id, $systemctx, 1);
+        $this->setup_test_scenario_data($user->id, $coursecategoryctx, 1, 'Sausage roll 2',
+            'sausageroll2');
+        $this->setup_test_scenario_data($user->id, $coursectx, 1, 'Sausage roll 3',
+            'sausageroll3');
 
         // Test the User's assigned cohortroles matches 3.
         $cohortroles = $DB->get_records('tool_cohortroles', ['userid' => $user->id]);
-        $this->assertCount($nocohortroles, $cohortroles);
+        $this->assertCount(3, $cohortroles);
 
-        // Test the User's retrieved contextlist contains only one context.
+        // Test the User's retrieved contextlist returns only the system and course category context.
         $contextlist = provider::get_contexts_for_userid($user->id);
         $contexts = $contextlist->get_contexts();
-        $this->assertCount(1, $contexts);
-
-        // Test the User's contexts equal the User's own context.
-        $context = reset($contexts);
-        $this->assertEquals(CONTEXT_USER, $context->contextlevel);
-        $this->assertEquals($user->id, $context->instanceid);
+        $this->assertCount(2, $contexts);
+
+        $contextlevels = array_column($contexts, 'contextlevel');
+        $expected = [
+            CONTEXT_SYSTEM,
+            CONTEXT_COURSECAT
+        ];
+        // Test the User's contexts equal the system and course category context.
+        $this->assertEquals($expected, $contextlevels, '', 0, 10, true);
     }
 
     /**
@@ -85,26 +99,45 @@ class tool_cohortroles_privacy_testcase extends \core_privacy\tests\provider_tes
         $this->setUser($user);
         $this->setAdminUser();
 
-        $nocohortroles = 3;
-        $this->setup_test_scenario_data($user->id, $nocohortroles);
-
-        // Test the User's retrieved contextlist contains only one context.
+        // Create course category.
+        $coursecategory = $this->getDataGenerator()->create_category();
+        $coursecategoryctx = \context_coursecat::instance($coursecategory->id);
+        $systemctx = context_system::instance();
+        // Create course.
+        $course = $this->getDataGenerator()->create_course();
+        $coursectx = \context_course::instance($course->id);
+
+        $this->setup_test_scenario_data($user->id, $systemctx, 1);
+        $this->setup_test_scenario_data($user->id, $coursecategoryctx, 1, 'Sausage roll 2',
+            'sausageroll2');
+        $this->setup_test_scenario_data($user->id, $coursectx, 1, 'Sausage roll 3',
+            'sausageroll3');
+
+        // Test the User's retrieved contextlist contains two contexts.
         $contextlist = provider::get_contexts_for_userid($user->id);
         $contexts = $contextlist->get_contexts();
-        $this->assertCount(1, $contexts);
+        $this->assertCount(2, $contexts);
 
-        // Test the User's contexts equal the User's own context.
-        $context = reset($contexts);
-        $this->assertEquals(CONTEXT_USER, $context->contextlevel);
-        $this->assertEquals($user->id, $context->instanceid);
+        // Add a system, course category and course context to the approved context list.
+        $approvedcontextids = [
+            $systemctx->id,
+            $coursecategoryctx->id,
+            $coursectx->id
+        ];
 
         // Retrieve the User's tool_cohortroles data.
-        $approvedcontextlist = new approved_contextlist($user, 'tool_cohortroles', $contextlist->get_contextids());
+        $approvedcontextlist = new approved_contextlist($user, 'tool_cohortroles', $approvedcontextids);
         provider::export_user_data($approvedcontextlist);
 
-        // Test the tool_cohortroles data is exported at the User context level.
-        $writer = writer::with_context($context);
+        // Test the tool_cohortroles data is exported at the system context level.
+        $writer = writer::with_context($systemctx);
+        $this->assertTrue($writer->has_any_data());
+        // Test the tool_cohortroles data is exported at the course category context level.
+        $writer = writer::with_context($coursecategoryctx);
         $this->assertTrue($writer->has_any_data());
+        // Test the tool_cohortroles data is not exported at the course context level.
+        $writer = writer::with_context($coursectx);
+        $this->assertFalse($writer->has_any_data());
     }
 
     /**
@@ -118,29 +151,52 @@ class tool_cohortroles_privacy_testcase extends \core_privacy\tests\provider_tes
         $this->setUser($user);
         $this->setAdminUser();
 
-        $nocohortroles = 4;
-        $this->setup_test_scenario_data($user->id, $nocohortroles);
+        // Create course category.
+        $coursecategory = $this->getDataGenerator()->create_category();
+        $coursecategoryctx = \context_coursecat::instance($coursecategory->id);
+        $systemctx = context_system::instance();
 
-        // Test the User's assigned cohortroles matches 4.
+        $this->setup_test_scenario_data($user->id, $systemctx, 1);
+        $this->setup_test_scenario_data($user->id, $coursecategoryctx, 1, 'Sausage roll 2',
+            'sausageroll2');
+
+        // Test the User's assigned cohortroles matches 2.
         $cohortroles = $DB->get_records('tool_cohortroles', ['userid' => $user->id]);
-        $this->assertCount($nocohortroles, $cohortroles);
+        $this->assertCount(2, $cohortroles);
 
-        // Test the User's retrieved contextlist contains only one context.
+        // Test the User's retrieved contextlist contains two contexts.
         $contextlist = provider::get_contexts_for_userid($user->id);
         $contexts = $contextlist->get_contexts();
-        $this->assertCount(1, $contexts);
+        $this->assertCount(2, $contexts);
 
-        // Test the User's contexts equal the User's own context.
-        $context = reset($contexts);
-        $this->assertEquals(CONTEXT_USER, $context->contextlevel);
-        $this->assertEquals($user->id, $context->instanceid);
+        // Make sure the user data is only being deleted in within the system and course category context.
+        $usercontext = context_user::instance($user->id);
+        // Delete all the User's records in mdl_tool_cohortroles table by the user context.
+        provider::delete_data_for_all_users_in_context($usercontext);
+
+        // Test the cohort roles records in mdl_tool_cohortroles table is still present.
+        $cohortroles = $DB->get_records('tool_cohortroles', ['userid' => $user->id]);
+        $this->assertCount(2, $cohortroles);
+
+        // Delete all the User's records in mdl_tool_cohortroles table by the specified system context.
+        provider::delete_data_for_all_users_in_context($systemctx);
+
+        // The user data in the system context should be deleted.
+        // Test the User's retrieved contextlist contains one context (course category).
+        $contextlist = provider::get_contexts_for_userid($user->id);
+        $contexts = $contextlist->get_contexts();
+        $this->assertCount(1, $contexts);
 
-        // Delete all the User's records in mdl_tool_cohortroles table by the specified User context.
-        provider::delete_data_for_all_users_in_context($context);
+        // Delete all the User's records in mdl_tool_cohortroles table by the specified course category context.
+        provider::delete_data_for_all_users_in_context($coursecategoryctx);
 
         // Test the cohort roles records in mdl_tool_cohortroles table is equals zero.
         $cohortroles = $DB->get_records('tool_cohortroles', ['userid' => $user->id]);
         $this->assertCount(0, $cohortroles);
+
+        $contextlist = provider::get_contexts_for_userid($user->id);
+        $contexts = $contextlist->get_contexts();
+        $this->assertCount(0, $contexts);
     }
 
     /**
@@ -154,24 +210,35 @@ class tool_cohortroles_privacy_testcase extends \core_privacy\tests\provider_tes
         $this->setUser($user);
         $this->setAdminUser();
 
-        $nocohortroles = 4;
-        $this->setup_test_scenario_data($user->id, $nocohortroles);
+        // Create course category.
+        $coursecategory = $this->getDataGenerator()->create_category();
+        $coursecategoryctx = \context_coursecat::instance($coursecategory->id);
+        $systemctx = context_system::instance();
+
+        $this->setup_test_scenario_data($user->id, $systemctx, 1);
+        $this->setup_test_scenario_data($user->id, $coursecategoryctx, 1, 'Sausage roll 2',
+            'sausageroll2');
 
-        // Test the User's assigned cohortroles matches 4.
+        // Test the User's assigned cohortroles matches 2.
         $cohortroles = $DB->get_records('tool_cohortroles', ['userid' => $user->id]);
-        $this->assertCount($nocohortroles, $cohortroles);
+        $this->assertCount(2, $cohortroles);
 
-        // Test the User's retrieved contextlist contains only one context.
+        // Test the User's retrieved contextlist contains two contexts.
         $contextlist = provider::get_contexts_for_userid($user->id);
         $contexts = $contextlist->get_contexts();
-        $this->assertCount(1, $contexts);
+        $this->assertCount(2, $contexts);
 
-        // Test the User's contexts equal the User's own context.
-        $context = reset($contexts);
-        $this->assertEquals(CONTEXT_USER, $context->contextlevel);
-        $this->assertEquals($user->id, $context->instanceid);
+        // Make sure the user data is only being deleted in within the system and the course category contexts.
+        $usercontext = context_user::instance($user->id);
+        // Delete all the User's records in mdl_tool_cohortroles table by the specified approved context list.
+        $approvedcontextlist = new approved_contextlist($user, 'tool_cohortroles', [$usercontext->id]);
+        provider::delete_data_for_user($approvedcontextlist);
 
-        // Delete all the User's records in mdl_tool_cohortroles table by the specified User approved context list.
+        // Test the cohort roles records in mdl_tool_cohortroles table are still present.
+        $cohortroles = $DB->get_records('tool_cohortroles', ['userid' => $user->id]);
+        $this->assertCount(2, $cohortroles);
+
+        // Delete all the User's records in mdl_tool_cohortroles table by the specified approved context list.
         $approvedcontextlist = new approved_contextlist($user, 'tool_cohortroles', $contextlist->get_contextids());
         provider::delete_data_for_user($approvedcontextlist);
 
@@ -190,22 +257,32 @@ class tool_cohortroles_privacy_testcase extends \core_privacy\tests\provider_tes
         $user = $this->getDataGenerator()->create_user();
         $usercontext = context_user::instance($user->id);
 
-        $systemcontext = context_system::instance();
+        // Create course category.
+        $coursecategory = $this->getDataGenerator()->create_category();
+        $coursecategoryctx = \context_coursecat::instance($coursecategory->id);
+        $systemctx = context_system::instance();
 
         $this->setAdminUser();
 
-        $userlist = new \core_privacy\local\request\userlist($systemcontext, $component);
+        $userlist = new \core_privacy\local\request\userlist($systemctx, $component);
         provider::get_users_in_context($userlist);
         $this->assertCount(0, $userlist);
 
-        $nocohortroles = 3;
-        $this->setup_test_scenario_data($user->id, $nocohortroles);
+        $this->setup_test_scenario_data($user->id, $systemctx, 1);
+        $this->setup_test_scenario_data($user->id, $coursecategoryctx, 1, 'Sausage roll 2',
+            'sausageroll2');
 
         // The list of users within the system context should contain user.
         provider::get_users_in_context($userlist);
         $this->assertCount(1, $userlist);
         $this->assertTrue(in_array($user->id, $userlist->get_userids()));
 
+        // The list of users within the course category context should contain user.
+        $userlist = new \core_privacy\local\request\userlist($coursecategoryctx, $component);
+        provider::get_users_in_context($userlist);
+        $this->assertCount(1, $userlist);
+        $this->assertTrue(in_array($user->id, $userlist->get_userids()));
+
         // The list of users within the user context should be empty.
         $userlist2 = new \core_privacy\local\request\userlist($usercontext, $component);
         provider::get_users_in_context($userlist2);
@@ -226,43 +303,44 @@ class tool_cohortroles_privacy_testcase extends \core_privacy\tests\provider_tes
         $user3 = $this->getDataGenerator()->create_user();
         $usercontext3 = context_user::instance($user3->id);
 
-        $systemcontext = context_system::instance();
+        // Create course category.
+        $coursecategory = $this->getDataGenerator()->create_category();
+        $coursecategoryctx = \context_coursecat::instance($coursecategory->id);
+        $systemctx = context_system::instance();
 
         $this->setAdminUser();
 
-        $nocohortroles = 3;
-        $this->setup_test_scenario_data($user1->id, $nocohortroles);
-        $this->setup_test_scenario_data($user2->id, $nocohortroles, 'Sausage roll 2',
+        $this->setup_test_scenario_data($user1->id, $systemctx, 1);
+        $this->setup_test_scenario_data($user2->id, $systemctx, 1, 'Sausage roll 2',
                 'sausageroll2');
-        $this->setup_test_scenario_data($user3->id, $nocohortroles, 'Sausage roll 3',
+        $this->setup_test_scenario_data($user3->id, $coursecategoryctx, 1, 'Sausage roll 3',
                 'sausageroll3');
 
-        $userlist1 = new \core_privacy\local\request\userlist($systemcontext, $component);
+        $userlist1 = new \core_privacy\local\request\userlist($systemctx, $component);
         provider::get_users_in_context($userlist1);
-        $this->assertCount(3, $userlist1);
+        $this->assertCount(2, $userlist1);
         $this->assertTrue(in_array($user1->id, $userlist1->get_userids()));
         $this->assertTrue(in_array($user2->id, $userlist1->get_userids()));
-        $this->assertTrue(in_array($user3->id, $userlist1->get_userids()));
 
         // Convert $userlist1 into an approved_contextlist.
-        $approvedlist1 = new approved_userlist($systemcontext, $component, [$user1->id, $user2->id]);
+        $approvedlist1 = new approved_userlist($systemctx, $component, [$user1->id]);
         // Delete using delete_data_for_user.
         provider::delete_data_for_users($approvedlist1);
 
         // Re-fetch users in systemcontext.
-        $userlist1 = new \core_privacy\local\request\userlist($systemcontext, $component);
+        $userlist1 = new \core_privacy\local\request\userlist($systemctx, $component);
         provider::get_users_in_context($userlist1);
-        // The user data of user1 and user2 in systemcontext should be deleted.
-        // The user data of user3 in systemcontext should be still present.
+        // The user data of user1in systemcontext should be deleted.
+        // The user data of user2 in systemcontext should be still present.
         $this->assertCount(1, $userlist1);
-        $this->assertTrue(in_array($user3->id, $userlist1->get_userids()));
+        $this->assertTrue(in_array($user2->id, $userlist1->get_userids()));
 
         // Convert $userlist1 into an approved_contextlist in the user context.
         $approvedlist2 = new approved_userlist($usercontext3, $component, $userlist1->get_userids());
         // Delete using delete_data_for_user.
         provider::delete_data_for_users($approvedlist2);
         // Re-fetch users in systemcontext.
-        $userlist1 = new \core_privacy\local\request\userlist($systemcontext, $component);
+        $userlist1 = new \core_privacy\local\request\userlist($systemctx, $component);
         provider::get_users_in_context($userlist1);
         // The user data in systemcontext should not be deleted.
         $this->assertCount(1, $userlist1);
@@ -278,12 +356,15 @@ class tool_cohortroles_privacy_testcase extends \core_privacy\tests\provider_tes
      * @throws \core_competency\invalid_persistent_exception
      * @throws coding_exception
      */
-    protected function setup_test_scenario_data($userid, $nocohortroles, $rolename = 'Sausage Roll',
+    protected function setup_test_scenario_data($userid, $context, $nocohortroles, $rolename = 'Sausage Roll',
                                                 $roleshortname = 'sausageroll') {
         $roleid = create_role($rolename, $roleshortname, 'mmmm');
 
+        $result = new \stdClass();
+        $result->contextid = $context->id;
+
         for ($c = 0; $c < $nocohortroles; $c++) {
-            $cohort = $this->getDataGenerator()->create_cohort();
+            $cohort = $this->getDataGenerator()->create_cohort($result);
 
             $params = (object)array(
                 'userid' => $userid,
index 5c08f5f..0fb74f2 100644 (file)
@@ -41,6 +41,7 @@ use tool_dataprivacy\external\data_request_exporter;
 use tool_dataprivacy\local\helper;
 use tool_dataprivacy\task\initiate_data_request_task;
 use tool_dataprivacy\task\process_data_request_task;
+use tool_dataprivacy\data_request;
 
 defined('MOODLE_INTERNAL') || die();
 
@@ -277,6 +278,7 @@ class api {
      * @param int $userid The User ID.
      * @param int[] $statuses The status filters.
      * @param int[] $types The request type filters.
+     * @param int[] $creationmethods The request creation method filters.
      * @param string $sort The order by clause.
      * @param int $offset Amount of records to skip.
      * @param int $limit Amount of records to fetch.
@@ -284,7 +286,8 @@ class api {
      * @throws coding_exception
      * @throws dml_exception
      */
-    public static function get_data_requests($userid = 0, $statuses = [], $types = [], $sort = '', $offset = 0, $limit = 0) {
+    public static function get_data_requests($userid = 0, $statuses = [], $types = [], $creationmethods = [],
+                                             $sort = '', $offset = 0, $limit = 0) {
         global $DB, $USER;
         $results = [];
         $sqlparams = [];
@@ -308,6 +311,13 @@ class api {
             $sqlparams = array_merge($sqlparams, $typeparams);
         }
 
+        // Set request creation method filter.
+        if (!empty($creationmethods)) {
+            list($typeinsql, $typeparams) = $DB->get_in_or_equal($creationmethods, SQL_PARAMS_NAMED);
+            $sqlconditions[] = "creationmethod $typeinsql";
+            $sqlparams = array_merge($sqlparams, $typeparams);
+        }
+
         if ($userid) {
             // Get the data requests for the user or data requests made by the user.
             $sqlconditions[] = "(userid = :userid OR requestedby = :requestedby)";
@@ -350,7 +360,7 @@ class api {
 
             if (!empty($expiredrequests)) {
                 data_request::expire($expiredrequests);
-                $results = self::get_data_requests($userid, $statuses, $types, $sort, $offset, $limit);
+                $results = self::get_data_requests($userid, $statuses, $types, $creationmethods, $sort, $offset, $limit);
             }
         }
 
@@ -363,11 +373,12 @@ class api {
      * @param int $userid The User ID.
      * @param int[] $statuses The status filters.
      * @param int[] $types The request type filters.
+     * @param int[] $creationmethods The request creation method filters.
      * @return int
      * @throws coding_exception
      * @throws dml_exception
      */
-    public static function get_data_requests_count($userid = 0, $statuses = [], $types = []) {
+    public static function get_data_requests_count($userid = 0, $statuses = [], $types = [], $creationmethods = []) {
         global $DB, $USER;
         $count = 0;
         $sqlparams = [];
@@ -381,6 +392,11 @@ class api {
             $sqlconditions[] = "type $typeinsql";
             $sqlparams = array_merge($sqlparams, $typeparams);
         }
+        if (!empty($creationmethods)) {
+            list($typeinsql, $typeparams) = $DB->get_in_or_equal($creationmethods, SQL_PARAMS_NAMED);
+            $sqlconditions[] = "creationmethod $typeinsql";
+            $sqlparams = array_merge($sqlparams, $typeparams);
+        }
         if ($userid) {
             // Get the data requests for the user or data requests made by the user.
             $sqlconditions[] = "(userid = :userid OR requestedby = :requestedby)";
@@ -439,8 +455,8 @@ class api {
             self::DATAREQUEST_STATUS_EXPIRED,
             self::DATAREQUEST_STATUS_DELETED,
         ];
-        list($insql, $inparams) = $DB->get_in_or_equal($nonpendingstatuses, SQL_PARAMS_NAMED);
-        $select = 'type = :type AND userid = :userid AND status NOT ' . $insql;
+        list($insql, $inparams) = $DB->get_in_or_equal($nonpendingstatuses, SQL_PARAMS_NAMED, 'st', false);
+        $select = "type = :type AND userid = :userid AND status {$insql}";
         $params = array_merge([
             'type' => $type,
             'userid' => $userid
@@ -449,6 +465,48 @@ class api {
         return data_request::record_exists_select($select, $params);
     }
 
+    /**
+     * Find whether any ongoing requests exist for a set of users.
+     *
+     * @param   array   $userids
+     * @return  array
+     */
+    public static function find_ongoing_request_types_for_users(array $userids) : array {
+        global $DB;
+
+        if (empty($userids)) {
+            return [];
+        }
+
+        // Check if the user already has an incomplete data request of the same type.
+        $nonpendingstatuses = [
+            self::DATAREQUEST_STATUS_COMPLETE,
+            self::DATAREQUEST_STATUS_CANCELLED,
+            self::DATAREQUEST_STATUS_REJECTED,
+            self::DATAREQUEST_STATUS_DOWNLOAD_READY,
+            self::DATAREQUEST_STATUS_EXPIRED,
+            self::DATAREQUEST_STATUS_DELETED,
+        ];
+        list($statusinsql, $statusparams) = $DB->get_in_or_equal($nonpendingstatuses, SQL_PARAMS_NAMED, 'st', false);
+        list($userinsql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED, 'us');
+
+        $select = "userid {$userinsql} AND status {$statusinsql}";
+        $params = array_merge($statusparams, $userparams);
+
+        $requests = $DB->get_records_select(data_request::TABLE, $select, $params, 'userid', 'id, userid, type');
+
+        $returnval = [];
+        foreach ($userids as $userid) {
+            $returnval[$userid] = (object) [];
+        }
+
+        foreach ($requests as $request) {
+            $returnval[$request->userid]->{$request->type} = true;
+        }
+
+        return $returnval;
+    }
+
     /**
      * Determines whether a request is active or not based on its status.
      *
@@ -967,6 +1025,34 @@ 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) {
+        $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) {
+        $expiredcontext = new expired_context($id);
+        return $expiredcontext->delete();
+    }
+
     /**
      * Updates the status of an expired context.
      *
index 9976422..a95d27a 100644 (file)
@@ -21,7 +21,9 @@
  * @copyright  2018 Jun Pataleta
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
+
 namespace tool_dataprivacy;
+
 defined('MOODLE_INTERNAL') || die();
 
 use core\persistent;
@@ -158,8 +160,6 @@ class data_request extends persistent {
         return $result;
     }
 
-
-
     /**
      * Fetch completed data requests which are due to expire.
      *
@@ -224,4 +224,66 @@ class data_request extends persistent {
             }
         }
     }
+
+    /**
+     * Whether this request is in a state appropriate for reset/resubmission.
+     *
+     * Note: This does not check whether any other completed requests exist for this user.
+     *
+     * @return  bool
+     */
+    public function is_resettable() : bool {
+        if (api::DATAREQUEST_TYPE_OTHERS == $this->get('type')) {
+            // It is not possible to reset 'other' reqeusts.
+            return false;
+        }
+
+        $resettable = [
+            api::DATAREQUEST_STATUS_APPROVED => true,
+            api::DATAREQUEST_STATUS_REJECTED => true,
+        ];
+
+        return isset($resettable[$this->get('status')]);
+    }
+
+    /**
+     * Whether this request is 'active'.
+     *
+     * @return  bool
+     */
+    public function is_active() : bool {
+        $active = [
+            api::DATAREQUEST_STATUS_APPROVED => true,
+        ];
+
+        return isset($active[$this->get('status')]);
+    }
+
+    /**
+     * Reject this request and resubmit it as a fresh request.
+     *
+     * Note: This does not check whether any other completed requests exist for this user.
+     *
+     * @return  self
+     */
+    public function resubmit_request() : data_request {
+        if ($this->is_active()) {
+            $this->set('status', api::DATAREQUEST_STATUS_REJECTED)->save();
+        }
+
+        if (!$this->is_resettable()) {
+            throw new \moodle_exception('cannotreset', 'tool_dataprivacy');
+        }
+
+        $currentdata = $this->to_record();
+        unset($currentdata->id);
+
+        $clone = api::create_data_request($this->get('userid'), $this->get('type'));
+        $clone->set('comments', $this->get('comments'));
+        $clone->set('dpo', $this->get('dpo'));
+        $clone->set('requestedby', $this->get('requestedby'));
+        $clone->save();
+
+        return $clone;
+    }
 }
diff --git a/admin/tool/dataprivacy/classes/event/user_deleted_observer.php b/admin/tool/dataprivacy/classes/event/user_deleted_observer.php
new file mode 100644 (file)
index 0000000..380236b
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Event observers supported by this module.
+ *
+ * @package    tool_dataprivacy
+ * @copyright   2018 Mihail Geshoski <mihail@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_dataprivacy\event;
+
+use \tool_dataprivacy\api;
+use \tool_dataprivacy\data_request;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Event observers supported by this module.
+ *
+ * @package    tool_dataprivacy
+ * @copyright   2018 Mihail Geshoski <mihail@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class user_deleted_observer {
+
+    /**
+     * Create user data deletion request when the user is deleted.
+     *
+     * @param \core\event\user_deleted $event
+     */
+    public static function create_delete_data_request(\core\event\user_deleted $event) {
+        // Automatic creation of deletion requests must be enabled.
+        if (get_config('tool_dataprivacy', 'automaticdeletionrequests')) {
+            $requesttypes = [api::DATAREQUEST_TYPE_DELETE];
+            $requeststatuses = [api::DATAREQUEST_STATUS_COMPLETE, api::DATAREQUEST_STATUS_DELETED];
+
+            $hasongoingdeleterequests = api::has_ongoing_request($event->objectid, $requesttypes[0]);
+            $hascompleteddeleterequest = (api::get_data_requests_count($event->objectid, $requeststatuses,
+                    $requesttypes) > 0) ? true : false;
+
+            if (!$hasongoingdeleterequests && !$hascompleteddeleterequest) {
+                api::create_data_request($event->objectid, $requesttypes[0],
+                        get_string('datarequestcreatedupondelete', 'tool_dataprivacy'),
+                        data_request::DATAREQUEST_CREATION_AUTO);
+            }
+        }
+    }
+}
index c2dc169..fa63eb0 100644 (file)
@@ -786,11 +786,13 @@ class expired_contexts_manager {
         $parents = $context->get_parent_contexts(true);
         foreach ($parents as $parent) {
             if ($parent instanceof \context_course) {
+                // This is a context within a course. Check whether _this context_ is expired as a function of a course.
                 return self::is_course_context_expired($context);
             }
 
             if ($parent instanceof \context_user) {
-                return self::are_user_context_dependencies_expired($context);
+                // This is a context within a user. Check whether the _user_ has expired.
+                return self::are_user_context_dependencies_expired($parent);
             }
         }
 
@@ -810,12 +812,13 @@ class expired_contexts_manager {
     }
 
     /**
-     * Determine whether the supplied course context has expired.
+     * Determine whether the supplied course-related context has expired.
+     * Note: This is not necessarily a _course_ context, but a context which is _within_ a course.
      *
-     * @param   \context_course $context
+     * @param   \context        $context
      * @return  bool
      */
-    protected static function is_course_context_expired(\context_course $context) : bool {
+    protected static function is_course_context_expired(\context $context) : bool {
         $expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
 
         return !empty($expiryrecords[$context->path]) && $expiryrecords[$context->path]->info->is_fully_expired();
@@ -890,11 +893,13 @@ class expired_contexts_manager {
         $parents = $context->get_parent_contexts(true);
         foreach ($parents as $parent) {
             if ($parent instanceof \context_course) {
-                return self::is_course_context_expired_or_unprotected_for_user($parent, $user);
+                // This is a context within a course. Check whether _this context_ is expired as a function of a course.
+                return self::is_course_context_expired_or_unprotected_for_user($context, $user);
             }
 
             if ($parent instanceof \context_user) {
-                return self::are_user_context_dependencies_expired($context);
+                // This is a context within a user. Check whether the _user_ has expired.
+                return self::are_user_context_dependencies_expired($parent);
             }
         }
 
@@ -902,13 +907,14 @@ class expired_contexts_manager {
     }
 
     /**
-     * Determine whether the supplied course context has expired, or is unprotected.
+     * Determine whether the supplied course-related context has expired, or is unprotected.
+     * Note: This is not necessarily a _course_ context, but a context which is _within_ a course.
      *
-     * @param   \context_course $context
+     * @param   \context        $context
      * @param   \stdClass       $user
      * @return  bool
      */
-    protected static function is_course_context_expired_or_unprotected_for_user(\context_course $context, \stdClass $user) {
+    protected static function is_course_context_expired_or_unprotected_for_user(\context $context, \stdClass $user) {
         $expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
 
         $info = $expiryrecords[$context->path]->info;
diff --git a/admin/tool/dataprivacy/classes/expired_user_contexts.php b/admin/tool/dataprivacy/classes/expired_user_contexts.php
new file mode 100644 (file)
index 0000000..009e71d
--- /dev/null
@@ -0,0 +1,149 @@
+<?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) {
+            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;
+    }
+}
index 2609ef1..25f5bf8 100644 (file)
@@ -27,6 +27,7 @@ defined('MOODLE_INTERNAL') || die();
 use coding_exception;
 use moodle_exception;
 use tool_dataprivacy\api;
+use tool_dataprivacy\data_request;
 
 /**
  * Class containing helper functions for the data privacy tool.
@@ -44,6 +45,9 @@ class helper {
     /** Filter constant associated with the request status filter. */
     const FILTER_STATUS = 2;
 
+    /** Filter constant associated with the request creation filter. */
+    const FILTER_CREATION = 3;
+
     /** The request filters preference key. */
     const PREF_REQUEST_FILTERS = 'tool_dataprivacy_request-filters';
 
@@ -145,6 +149,34 @@ class helper {
         ];
     }
 
+    /**
+     * Retrieves the human-readable value of a data request creation method.
+     *
+     * @param int $creation The request creation method.
+     * @return string
+     * @throws moodle_exception
+     */
+    public static function get_request_creation_method_string($creation) {
+        $creationmethods = self::get_request_creation_methods();
+        if (!isset($creationmethods[$creation])) {
+            throw new moodle_exception('errorinvalidrequestcreationmethod', 'tool_dataprivacy');
+        }
+
+        return $creationmethods[$creation];
+    }
+
+    /**
+     * Returns the key value-pairs of request creation method code and string value.
+     *
+     * @return array
+     */
+    public static function get_request_creation_methods() {
+        return [
+            data_request::DATAREQUEST_CREATION_MANUAL => get_string('creationmanual', 'tool_dataprivacy'),
+            data_request::DATAREQUEST_CREATION_AUTO => get_string('creationauto', 'tool_dataprivacy'),
+        ];
+    }
+
     /**
      * Get the users that a user can make data request for.
      *
@@ -199,6 +231,10 @@ class helper {
                 'name' => get_string('requeststatus', 'tool_dataprivacy'),
                 'options' => self::get_request_statuses()
             ],
+            self::FILTER_CREATION => (object)[
+                'name' => get_string('requestcreation', 'tool_dataprivacy'),
+                'options' => self::get_request_creation_methods()
+            ],
         ];
         $options = [];
         foreach ($filters as $category => $filtercategory) {
index b6fa957..b86e464 100644 (file)
@@ -62,6 +62,9 @@ class data_requests_table extends table_sql {
     /** @var \tool_dataprivacy\data_request[] Array of data request persistents. */
     protected $datarequests = [];
 
+    /** @var \stdClass[] List of userids and whether they have any ongoing active requests. */
+    protected $ongoingrequests = [];
+
     /** @var int The number of data request to be displayed per page. */
     protected $perpage;
 
@@ -74,15 +77,17 @@ class data_requests_table extends table_sql {
      * @param int $userid The user ID
      * @param int[] $statuses
      * @param int[] $types
+     * @param int[] $creationmethods
      * @param bool $manage
      * @throws coding_exception
      */
-    public function __construct($userid = 0, $statuses = [], $types = [], $manage = false) {
+    public function __construct($userid = 0, $statuses = [], $types = [], $creationmethods = [], $manage = false) {
         parent::__construct('data-requests-table');
 
         $this->userid = $userid;
         $this->statuses = $statuses;
         $this->types = $types;
+        $this->creationmethods = $creationmethods;
         $this->manage = $manage;
 
         $checkboxattrs = [
@@ -247,6 +252,20 @@ class data_requests_table extends table_sql {
                 break;
         }
 
+        if ($this->manage) {
+            $persistent = $this->datarequests[$requestid];
+            $canreset = $persistent->is_active() || empty($this->ongoingrequests[$data->foruser->id]->{$data->type});
+            $canreset = $canreset && $persistent->is_resettable();
+            if ($canreset) {
+                $reseturl = new moodle_url('/admin/tool/dataprivacy/resubmitrequest.php', [
+                        'requestid' => $requestid,
+                    ]);
+                $actiondata = ['data-action' => 'reset', 'data-requestid' => $requestid];
+                $actiontext = get_string('resubmitrequestasnew', 'tool_dataprivacy');
+                $actions[] = new action_menu_link_secondary($reseturl, null, $actiontext, $actiondata);
+            }
+        }
+
         $actionsmenu = new action_menu($actions);
         $actionsmenu->set_menu_trigger(get_string('actions'));
         $actionsmenu->set_owner_selector('request-actions-' . $requestid);
@@ -273,23 +292,31 @@ class data_requests_table extends table_sql {
         $sort = $this->get_sql_sort();
 
         // Get data requests from the given conditions.
-        $datarequests = api::get_data_requests($this->userid, $this->statuses, $this->types, $sort,
-                $this->get_page_start(), $this->get_page_size());
+        $datarequests = api::get_data_requests($this->userid, $this->statuses, $this->types,
+                $this->creationmethods, $sort, $this->get_page_start(), $this->get_page_size());
 
         // Count data requests from the given conditions.
-        $total = api::get_data_requests_count($this->userid, $this->statuses, $this->types);
+        $total = api::get_data_requests_count($this->userid, $this->statuses, $this->types,
+                $this->creationmethods);
         $this->pagesize($pagesize, $total);
 
         $this->rawdata = [];
         $context = \context_system::instance();
         $renderer = $PAGE->get_renderer('tool_dataprivacy');
 
+        $forusers = [];
         foreach ($datarequests as $persistent) {
             $this->datarequests[$persistent->get('id')] = $persistent;
             $exporter = new data_request_exporter($persistent, ['context' => $context]);
             $this->rawdata[] = $exporter->export($renderer);
+            $forusers[] = $persistent->get('userid');
         }
 
+        // Fetch the list of all ongoing requests for the users currently shown.
+        // This is used to determine whether any non-active request can be resubmitted.
+        // There can only be one ongoing request of a type for each user.
+        $this->ongoingrequests = api::find_ongoing_request_types_for_users($forusers);
+
         // Set initial bars.
         if ($useinitialsbar) {
             $this->initialbars($total > $pagesize);
index 193bd70..d415002 100644 (file)
@@ -167,6 +167,8 @@ class provider implements
             $data->type = tool_helper::get_shortened_request_type_string($record->type);
             // Status.
             $data->status = tool_helper::get_request_status_string($record->status);
+            // Creation method.
+            $data->creationmethod = tool_helper::get_request_creation_method_string($record->creationmethod);
             // Comments.
             $data->comments = $record->comments;
             // The DPO's comment about this request.
@@ -234,6 +236,10 @@ class provider implements
                         $option->category = get_string('requeststatus', 'tool_dataprivacy');
                         $option->name = tool_helper::get_request_status_string($value);
                         break;
+                    case tool_helper::FILTER_CREATION:
+                        $option->category = get_string('requestcreation', 'tool_dataprivacy');
+                        $option->name = tool_helper::get_request_creation_method_string($value);
+                        break;
                 }
                 $descriptions[] = get_string('filteroption', 'tool_dataprivacy', $option);
             }
index a078747..83827ef 100644 (file)
@@ -57,8 +57,10 @@ class delete_existing_deleted_users extends scheduled_task {
     public function execute() {
         global $DB;
 
-        // Select all deleted users that do not have any delete data requests created for them.
-        $sql = "SELECT DISTINCT(u.id)
+        // Automatic creation of deletion requests must be enabled.
+        if (get_config('tool_dataprivacy', 'automaticdeletionrequests')) {
+            // Select all deleted users that do not have any delete data requests created for them.
+            $sql = "SELECT DISTINCT(u.id)
                   FROM {user} u
              LEFT JOIN {tool_dataprivacy_request} r
                        ON u.id = r.userid
@@ -66,23 +68,24 @@ class delete_existing_deleted_users extends scheduled_task {
                        AND (r.id IS NULL
                            OR r.type != ?)";
 
-        $params = [
-            1,
-            api::DATAREQUEST_TYPE_DELETE
-        ];
+            $params = [
+                1,
+                api::DATAREQUEST_TYPE_DELETE
+            ];
 
-        $deletedusers = $DB->get_records_sql($sql, $params);
-        $createdrequests = 0;
+            $deletedusers = $DB->get_records_sql($sql, $params);
+            $createdrequests = 0;
 
-        foreach ($deletedusers as $user) {
-            api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE,
+            foreach ($deletedusers as $user) {
+                api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE,
                     get_string('datarequestcreatedfromscheduledtask', 'tool_dataprivacy'),
                     data_request::DATAREQUEST_CREATION_AUTO);
-            $createdrequests++;
-        }
+                $createdrequests++;
+            }
 
-        if ($createdrequests > 0) {
-            mtrace($createdrequests . ' delete data request(s) created for existing deleted users');
+            if ($createdrequests > 0) {
+                mtrace($createdrequests . ' delete data request(s) created for existing deleted users');
+            }
         }
     }
 }
index 8b2b16f..ec6b9c2 100644 (file)
@@ -55,6 +55,7 @@ if (\tool_dataprivacy\api::is_site_dpo($USER->id)) {
 
     $types = [];
     $statuses = [];
+    $creationmethods = [];
     foreach ($filtersapplied as $filter) {
         list($category, $value) = explode(':', $filter);
         switch($category) {
@@ -64,10 +65,13 @@ if (\tool_dataprivacy\api::is_site_dpo($USER->id)) {
             case \tool_dataprivacy\local\helper::FILTER_STATUS:
                 $statuses[] = $value;
                 break;
+            case \tool_dataprivacy\local\helper::FILTER_CREATION:
+                $creationmethods[] = $value;
+                break;
         }
     }
 
-    $table = new \tool_dataprivacy\output\data_requests_table(0, $statuses, $types, true);
+    $table = new \tool_dataprivacy\output\data_requests_table(0, $statuses, $types, $creationmethods, true);
     if (!empty($perpage)) {
         set_user_preference(\tool_dataprivacy\local\helper::PREF_REQUEST_PERPAGE, $perpage);
     } else {
diff --git a/admin/tool/dataprivacy/db/events.php b/admin/tool/dataprivacy/db/events.php
new file mode 100644 (file)
index 0000000..2278b66
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This file defines observers needed by the plugin.
+ *
+ * @package    tool_dataprivacy
+ * @copyright   2018 Mihail Geshoski <mihail@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$observers = [
+    [
+        'eventname'   => '\core\event\user_deleted',
+        'callback'    => '\tool_dataprivacy\event\user_deleted_observer::create_delete_data_request',
+    ],
+];
index 5449638..306337d 100644 (file)
@@ -252,10 +252,12 @@ function xmldb_tool_dataprivacy_upgrade($oldversion) {
         // Define field sensitivedatareasons to be added to tool_dataprivacy_purpose.
         $table = new xmldb_table('tool_dataprivacy_request');
         $field = new xmldb_field('creationmethod', XMLDB_TYPE_INTEGER, 10, null, XMLDB_NOTNULL, null, 0, 'timemodified');
+
         // Conditionally launch add field sensitivedatareasons.
         if (!$dbman->field_exists($table, $field)) {
             $dbman->add_field($table, $field);
         }
+
         // Dataprivacy savepoint reached.
         upgrade_plugin_savepoint(true, 2018100406, 'tool', 'dataprivacy');
     }
index 84ca013..35595cf 100644 (file)
@@ -32,6 +32,8 @@ $string['addnewdefaults'] = 'Add a new module default';
 $string['addpurpose'] = 'Add purpose';
 $string['approve'] = 'Approve';
 $string['approverequest'] = 'Approve request';
+$string['automaticdeletionrequests'] = 'Create automatic data deletion requests';
+$string['automaticdeletionrequests_desc'] = 'If enabled, automatic delete data request will be created upon user deletion or for each existing deleted user which data was not fully deleted.';
 $string['bulkapproverequests'] = 'Approve requests';
 $string['bulkdenyrequests'] = 'Deny requests';
 $string['cachedef_purpose'] = 'Data purposes';
@@ -39,6 +41,7 @@ $string['cachedef_purpose_overrides'] = 'Purpose overrides in the Data privacy t
 $string['cachedef_contextlevel'] = 'Context levels purpose and category';
 $string['cancelrequest'] = 'Cancel request';
 $string['cancelrequestconfirmation'] = 'Do you really want cancel this data request?';
+$string['cannotreset'] = 'Unable to reset this request. Only rejected requests can be reset.';
 $string['categories'] = 'Categories';
 $string['category'] = 'Category';
 $string['category_help'] = 'A category in the data registry describes a type of data. A new category may be added, or if Inherit is selected, the data category from a higher context is applied. Contexts are (from low to high): Blocks > Activity modules > Courses > Course categories > Site.';
@@ -55,6 +58,7 @@ $string['confirmcompletion'] = 'Do you really want to mark this user enquiry as
 $string['confirmcontextdeletion'] = 'Do you really want to confirm the deletion of the selected contexts? This will also delete all of the user data for their respective sub-contexts.';
 $string['confirmdenial'] = 'Do you really want deny this data request?';
 $string['confirmbulkdenial'] = 'Do you really want to bulk deny the selected data requests?';
+$string['confirmrequestresubmit'] = 'Are you sure you wish to cancel the current {$a->type} request for {$a->username} and resubmit it?';
 $string['contactdataprotectionofficer'] = 'Contact the privacy officer';
 $string['contactdataprotectionofficer_desc'] = 'If enabled, users will be able to contact the privacy officer and make a data request via a link on their profile page.';
 $string['contextlevelname10'] = 'Site';
@@ -68,6 +72,8 @@ $string['contactdpoviaprivacypolicy'] = 'Please contact the privacy officer as d
 $string['createcategory'] = 'Create data category';
 $string['createnewdatarequest'] = 'Create a new data request';
 $string['createpurpose'] = 'Create data purpose';
+$string['creationauto'] = 'Automatically';
+$string['creationmanual'] = 'Manually';
 $string['datadeletion'] = 'Data deletion';
 $string['datadeletionpagehelp'] = 'Data for which the retention period has expired are listed here. Please review and confirm data deletion, which will then be executed by the "Delete expired contexts" scheduled task.';
 $string['dataprivacy:makedatarequestsforchildren'] = 'Make data requests for minors';
@@ -82,6 +88,7 @@ $string['dataretentionsummary'] = 'Data retention summary';
 $string['datarequestcreatedforuser'] = 'Data request created for {$a}';
 $string['datarequestcreatedfromscheduledtask'] = 'Automatically created from a scheduled task (pre-existing deleted user).';
 $string['datarequestemailsubject'] = 'Data request: {$a}';
+$string['datarequestcreatedupondelete'] = 'Automatically created upon user deletion.';
 $string['datarequests'] = 'Data requests';
 $string['datecomment'] = '[{$a->date}]: ' . PHP_EOL . ' {$a->comment}';
 $string['daterequested'] = 'Date requested';
@@ -117,6 +124,7 @@ $string['editpurposes'] = 'Edit purposes';
 $string['effectiveretentionperiodcourse'] = '{$a} (after the course end date)';
 $string['effectiveretentionperioduser'] = '{$a} (since the last time the user accessed the site)';
 $string['emailsalutation'] = 'Dear {$a},';
+$string['errorinvalidrequestcreationmethod'] = 'Invalid request creation method!';
 $string['errorinvalidrequeststatus'] = 'Invalid request status!';
 $string['errorinvalidrequesttype'] = 'Invalid request type!';
 $string['errornocapabilitytorequestforothers'] = 'User {$a->requestedby} doesn\'t have the capability to make a data request on behalf of user {$a->userid}';
@@ -235,6 +243,7 @@ $string['requestby'] = 'Requested by';
 $string['requestbydetail'] = 'Requested by:';
 $string['requestcomments'] = 'Comments';
 $string['requestcomments_help'] = 'This box enables you to enter any further details about your data request.';
+$string['requestcreation'] = 'Creation';
 $string['requestdenied'] = 'The request has been denied';
 $string['requestemailintro'] = 'You have received a data request:';
 $string['requestfor'] = 'User';
@@ -264,6 +273,9 @@ When checking the active enrolment in a course, if the course has no end date th
 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['resubmitrequestasnew'] = 'Resubmit as new request';
+$string['resubmitrequest'] = 'Resubmit {$a->type} request for {$a->username}';
+$string['resubmittedrequest'] = 'The existing {$a->type} request for {$a->username} was cancelled and resubmitted';
 $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.';
 $string['resultdownloadready'] = 'Your copy of your personal data in {$a} that you recently requested is now available for download. Please click on the link below to go to the download page.';
 $string['reviewdata'] = 'Review data';
index f435d00..0a4070a 100644 (file)
@@ -55,7 +55,7 @@ $PAGE->set_title($title);
 echo $OUTPUT->header();
 echo $OUTPUT->heading($title);
 
-$requests = tool_dataprivacy\api::get_data_requests($USER->id, [], [], 'timecreated DESC');
+$requests = tool_dataprivacy\api::get_data_requests($USER->id, [], [], [], 'timecreated DESC');
 $requestlist = new tool_dataprivacy\output\my_data_requests_page($requests);
 $requestlistoutput = $PAGE->get_renderer('tool_dataprivacy');
 echo $requestlistoutput->render($requestlist);
diff --git a/admin/tool/dataprivacy/resubmitrequest.php b/admin/tool/dataprivacy/resubmitrequest.php
new file mode 100644 (file)
index 0000000..ac7520e
--- /dev/null
@@ -0,0 +1,60 @@
+<?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/>.
+
+/**
+ * Display the request reject + resubmit confirmation page.
+ *
+ * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
+ * @package tool_dataprivacy
+ */
+
+require_once('../../../config.php');
+
+$requestid = required_param('requestid', PARAM_INT);
+$confirm = optional_param('confirm', null, PARAM_INT);
+
+$PAGE->set_url(new moodle_url('/admin/tool/dataprivacy/resubmitrequest.php', ['requestid' => $requestid]));
+
+require_login();
+
+$PAGE->set_context(\context_system::instance());
+require_capability('tool/dataprivacy:managedatarequests', $PAGE->context);
+
+$manageurl = new moodle_url('/admin/tool/dataprivacy/datarequests.php');
+
+$originalrequest = \tool_dataprivacy\api::get_request($requestid);
+$user = \core_user::get_user($originalrequest->get('userid'));
+$stringparams = (object) [
+        'username' => fullname($user),
+        'type' => \tool_dataprivacy\local\helper::get_shortened_request_type_string($originalrequest->get('type')),
+    ];
+
+if (null !== $confirm && confirm_sesskey()) {
+    $originalrequest->resubmit_request();
+    redirect($manageurl, get_string('resubmittedrequest', 'tool_dataprivacy', $stringparams));
+}
+
+$heading = get_string('resubmitrequest', 'tool_dataprivacy', $stringparams);
+$PAGE->set_title($heading);
+$PAGE->set_heading($heading);
+
+echo $OUTPUT->header();
+
+$confirmstring = get_string('confirmrequestresubmit', 'tool_dataprivacy', $stringparams);
+$confirmurl = new \moodle_url($PAGE->url, ['confirm' => 1]);
+echo $OUTPUT->confirm($confirmstring, $confirmurl, $manageurl);
+echo $OUTPUT->footer();
index a434384..de3d715 100644 (file)
@@ -34,6 +34,14 @@ if ($hassiteconfig) {
                 new lang_string('contactdataprotectionofficer_desc', 'tool_dataprivacy'), 0)
         );
 
+        // Automatically create delete data request for users upon user deletion.
+        // Automatically create delete data request for pre-existing deleted users.
+        // Enabled by default.
+        $privacysettings->add(new admin_setting_configcheckbox('tool_dataprivacy/automaticdeletionrequests',
+                new lang_string('automaticdeletionrequests', 'tool_dataprivacy'),
+                new lang_string('automaticdeletionrequests_desc', 'tool_dataprivacy'), 1)
+        );
+
         // Set days approved data requests will be accessible. 1 week default.
         $privacysettings->add(new admin_setting_configduration('tool_dataprivacy/privacyrequestexpiry',
                 new lang_string('privacyrequestexpiry', 'tool_dataprivacy'),
index ec6c171..a1a5de9 100644 (file)
@@ -2052,4 +2052,116 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
             'category' => $cat,
         ];
     }
+
+    /**
+     * Ensure that the find_ongoing_request_types_for_users only returns requests which are active.
+     */
+    public function test_find_ongoing_request_types_for_users() {
+        $this->resetAfterTest();
+
+        // Create users and their requests:.
+        // - u1 has no requests of any type.
+        // - u2 has one rejected export request.
+        // - u3 has one rejected other request.
+        // - u4 has one rejected delete request.
+        // - u5 has one active and one rejected export request.
+        // - u6 has one active and one rejected other request.
+        // - u7 has one active and one rejected delete request.
+        // - u8 has one active export, and one active delete request.
+        $u1 = $this->getDataGenerator()->create_user();
+        $u1expect = (object) [];
+
+        $u2 = $this->getDataGenerator()->create_user();
+        $this->create_request_with_type_and_status($u2->id, api::DATAREQUEST_TYPE_EXPORT, api::DATAREQUEST_STATUS_REJECTED);
+        $u2expect = (object) [];
+
+        $u3 = $this->getDataGenerator()->create_user();
+        $this->create_request_with_type_and_status($u3->id, api::DATAREQUEST_TYPE_OTHERS, api::DATAREQUEST_STATUS_REJECTED);
+        $u3expect = (object) [];
+
+        $u4 = $this->getDataGenerator()->create_user();
+        $this->create_request_with_type_and_status($u4->id, api::DATAREQUEST_TYPE_DELETE, api::DATAREQUEST_STATUS_REJECTED);
+        $u4expect = (object) [];
+
+        $u5 = $this->getDataGenerator()->create_user();
+        $this->create_request_with_type_and_status($u5->id, api::DATAREQUEST_TYPE_EXPORT, api::DATAREQUEST_STATUS_REJECTED);
+        $this->create_request_with_type_and_status($u5->id, api::DATAREQUEST_TYPE_EXPORT, api::DATAREQUEST_STATUS_APPROVED);
+        $u5expect = (object) [
+            api::DATAREQUEST_TYPE_EXPORT => true,
+        ];
+
+        $u6 = $this->getDataGenerator()->create_user();
+        $this->create_request_with_type_and_status($u6->id, api::DATAREQUEST_TYPE_OTHERS, api::DATAREQUEST_STATUS_REJECTED);
+        $this->create_request_with_type_and_status($u6->id, api::DATAREQUEST_TYPE_OTHERS, api::DATAREQUEST_STATUS_APPROVED);
+        $u6expect = (object) [
+            api::DATAREQUEST_TYPE_OTHERS => true,
+        ];
+
+        $u7 = $this->getDataGenerator()->create_user();
+        $this->create_request_with_type_and_status($u7->id, api::DATAREQUEST_TYPE_DELETE, api::DATAREQUEST_STATUS_REJECTED);
+        $this->create_request_with_type_and_status($u7->id, api::DATAREQUEST_TYPE_DELETE, api::DATAREQUEST_STATUS_APPROVED);
+        $u7expect = (object) [
+            api::DATAREQUEST_TYPE_DELETE => true,
+        ];
+
+        $u8 = $this->getDataGenerator()->create_user();
+        $this->create_request_with_type_and_status($u8->id, api::DATAREQUEST_TYPE_EXPORT, api::DATAREQUEST_STATUS_APPROVED);
+        $this->create_request_with_type_and_status($u8->id, api::DATAREQUEST_TYPE_DELETE, api::DATAREQUEST_STATUS_APPROVED);
+        $u8expect = (object) [
+            api::DATAREQUEST_TYPE_EXPORT => true,
+            api::DATAREQUEST_TYPE_DELETE => true,
+        ];
+
+        // Test with no users specified.
+        $result = api::find_ongoing_request_types_for_users([]);
+        $this->assertEquals([], $result);
+
+        // Fetch a subset of the users.
+        $result = api::find_ongoing_request_types_for_users([$u3->id, $u4->id, $u5->id]);
+        $this->assertEquals([
+                $u3->id => $u3expect,
+                $u4->id => $u4expect,
+                $u5->id => $u5expect,
+            ], $result);
+
+        // Fetch the empty user.
+        $result = api::find_ongoing_request_types_for_users([$u1->id]);
+        $this->assertEquals([
+                $u1->id => $u1expect,
+            ], $result);
+
+        // Fetch all.
+        $result = api::find_ongoing_request_types_for_users(
+            [$u1->id, $u2->id, $u3->id, $u4->id, $u5->id, $u6->id, $u7->id, $u8->id]);
+        $this->assertEquals([
+                $u1->id => $u1expect,
+                $u2->id => $u2expect,
+                $u3->id => $u3expect,
+                $u4->id => $u4expect,
+                $u5->id => $u5expect,
+                $u6->id => $u6expect,
+                $u7->id => $u7expect,
+                $u8->id => $u8expect,
+            ], $result);
+    }
+
+    /**
+     * Create  a new data request for the user with the type and status specified.
+     *
+     * @param   int     $userid
+     * @param   int     $type
+     * @param   int     $status
+     * @return  \tool_dataprivacy\data_request
+     */
+    protected function create_request_with_type_and_status(int $userid, int $type, int $status) : \tool_dataprivacy\data_request {
+        $request = new \tool_dataprivacy\data_request(0, (object) [
+            'userid' => $userid,
+            'type' => $type,
+            'status' => $status,
+        ]);
+
+        $request->save();
+
+        return $request;
+    }
 }
diff --git a/admin/tool/dataprivacy/tests/data_request_test.php b/admin/tool/dataprivacy/tests/data_request_test.php
new file mode 100644 (file)
index 0000000..e10c12d
--- /dev/null
@@ -0,0 +1,241 @@
+<?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/>.
+
+/**
+ * Tests for the data_request persistent.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+require_once('data_privacy_testcase.php');
+
+use tool_dataprivacy\api;
+
+/**
+ * Tests for the data_request persistent.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_dataprivacy_data_request_testcase extends data_privacy_testcase {
+
+    /**
+     * Data provider for testing is_resettable, and is_active.
+     *
+     * @return  array
+     */
+    public function status_state_provider() : array {
+        return [
+            [
+                'state' => api::DATAREQUEST_STATUS_PENDING,
+                'resettable' => false,
+                'active' => false,
+            ],
+            [
+                'state' => api::DATAREQUEST_STATUS_AWAITING_APPROVAL,
+                'resettable' => false,
+                'active' => false,
+            ],
+            [
+                'state' => api::DATAREQUEST_STATUS_APPROVED,
+                'resettable' => true,
+                'active' => true,
+            ],
+            [
+                'state' => api::DATAREQUEST_STATUS_PROCESSING,
+                'resettable' => false,
+                'active' => false,
+            ],
+            [
+                'state' => api::DATAREQUEST_STATUS_COMPLETE,
+                'resettable' => false,
+                'active' => false,
+            ],
+            [
+                'state' => api::DATAREQUEST_STATUS_CANCELLED,
+                'resettable' => false,
+                'active' => false,
+            ],
+            [
+                'state' => api::DATAREQUEST_STATUS_REJECTED,
+                'resettable' => true,
+                'active' => false,
+            ],
+            [
+                'state' => api::DATAREQUEST_STATUS_DOWNLOAD_READY,
+                'resettable' => false,
+                'active' => false,
+            ],
+            [
+                'state' => api::DATAREQUEST_STATUS_EXPIRED,
+                'resettable' => false,
+                'active' => false,
+            ],
+        ];
+    }
+
+    /**
+     * Test the pseudo states of a data request with an export request.
+     *
+     * @dataProvider        status_state_provider
+     * @param       int     $status
+     * @param       bool    $resettable
+     * @param       bool    $active
+     */
+    public function test_pseudo_states_export(int $status, bool $resettable, bool $active) {
+        $uut = new \tool_dataprivacy\data_request();
+        $uut->set('status', $status);
+        $uut->set('type', api::DATAREQUEST_TYPE_EXPORT);
+
+        $this->assertEquals($resettable, $uut->is_resettable());
+        $this->assertEquals($active, $uut->is_active());
+    }
+
+    /**
+     * Test the pseudo states of a data request with a delete request.
+     *
+     * @dataProvider        status_state_provider
+     * @param       int     $status
+     * @param       bool    $resettable
+     * @param       bool    $active
+     */
+    public function test_pseudo_states_delete(int $status, bool $resettable, bool $active) {
+        $uut = new \tool_dataprivacy\data_request();
+        $uut->set('status', $status);
+        $uut->set('type', api::DATAREQUEST_TYPE_DELETE);
+
+        $this->assertEquals($resettable, $uut->is_resettable());
+        $this->assertEquals($active, $uut->is_active());
+    }
+
+    /**
+     * Test the pseudo states of a data request.
+     *
+     * @dataProvider        status_state_provider
+     * @param       int     $status
+     */
+    public function test_can_reset_others($status) {
+        $uut = new \tool_dataprivacy\data_request();
+        $uut->set('status', $status);
+        $uut->set('type', api::DATAREQUEST_TYPE_OTHERS);
+
+        $this->assertFalse($uut->is_resettable());
+    }
+
+    /**
+     * Data provider for states which are not resettable.
+     *
+     * @return      array
+     */
+    public function non_resettable_provider() : array {
+        $states = [];
+        foreach ($this->status_state_provider() as $thisstatus) {
+            if (!$thisstatus['resettable']) {
+                $states[] = $thisstatus;
+            }
+        }
+
+        return $states;
+    }
+
+    /**
+     * Ensure that requests which are not resettable cause an exception to be thrown.
+     *
+     * @dataProvider        non_resettable_provider
+     * @param       int     $status
+     */
+    public function test_non_resubmit_request($status) {
+        $uut = new \tool_dataprivacy\data_request();
+        $uut->set('status', $status);
+
+        $this->expectException(\moodle_exception::class);
+        $this->expectExceptionMessage(get_string('cannotreset', 'tool_dataprivacy'));
+
+        $uut->resubmit_request();
+    }
+
+    /**
+     * Ensure that a rejected request can be reset.
+     */
+    public function test_resubmit_request() {
+        $this->resetAfterTest();
+
+        $uut = new \tool_dataprivacy\data_request();
+        $uut->set('status', api::DATAREQUEST_STATUS_REJECTED);
+        $uut->set('type', api::DATAREQUEST_TYPE_DELETE);
+        $uut->set('comments', 'Foo');
+        $uut->set('requestedby', 42);
+        $uut->set('dpo', 98);
+
+        $newrequest = $uut->resubmit_request();
+
+        $this->assertEquals('Foo', $newrequest->get('comments'));
+        $this->assertEquals(42, $newrequest->get('requestedby'));
+        $this->assertEquals(98, $newrequest->get('dpo'));
+        $this->assertEquals(api::DATAREQUEST_STATUS_PENDING, $newrequest->get('status'));
+        $this->assertEquals(api::DATAREQUEST_TYPE_DELETE, $newrequest->get('type'));
+
+        $this->assertEquals(api::DATAREQUEST_STATUS_REJECTED, $uut->get('status'));
+    }
+
+    /**
+     * Ensure that an active request can be reset.
+     */
+    public function test_resubmit_active_request() {
+        $this->resetAfterTest();
+
+        $uut = new \tool_dataprivacy\data_request();
+        $uut->set('status', api::DATAREQUEST_STATUS_APPROVED);
+        $uut->set('type', api::DATAREQUEST_TYPE_DELETE);
+        $uut->set('comments', 'Foo');
+        $uut->set('requestedby', 42);
+        $uut->set('dpo', 98);
+
+        $newrequest = $uut->resubmit_request();
+
+        $this->assertEquals('Foo', $newrequest->get('comments'));
+        $this->assertEquals(42, $newrequest->get('requestedby'));
+        $this->assertEquals(98, $newrequest->get('dpo'));
+        $this->assertEquals(api::DATAREQUEST_STATUS_PENDING, $newrequest->get('status'));
+        $this->assertEquals(api::DATAREQUEST_TYPE_DELETE, $newrequest->get('type'));
+
+        $this->assertEquals(api::DATAREQUEST_STATUS_REJECTED, $uut->get('status'));
+    }
+
+    /**
+     * Create a data request for the user.
+     *
+     * @param   int     $userid
+     * @param   int     $type
+     * @param   int     $status
+     * @return  data_request
+     */
+    public function create_request_for_user_with_status(int $userid, int $type, int $status) : data_request {
+        $request = new data_request(0, (object) [
+                'userid' => $userid,
+                'type' => $type,
+                'status' => $status,
+            ]);
+
+        $request->save();
+
+        return $request;
+    }
+}
index 6058ac5..08ffb80 100644 (file)
@@ -97,7 +97,7 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
             api::set_contextlevel($record);
         } else {
             list($purposevar, ) = data_registry::var_names_from_context(
-                    \context_helper::get_class_for_level(CONTEXT_COURSE)
+                    \context_helper::get_class_for_level($contextlevel)
                 );
             set_config($purposevar, $purpose->get('id'), 'tool_dataprivacy');
         }
@@ -1719,18 +1719,17 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
         $manager->set_progress(new \null_progress_trace());
         $manager->method('get_privacy_manager')->willReturn($mockprivacymanager);
 
-        $purposes->course->set('retentionperiod', 'P5Y');
-        $purposes->course->save();
+        // Changing the retention period to a longer period will remove the expired_context record.
+        $purposes->activity->set('retentionperiod', 'P5Y');
+        $purposes->activity->save();
 
         list($processedcourses, $processedusers) = $manager->process_approved_deletions();
 
         $this->assertEquals(0, $processedcourses);
         $this->assertEquals(0, $processedusers);
 
+        $this->expectException('dml_missing_record_exception');
         $updatedcontext = new expired_context($expiredcontext->get('id'));
-
-        // No change - we just can't process it until the children have finished.
-        $this->assertEquals(expired_context::STATUS_APPROVED, $updatedcontext->get('status'));
     }
 
     /**
@@ -2186,8 +2185,6 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
      * Test the is_context_expired functions when supplied with the system context.
      */
     public function test_is_context_expired_system() {
-        global $DB;
-
         $this->resetAfterTest();
         $this->setup_basics('PT1H', 'PT1H', 'P1D');
         $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
@@ -2197,12 +2194,38 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
                 expired_contexts_manager::is_context_expired_or_unprotected_for_user(\context_system::instance(), $user));
     }
 
+    /**
+     * Test the is_context_expired functions when supplied with a block in the user context.
+     *
+     * Children of a user context always follow the user expiry rather than any context level defaults (e.g. at the
+     * block level.
+     */
+    public function test_is_context_expired_user_block() {
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1D');
+        $purposes->block = $this->create_and_set_purpose_for_contextlevel('P5Y', CONTEXT_BLOCK);
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $this->setUser($user);
+        $block = $this->create_user_block('Title', 'Content', FORMAT_PLAIN);
+        $blockcontext = \context_block::instance($block->instance->id);
+        $this->setUser();
+
+        // Protected flags have no bearing on expiry of user subcontexts.
+        $this->assertTrue(expired_contexts_manager::is_context_expired($blockcontext));
+
+        $purposes->block->set('protected', 1)->save();
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($blockcontext, $user));
+
+        $purposes->block->set('protected', 0)->save();
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($blockcontext, $user));
+    }
+
     /**
      * Test the is_context_expired functions when supplied with an expired course.
      */
     public function test_is_context_expired_course_expired() {
-        global $DB;
-
         $this->resetAfterTest();
 
         $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1D');
@@ -2226,8 +2249,6 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
      * Test the is_context_expired functions when supplied with an unexpired course.
      */
     public function test_is_context_expired_course_unexpired() {
-        global $DB;
-
         $this->resetAfterTest();
 
         $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1D');
@@ -2247,6 +2268,42 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
         $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user));
     }
 
+    /**
+     * Test the is_context_expired functions when supplied with an unexpired course and a child context in the course which is protected.
+     *
+     * When a child context has a specific purpose set, then that purpose should be respected with respect to the
+     * course.
+     *
+     * If the course is still within the expiry period for the child context, then that child's protected flag should be
+     * respected, even when the course may have expired.
+     */
+    public function test_is_child_context_expired_course_unexpired_with_child() {
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1D', 'P1D');
+        $purposes->course->set('protected', 0)->save();
+        $purposes->activity->set('protected', 1)->save();
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+        $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() + WEEKSECS]);
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+
+        $coursecontext = \context_course::instance($course->id);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+        $forumcontext = \context_module::instance($cm->id);
+
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        $this->assertFalse(expired_contexts_manager::is_context_expired($coursecontext));
+        $this->assertFalse(expired_contexts_manager::is_context_expired($forumcontext));
+
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user));
+        $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($forumcontext, $user));
+
+        $purposes->activity->set('protected', 0)->save();
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($forumcontext, $user));
+    }
+
     /**
      * Test the is_context_expired functions when supplied with an expired course which has role overrides.
      */
index e6be1b8..1006364 100644 (file)
@@ -53,6 +53,10 @@ class tool_dataprivacy_task_testcase extends data_privacy_testcase {
 
         $this->resetAfterTest();
         $this->setAdminUser();
+
+        // Enable automatic creation of delete data requests.
+        set_config('automaticdeletionrequests', 1, 'tool_dataprivacy');
+
         // Create a user.
         $user = $this->getDataGenerator()->create_user();
         // Mark the user as deleted.
@@ -69,6 +73,35 @@ class tool_dataprivacy_task_testcase extends data_privacy_testcase {
                 [api::DATAREQUEST_TYPE_DELETE]));
     }
 
+    /**
+     * Ensure that a delete data request for pre-existing deleted users
+     * is not being created when automatic creation of delete data requests is disabled.
+     */
+    public function test_delete_existing_deleted_users_task_automatic_creation_disabled() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Disable automatic creation of delete data requests.
+        set_config('automaticdeletionrequests', 0, 'tool_dataprivacy');
+
+        // Create a user.
+        $user = $this->getDataGenerator()->create_user();
+        // Mark the user as deleted.
+        $user->deleted = 1;
+        $DB->update_record('user', $user);
+
+        // The user should not have a delete data request.
+        $this->assertCount(0, api::get_data_requests($user->id, [],
+            [api::DATAREQUEST_TYPE_DELETE]));
+
+        $this->execute_task('tool_dataprivacy\task\delete_existing_deleted_users');
+        // After running the scheduled task, the deleted user should still not have a delete data request.
+        $this->assertCount(0, api::get_data_requests($user->id, [],
+            [api::DATAREQUEST_TYPE_DELETE]));
+    }
+
     /**
      * Ensure that a delete data request for pre-existing deleted users
      * is created when there are existing non-delete data requests
@@ -79,6 +112,10 @@ class tool_dataprivacy_task_testcase extends data_privacy_testcase {
 
         $this->resetAfterTest();
         $this->setAdminUser();
+
+        // Enable automatic creation of delete data requests.
+        set_config('automaticdeletionrequests', 1, 'tool_dataprivacy');
+
         // Create a user.
         $user = $this->getDataGenerator()->create_user();
         // Create export data request for the user.
@@ -106,8 +143,14 @@ class tool_dataprivacy_task_testcase extends data_privacy_testcase {
      * for that particular user.
      */
     public function test_delete_existing_deleted_users_task_existing_ongoing_delete_data_requests() {
+        global $DB;
+
         $this->resetAfterTest();
         $this->setAdminUser();
+
+        // Enable automatic creation of delete data requests.
+        set_config('automaticdeletionrequests', 1, 'tool_dataprivacy');
+
         // Create a user.
         $user = $this->getDataGenerator()->create_user();
         $this->setUser($user);
@@ -120,9 +163,10 @@ class tool_dataprivacy_task_testcase extends data_privacy_testcase {
         $this->assertCount(1, api::get_data_requests($user->id,
                 [api::DATAREQUEST_STATUS_AWAITING_APPROVAL], [api::DATAREQUEST_TYPE_DELETE]));
 
-        $this->setAdminUser();
-        // Delete the user.
-        delete_user($user);
+        // Mark the user as deleted.
+        $user->deleted = 1;
+        $DB->update_record('user', $user);
+
         // The user should still have the existing ongoing delete data request.
         $this->assertCount(1, \tool_dataprivacy\api::get_data_requests($user->id,
                 [api::DATAREQUEST_STATUS_AWAITING_APPROVAL], [api::DATAREQUEST_TYPE_DELETE]));
@@ -142,8 +186,14 @@ class tool_dataprivacy_task_testcase extends data_privacy_testcase {
      * for that particular user.
      */
     public function test_delete_existing_deleted_users_task_existing_finished_delete_data_requests() {
+        global $DB;
+
         $this->resetAfterTest();
         $this->setAdminUser();
+
+        // Enable automatic creation of delete data requests.
+        set_config('automaticdeletionrequests', 1, 'tool_dataprivacy');
+
         // Create a user.
         $user = $this->getDataGenerator()->create_user();
         $this->setUser($user);
@@ -158,9 +208,10 @@ class tool_dataprivacy_task_testcase extends data_privacy_testcase {
         // The user should not have an ongoing data requests.
         $this->assertFalse(api::has_ongoing_request($user->id, api::DATAREQUEST_TYPE_DELETE));
 
-        $this->setAdminUser();
-        // Delete the user.
-        delete_user($user);
+        // Mark the user as deleted.
+        $user->deleted = 1;
+        $DB->update_record('user', $user);
+
         // The user should still have the existing finished delete data request.
         $this->assertCount(1, \tool_dataprivacy\api::get_data_requests($user->id,
                 [api::DATAREQUEST_STATUS_CANCELLED], [api::DATAREQUEST_TYPE_DELETE]));
diff --git a/admin/tool/dataprivacy/tests/user_deleted_observer_test.php b/admin/tool/dataprivacy/tests/user_deleted_observer_test.php
new file mode 100644 (file)
index 0000000..64a49cb
--- /dev/null
@@ -0,0 +1,203 @@
+<?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/>.
+
+/**
+ * Tests for the event observer.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Mihail Geshoski <mihail@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use \tool_dataprivacy\event\user_deleted_observer;
+use \tool_dataprivacy\api;
+
+/**
+ * Event observer test.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Mihail Geshoski <mihail@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_dataprivacy_user_deleted_observer_testcase extends advanced_testcase {
+
+    /**
+     * Ensure that a delete data request is created upon user deletion.
+     */
+    public function test_create_delete_data_request() {
+        $this->resetAfterTest();
+
+        // Enable automatic creation of delete data requests.
+        set_config('automaticdeletionrequests', 1, 'tool_dataprivacy');
+
+        // Create another user who is not a DPO.
+        $user = $this->getDataGenerator()->create_user();
+
+        $event = $this->trigger_delete_user_event($user);
+
+        user_deleted_observer::create_delete_data_request($event);
+        // Validate that delete data request has been created.
+        $this->assertTrue(api::has_ongoing_request($user->id, api::DATAREQUEST_TYPE_DELETE));
+    }
+
+    /**
+     * Ensure that a delete data request is not created upon user deletion if automatic creation of
+     * delete data requests is disabled.
+     */
+    public function test_create_delete_data_request_automatic_creation_disabled() {
+        $this->resetAfterTest();
+
+        // Disable automatic creation of delete data requests.
+        set_config('automaticdeletionrequests', 0, 'tool_dataprivacy');
+
+        // Create another user who is not a DPO.
+        $user = $this->getDataGenerator()->create_user();
+
+        $event = $this->trigger_delete_user_event($user);
+
+        user_deleted_observer::create_delete_data_request($event);
+        // Validate that delete data request has been created.
+        $this->assertFalse(api::has_ongoing_request($user->id, api::DATAREQUEST_TYPE_DELETE));
+    }
+
+    /**
+     * Ensure that a delete data request is being created upon user deletion
+     * if an ongoing export data request (or any other except delete data request) for that user already exists.
+     */
+    public function test_create_delete_data_request_export_data_request_preexists() {
+        $this->resetAfterTest();
+
+        // Enable automatic creation of delete data requests.
+        set_config('automaticdeletionrequests', 1, 'tool_dataprivacy');
+
+        // Create another user who is not a DPO.
+        $user = $this->getDataGenerator()->create_user();
+        // Create a delete data request for $user.
+        api::create_data_request($user->id, api::DATAREQUEST_TYPE_EXPORT);
+        // Validate that delete data request has been created.
+        $this->assertTrue(api::has_ongoing_request($user->id, api::DATAREQUEST_TYPE_EXPORT));
+        $this->assertEquals(0, api::get_data_requests_count($user->id, [], [api::DATAREQUEST_TYPE_DELETE]));
+
+        $event = $this->trigger_delete_user_event($user);
+
+        user_deleted_observer::create_delete_data_request($event);
+        // Validate that delete data request has been created.
+        $this->assertEquals(1, api::get_data_requests_count($user->id, [], [api::DATAREQUEST_TYPE_DELETE]));
+    }
+
+    /**
+     * Ensure that a delete data request is not being created upon user deletion
+     * if an ongoing delete data request for that user already exists.
+     */
+    public function test_create_delete_data_request_ongoing_delete_data_request_preexists() {
+        $this->resetAfterTest();
+
+        // Enable automatic creation of delete data requests.
+        set_config('automaticdeletionrequests', 1, 'tool_dataprivacy');
+
+        // Create another user who is not a DPO.
+        $user = $this->getDataGenerator()->create_user();
+        // Create a delete data request for $user.
+        api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE);
+        // Validate that delete data request has been created.
+        $this->assertTrue(api::has_ongoing_request($user->id, api::DATAREQUEST_TYPE_DELETE));
+
+        $event = $this->trigger_delete_user_event($user);
+
+        user_deleted_observer::create_delete_data_request($event);
+        // Validate that additional delete data request has not been created.
+        $this->assertEquals(1, api::get_data_requests_count($user->id, [], [api::DATAREQUEST_TYPE_DELETE]));
+    }
+
+    /**
+     * Ensure that a delete data request is being created upon user deletion
+     * if a finished delete data request (excluding complete) for that user already exists.
+     */
+    public function test_create_delete_data_request_canceled_delete_data_request_preexists() {
+        $this->resetAfterTest();
+
+        // Enable automatic creation of delete data requests.
+        set_config('automaticdeletionrequests', 1, 'tool_dataprivacy');
+
+        // Create another user who is not a DPO.
+        $user = $this->getDataGenerator()->create_user();
+        // Create a delete data request for $user.
+        $datarequest = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE);
+        $requestid = $datarequest->get('id');
+        api::update_request_status($requestid, api::DATAREQUEST_STATUS_CANCELLED);
+
+        // Validate that delete data request has been created and the status has been updated to 'Canceled'.
+        $this->assertEquals(1, api::get_data_requests_count($user->id, [], [api::DATAREQUEST_TYPE_DELETE]));
+        $this->assertFalse(api::has_ongoing_request($user->id, api::DATAREQUEST_TYPE_DELETE));
+
+        $event = $this->trigger_delete_user_event($user);
+
+        user_deleted_observer::create_delete_data_request($event);
+        // Validate that additional delete data request has been created.
+        $this->assertEquals(2, api::get_data_requests_count($user->id, [], [api::DATAREQUEST_TYPE_DELETE]));
+        $this->assertTrue(api::has_ongoing_request($user->id, api::DATAREQUEST_TYPE_DELETE));
+    }
+
+    /**
+     * Ensure that a delete data request is being created upon user deletion
+     * if a completed delete data request for that user already exists.
+     */
+    public function test_create_delete_data_request_completed_delete_data_request_preexists() {
+        $this->resetAfterTest();
+
+        // Enable automatic creation of delete data requests.
+        set_config('automaticdeletionrequests', 1, 'tool_dataprivacy');
+
+        // Create another user who is not a DPO.
+        $user = $this->getDataGenerator()->create_user();
+        // Create a delete data request for $user.
+        $datarequest = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE);
+        $requestid = $datarequest->get('id');
+        api::update_request_status($requestid, api::DATAREQUEST_STATUS_COMPLETE);
+
+        // Validate that delete data request has been created and the status has been updated to 'Completed'.
+        $this->assertEquals(1, api::get_data_requests_count($user->id, [], [api::DATAREQUEST_TYPE_DELETE]));
+        $this->assertFalse(api::has_ongoing_request($user->id, api::DATAREQUEST_TYPE_DELETE));
+
+        $event = $this->trigger_delete_user_event($user);
+
+        user_deleted_observer::create_delete_data_request($event);
+        // Validate that additional delete data request has not been created.
+        $this->assertEquals(1, api::get_data_requests_count($user->id, [], [api::DATAREQUEST_TYPE_DELETE]));
+        $this->assertFalse(api::has_ongoing_request($user->id, api::DATAREQUEST_TYPE_DELETE));
+    }
+
+    /**
+     * Helper to trigger and capture the delete user event.
+     *
+     * @param object $user The user object.
+     * @return \core\event\user_deleted $event The returned event.
+     */
+    private function trigger_delete_user_event($user) {
+
+        $sink = $this->redirectEvents();
+        delete_user($user);
+        $events = $sink->get_events();
+        $sink->close();
+        $event = reset($events);
+        // Validate event data.
+        $this->assertInstanceOf('\core\event\user_deleted', $event);
+
+        return $event;
+    }
+}
index 5fcd7f7..71cf2bb 100644 (file)
@@ -336,6 +336,7 @@ class api {
         $mainmenu = new lang_string('mainmenu', 'tool_mobile');
         $course = new lang_string('course');
         $modules = new lang_string('managemodules');
+        $blocks = new lang_string('blocks');
         $user = new lang_string('user');
         $files = new lang_string('files');
         $remoteaddons = new lang_string('remoteaddons', 'tool_mobile');
@@ -359,6 +360,25 @@ class api {
 
         }
 
+        // Display blocks.
+        $availableblocks = core_plugin_manager::instance()->get_plugins_of_type('block');
+        $courseblocks = array();
+        $appsupportedblocks = array(
+            'activity_modules' => 'CoreBlockDelegate_AddonBlockActivityModules',
+            'site_main_menu' => 'CoreBlockDelegate_AddonBlockSiteMainMenu',
+            'myoverview' => 'CoreBlockDelegate_AddonBlockMyOverview',
+            'timeline' => 'CoreBlockDelegate_AddonBlockTimeline',
+            'recentlyaccessedcourses' => 'CoreBlockDelegate_AddonBlockRecentlyAccessedCourses',
+            'starredcourses' => 'CoreBlockDelegate_AddonBlockStarredCourses',
+            'recentlyaccesseditems' => 'CoreBlockDelegate_AddonBlockRecentlyAccessedItems',
+        );
+
+        foreach ($availableblocks as $block) {
+            if (isset($appsupportedblocks[$block->name])) {
+                $courseblocks[$appsupportedblocks[$block->name]] = $block->displayname;
+            }
+        }
+
         $features = array(
             'NoDelegate_CoreOffline' => new lang_string('offlineuse', 'tool_mobile'),
             '$mmLoginEmailSignup' => new lang_string('startsignup'),
@@ -401,6 +421,7 @@ class api {
                 'files_upload' => new lang_string('upload'),
             ),
             "$modules" => $coursemodules,
+            "$blocks" => $courseblocks,
         );
 
         if (!empty($remoteaddonslist)) {
index d5a8a34..00db97e 100644 (file)
@@ -109,6 +109,7 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
         set_config('agedigitalconsentverification', 1);
         set_config('autolang', 1);
         set_config('lang', 'a_b');  // Set invalid lang.
+        set_config('disabledfeatures', 'myoverview', 'tool_mobile');
 
         list($authinstructions, $notusedformat) = external_format_text($authinstructions, FORMAT_MOODLE, $context->id);
         $expected['registerauth'] = 'email';
@@ -120,6 +121,7 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
         $expected['supportemail'] = $CFG->supportemail;
         $expected['autolang'] = '1';
         $expected['lang'] = ''; // Expect empty because it was set to an invalid lang.
+        $expected['tool_mobile_disabledfeatures'] = 'myoverview';
 
         if ($logourl = $OUTPUT->get_logo_url()) {
             $expected['logourl'] = $logourl->out(false);
index 584abb8..7034e97 100644 (file)
@@ -70,6 +70,8 @@ class login implements renderable, templatable {
     public $signupurl;
     /** @var string The user name to pre-fill the form with. */
     public $username;
+    /** @var string The csrf token to limit login to requests that come from the login form. */
+    public $logintoken;
 
     /**
      * Constructor.
@@ -109,6 +111,7 @@ class login implements renderable, templatable {
 
         // Identity providers.
         $this->identityproviders = \auth_plugin_base::get_identity_providers($authsequence);
+        $this->logintoken = \core\session\manager::get_login_token();
     }
 
     /**
@@ -141,6 +144,7 @@ class login implements renderable, templatable {
         $data->rememberusername = $this->rememberusername;
         $data->signupurl = $this->signupurl->out(false);
         $data->username = $this->username;
+        $data->logintoken = $this->logintoken;
 
         return $data;
     }
index 4c0ca9e..cd9e347 100644 (file)
@@ -1725,7 +1725,8 @@ class auth_plugin_ldap extends auth_plugin_base {
 
         // Here we want to trigger the whole authentication machinery
         // to make sure no step is bypassed...
-        $user = authenticate_user_login($username, $key);
+        $reason = null;
+        $user = authenticate_user_login($username, $key, false, $reason, false);
         if ($user) {
             complete_user_login($user);
 
index 07b5ce7..b177f7c 100644 (file)
         $frm->password = generate_password(8);
 
     /// Check if the user has actually submitted login data to us
+        $reason = null;
 
         if ($shibbolethauth->user_login($frm->username, $frm->password)
-                && $user = authenticate_user_login($frm->username, $frm->password)) {
+                && $user = authenticate_user_login($frm->username, $frm->password, false, $reason, false)) {
             complete_user_login($user);
 
             if (user_not_fully_set_up($USER, true)) {
index 144c1fd..9f1e23e 100644 (file)
@@ -54,6 +54,7 @@ if ($show_instructions) {
         </div>
         <form action="../../login/index.php" method="post" id="guestlogin">
           <div class="guestform">
+            <input type="hidden" name="logintoken" value="<?php echo s(\core\session\manager::get_login_token()); ?>" />
             <input type="hidden" name="username" value="guest" />
             <input type="hidden" name="password" value="guest" />
             <input type="submit" value="<?php print_string("loginguest") ?>" />
index 2ed931a..6877fcb 100644 (file)
     $PAGE->set_heading($site->fullname);
 
     echo $OUTPUT->header();
-    include("index_form.html");
+
+    if (isloggedin() and !isguestuser()) {
+        // Prevent logging when already logged in, we do not want them to relogin by accident because sesskey would be changed.
+        echo $OUTPUT->box_start();
+        $params = array('sesskey' => sesskey(), 'loginpage' => 1);
+        $logout = new single_button(new moodle_url('/login/logout.php', $params), get_string('logout'), 'post');
+        $continue = new single_button(new moodle_url('/'), get_string('cancel'), 'get');
+        echo $OUTPUT->confirm(get_string('alreadyloggedin', 'error', fullname($USER)), $logout, $continue);
+        echo $OUTPUT->box_end();
+    } else {
+        include("index_form.html");
+    }
+
     echo $OUTPUT->footer();
 
 
index 138cbbc..4e0502b 100644 (file)
@@ -1,6 +1,10 @@
 This files describes API changes in /auth/* - plugins,
 information provided here is intended especially for developers.
 
+=== 3.6 ===
+
+* Login forms generated from Moodle must include a login token to protect automated logins. See \core\session\manager::get_login_token().
+
 === 3.5 ===
 
 * The auth_db and auth_ldap plugins' implementations of update_user_record() have been removed and both now
index e17aea1..67d0d03 100644 (file)
@@ -86,6 +86,7 @@ class block_login extends block_base {
             $this->content->text .= '<div class="form-group">';
             $this->content->text .= '<input type="submit" class="btn btn-primary btn-block" value="'.get_string('login').'" />';
             $this->content->text .= '</div>';
+            $this->content->text .= '<input type="hidden" name="logintoken" value="'.s(\core\session\manager::get_login_token()).'" />';
 
             $this->content->text .= "</form>\n";
 
index fc3ebb9..cd5edb4 100644 (file)
@@ -33,6 +33,7 @@ $functions = array(
         'description' => 'Get users starred courses.',
         'type'        => 'read',
         'ajax'        => true,
+        'services'    => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
 );
 
index d48ecab..18cbe31 100644 (file)
@@ -594,6 +594,11 @@ $CFG->admin = 'admin';
 //
 //      $CFG->keepmessagingallusersenabled = true;
 //
+// Disable login token validation for login pages. Login token validation is enabled
+// by default unless $CFG->alternateloginurl is set.
+//
+//      $CFG->disablelogintoken = true;
+//
 //=========================================================================
 // 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!!
 //=========================================================================
index 2a4bb15..163e3d8 100644 (file)
@@ -327,7 +327,6 @@ $string['courseformatdata'] = 'Course format data';
 $string['courseformats'] = 'Course formats';
 $string['courseformatoptions'] = 'Formatting options for {$a}';
 $string['courseformatudpate'] = 'Update format';
-$string['courseheaderimage'] = 'Course header image';
 $string['courseprofiles'] = 'Course profiles';
 $string['coursepreferences'] = 'Course preferences';
 $string['coursegrades'] = 'Course grades';
@@ -1823,8 +1822,6 @@ $string['showallusers'] = 'Show all users';
 $string['showblockcourse'] = 'Show list of courses containing block';
 $string['showcategory'] = 'Show {$a}';
 $string['showchartdata'] = 'Show chart data';
-$string['showcourseimages'] = 'Show course images';
-$string['showcourseimages_desc'] = 'Show the course image or image placeholder in the course header.';
 $string['showcomments'] = 'Show/hide comments';
 $string['showcommentsnonjs'] = 'Show comments';
 $string['showdescription'] = 'Display description on course page';
index 6568e4b..a84424c 100644 (file)
@@ -46,6 +46,9 @@ class manager {
     /** @var bool $sessionactive Is the session active? */
     protected static $sessionactive = null;
 
+    /** @var string $logintokenkey Key used to get and store request protection for login form. */
+    protected static $logintokenkey = 'core_auth_login';
+
     /**
      * Start user session.
      *
@@ -923,4 +926,102 @@ class manager {
         )));
     }
 
+    /**
+     * Generate a new login token and store it in the session.
+     *
+     * @return array The current login state.
+     */
+    private static function create_login_token() {
+        global $SESSION;
+
+        $state = [
+            'token' => random_string(32),
+            'created' => time() // Server time - not user time.
+        ];
+
+        if (!isset($SESSION->logintoken)) {
+            $SESSION->logintoken = [];
+        }
+
+        // Overwrite any previous values.
+        $SESSION->logintoken[self::$logintokenkey] = $state;
+
+        return $state;
+    }
+
+    /**
+     * Get the current login token or generate a new one.
+     *
+     * All login forms generated from Moodle must include a login token
+     * named "logintoken" with the value being the result of this function.
+     * Logins will be rejected if they do not include this token as well as
+     * the username and password fields.
+     *
+     * @return string The current login token.
+     */
+    public static function get_login_token() {
+        global $CFG, $SESSION;
+
+        $state = false;
+
+        if (!isset($SESSION->logintoken)) {
+            $SESSION->logintoken = [];
+        }
+
+        if (array_key_exists(self::$logintokenkey, $SESSION->logintoken)) {
+            $state = $SESSION->logintoken[self::$logintokenkey];
+        }
+        if (empty($state)) {
+            $state = self::create_login_token();
+        }
+
+        // Check token lifespan.
+        if ($state['created'] < (time() - $CFG->sessiontimeout)) {
+            $state = self::create_login_token();
+        }
+
+        // Return the current session login token.
+        if (array_key_exists('token', $state)) {
+            return $state['token'];
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Check the submitted value against the stored login token.
+     *
+     * @param mixed $token The value submitted in the login form that we are validating.
+     *                     If false is passed for the token, this function will always return true.
+     * @return boolean If the submitted token is valid.
+     */
+    public static function validate_login_token($token = false) {
+        global $CFG;
+
+        if (!empty($CFG->alternateloginurl) || !empty($CFG->disablelogintoken)) {
+            // An external login page cannot generate the login token we need to protect CSRF on
+            // login requests.
+            // Other custom login workflows may skip this check by setting disablelogintoken in config.
+            return true;
+        }
+        if ($token === false) {
+            // authenticate_user_login is a core function was extended to validate tokens.
+            // For existing uses other than the login form it does not
+            // validate that a token was generated.
+            // Some uses that do not validate the token are login/token.php,
+            // or an auth plugin like auth/ldap/auth.php.
+            return true;
+        }
+
+        $currenttoken = self::get_login_token();
+
+        // We need to clean the login token so the old one is not valid again.
+        self::create_login_token();
+
+        if ($currenttoken !== $token) {
+            // Fail the login.
+            return false;
+        }
+        return true;
+    }
 }
index 53cc5eb..346ea3c 100644 (file)
@@ -542,7 +542,8 @@ $functions = array(
         'classpath' => 'course/externallib.php',
         'description' => 'List of enrolled courses for the given timeline classification (past, inprogress, or future).',
         'type' => 'read',
-        'ajax' => true
+        'ajax' => true,
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
     'core_course_get_recent_courses' => array(
         'classname' => 'core_course_external',
@@ -550,7 +551,17 @@ $functions = array(
         'classpath' => 'course/externallib.php',
         'description' => 'List of courses a user has accessed most recently.',
         'type' => 'read',
-        'ajax' => true
+        'ajax' => true,
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
+    'core_course_set_favourite_courses' => array(
+        'classname' => 'core_course_external',
+        'methodname' => 'set_favourite_courses',
+        'classpath' => 'course/externallib.php',
+        'description' => 'Add a list of courses to the list of favourite courses.',
+        'type' => 'read',
+        'ajax' => true,
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
     'core_enrol_get_course_enrolment_methods' => array(
         'classname' => 'core_enrol_external',
@@ -989,7 +1000,8 @@ $functions = array(
         'classname' => 'core_message_external',
         'methodname' => 'data_for_messagearea_search_users',
         'classpath' => 'message/externallib.php',
-        'description' => 'Retrieve the template data for searching for people',
+        'description' => '** DEPRECATED ** Please do not call this function any more.
+                          Retrieve the template data for searching for people',
         'type' => 'read',
         'ajax' => true,
     ),
@@ -997,9 +1009,19 @@ $functions = array(
         'classname' => 'core_message_external',
         'methodname' => 'data_for_messagearea_search_users_in_course',
         'classpath' => 'message/externallib.php',
-        'description' => 'Retrieve the template data for searching for people in a course',
+        'description' => '** DEPRECATED ** Please do not call this function any more.
+                          Retrieve the template data for searching for people in a course',
+        'type' => 'read',
+        'ajax' => true,
+    ),
+    'core_message_message_search_users' => array(
+        'classname' => 'core_message_external',
+        'methodname' => 'message_search_users',
+        'classpath' => 'message/externallib.php',
+        'description' => 'Retrieve the data for searching for people',
         'type' => 'read',
         'ajax' => true,
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
     'core_message_data_for_messagearea_conversations' => array(
         'classname' => 'core_message_external',
@@ -2319,14 +2341,6 @@ $functions = array(
         'type'        => 'read',
         'services'    => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
-    'core_course_set_favourite_courses' => array(
-        'classname' => 'core_course_external',
-        'methodname' => 'set_favourite_courses',
-        'classpath' => 'course/externallib.php',
-        'description' => 'Add a list of courses to the list of favourite courses.',
-        'type' => 'read',
-        'ajax' => true
-    )
 );
 
 $services = array(
index edd43cc..9b9fb47 100644 (file)
@@ -2666,12 +2666,6 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2018102900.00);
     }
 
-    if ($oldversion < 2018102900.01) {
-        // Show course images by default.
-        set_config('showcourseimages', 1, 'moodlecourse');
-        upgrade_main_savepoint(true, 2018102900.01);
-    }
-
     if ($oldversion < 2018110500.01) {
         // Define fields to be added to the 'badge' table.
         $tablebadge = new xmldb_table('badge');
@@ -2769,5 +2763,13 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2018110500.01);
     }
 
+    if ($oldversion < 2018110700.01) {
+        // This config setting added and then removed.
+        unset_config('showcourseimages', 'moodlecourse');
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2018110700.01);
+    }
+
     return true;
 }
index 18d328f..8aaca96 100644 (file)
@@ -110,7 +110,7 @@ class Less_Visitor_toCSS extends Less_VisitorReplacing{
 
 
                // Compile rules and rulesets
-               $nodeRuleCnt = count($rulesetNode->rules);
+               $nodeRuleCnt = $rulesetNode->rules?count($rulesetNode->rules):0;
                for( $i = 0; $i < $nodeRuleCnt; ){
                        $rule = $rulesetNode->rules[$i];
 
index fcfaf4b..2b9274d 100644 (file)
@@ -8,9 +8,13 @@ All the files from the folder lib/Less are copied in
 this directory. Only exception made for the directory
 '.easymin' which is not included.
 
-Verify that https://github.com/oyejorge/less.php/pull/367 has been applied to the imported version or apply it locally.
-
 Also copy the license file from the project root.
 
 Licensed under the Apache license 2.0.
 
+Modifications:
+* MDL-63422 - Verify that https://github.com/oyejorge/less.php/pull/367 has been applied to the
+    imported version or apply it locally. PHP 7.3 compatibility.
+* MDL-62294 - Cherry-picked upstream commit to fix PHP 7.2 compatibility when counting ruleset rules.
+    https://github.com/oyejorge/less.php/commit/669acc51817a8da162b5f1b7137e79f0e4acc636
+    TODO: Remove this note when this library gets upgraded to the latest release that already includes this fix.
index 6c760dd..faa4580 100644 (file)
@@ -2548,6 +2548,9 @@ function dayofweek($day, $month, $year) {
 /**
  * Returns full login url.
  *
+ * Any form submissions for authentication to this URL must include username,
+ * password as well as a logintoken generated by \core\session\manager::get_login_token().
+ *
  * @return string login url
  */
 function get_login_url() {
@@ -4286,9 +4289,10 @@ function guest_user() {
  * @param string $password  User's password
  * @param bool $ignorelockout useful when guessing is prevented by other mechanism such as captcha or SSO
  * @param int $failurereason login failure reason, can be used in renderers (it may disclose if account exists)
+ * @param mixed logintoken If this is set to a string it is validated against the login token for the session.
  * @return stdClass|false A {@link $USER} object or false if error
  */
-function authenticate_user_login($username, $password, $ignorelockout=false, &$failurereason=null) {
+function authenticate_user_login($username, $password, $ignorelockout=false, &$failurereason=null, $logintoken=false) {
     global $CFG, $DB;
     require_once("$CFG->libdir/authlib.php");
 
@@ -4310,6 +4314,18 @@ function authenticate_user_login($username, $password, $ignorelockout=false, &$f
         }
     }
 
+    // Make sure this request came from the login form.
+    if (!\core\session\manager::validate_login_token($logintoken)) {
+        $failurereason = AUTH_LOGIN_FAILED;
+
+        // Trigger login failed event.
+        $event = \core\event\user_login_failed::create(array('userid' => $user->id,
+                'other' => array('username' => $username, 'reason' => $failurereason)));
+        $event->trigger();
+        error_log('[client '.getremoteaddr()."]  $CFG->wwwroot  Invalid Login Token:  $username  ".$_SERVER['HTTP_USER_AGENT']);
+        return false;
+    }
+
     $authsenabled = get_enabled_auth_plugins();
 
     if ($user) {
index c3fd597..75f8e3a 100644 (file)
@@ -4153,7 +4153,7 @@ EOD;
     }
 
     public function context_header($headerinfo = null, $headinglevel = 1) {
-        global $DB, $USER, $CFG, $COURSE;
+        global $DB, $USER, $CFG;
         require_once($CFG->dirroot . '/user/lib.php');
         $context = $this->page->context;
         $heading = null;
@@ -4165,13 +4165,6 @@ EOD;
             $heading = $headerinfo['heading'];
         }
 
-        // Show a course image if enabled.
-        if ($context->contextlevel == CONTEXT_COURSE && get_config('moodlecourse', 'showcourseimages')) {
-            $exporter = new core_course\external\course_summary_exporter($COURSE, ['context' => $context]);
-            $courseinfo = $exporter->export($this);
-            $imagedata = $this->render_from_template('core/course_header_image', $courseinfo);
-        }
-
         // The user context currently has images and buttons. Other contexts may follow.
         if (isset($headerinfo['user']) || $context->contextlevel == CONTEXT_USER) {
             if (isset($headerinfo['user'])) {
diff --git a/lib/templates/course_header_image.mustache b/lib/templates/course_header_image.mustache
deleted file mode 100644 (file)
index 52ab679..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-{{!
-    This file is part of Moodle - http://moodle.org/
-
-    Moodle is free software: you can redistribute it and/or modify
-    it under the terms of the GNU General Public License as published by
-    the Free Software Foundation, either version 3 of the License, or
-    (at your option) any later version.
-
-    Moodle is distributed in the hope that it will be useful,
-    but WITHOUT ANY WARRANTY; without even the implied warranty of
-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-    GNU General Public License for more details.
-
-    You should have received a copy of the GNU General Public License
-    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-}}
-{{!
-    @template core/course_header_image
-
-    Example context (json):
-    {
-        "courseimage": "http://domain.name/pluginfile.php/123/course/overviewfiles/kitten.jpg"
-    }
-}}
-<div class="course-header-image-wrapper">
-    <div class="course-header-image rounded w-100 h-100" style='background-image: url("{{{courseimage}}}");'>
-        <div class="sr-only">{{#str}}courseheaderimage, core{{/str}}</div>
-    </div>
-</div>
\ No newline at end of file
index 616e860..7aa51a6 100644 (file)
@@ -35,7 +35,8 @@
         "rememberusername": true,
         "signupurl": "http://localhost/stable_master/login/signup.php",
         "cookieshelpiconformatted": "",
-        "username": ""
+        "username": "",
+        "logintoken": "randomstring"
     }
 }}
 {{#hasinstructions}}
@@ -95,6 +96,7 @@
                 <div class="clearer"><!-- --></div>
                 <input id="anchor" type="hidden" name="anchor" value="" />
                 <script>document.getElementById('anchor').value = location.hash;</script>
+                <input type="hidden" name="logintoken" value="{{logintoken}}">
                 <input type="submit" id="loginbtn" value={{#quote}}{{#str}} login {{/str}}{{/quote}} />
                 <div class="forgetpass">
                     <a href="{{forgotpasswordurl}}">{{#str}} forgotten {{/str}}</a>
                 <div class="desc">{{#str}} someallowguest {{/str}}</div>
                 <form action="{{loginurl}}" method="post" id="guestlogin">
                     <div class="guestform">
+                        <input type="hidden" name="logintoken" value="{{logintoken}}">
                         <input type="hidden" name="username" value="guest" />
                         <input type="hidden" name="password" value="guest" />
                         <input type="submit" value={{#quote}}{{#str}} loginguest {{/str}}{{/quote}} />
index 23b5042..961d669 100644 (file)
@@ -542,6 +542,18 @@ EOD;
 
         $id = groups_create_group((object)$record);
 
+        // Allow tests to set group pictures.
+        if (!empty($record['picturepath'])) {
+            require_once($CFG->dirroot . '/lib/gdlib.php');
+            $grouppicture = process_new_icon(\context_course::instance($record['courseid']), 'group', 'icon', $id,
+                $record['picturepath']);
+
+            $DB->set_field('groups', 'picture', $grouppicture, ['id' => $id]);
+
+            // Invalidate the group data as we've updated the group record.
+            cache_helper::invalidate_by_definition('core', 'groupdata', array(), [$record['courseid']]);
+        }
+
         return $DB->get_record('groups', array('id'=>$id));
     }
 
index 53ad307..e59e383 100644 (file)
@@ -200,6 +200,59 @@ class core_authlib_testcase extends advanced_testcase {
         $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_FAILED);
         $this->assertEventContextNotUsed($event);
 
+        // Capture failed login token.
+        unset($CFG->alternateloginurl);
+        unset($CFG->disablelogintoken);
+        $sink = $this->redirectEvents();
+        $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken');
+        $events = $sink->get_events();
+        $sink->close();
+        $event = array_pop($events);
+
+        $this->assertFalse($result);
+        $this->assertEquals(AUTH_LOGIN_FAILED, $reason);
+        // Test Event.
+        $this->assertInstanceOf('\core\event\user_login_failed', $event);
+        $expectedlogdata = array(SITEID, 'login', 'error', 'index.php', 'username1');
+        $this->assertEventLegacyLogData($expectedlogdata, $event);
+        $eventdata = $event->get_data();
+        $this->assertSame($eventdata['other']['username'], 'username1');
+        $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_FAILED);
+        $this->assertEventContextNotUsed($event);
+
+        // Login should work with invalid token if CFG login token settings override it.
+        $CFG->alternateloginurl = 'http://localhost/';
+        $sink = $this->redirectEvents();
+        $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken');
+        $events = $sink->get_events();
+        $sink->close();
+        $this->assertEmpty($events);
+        $this->assertInstanceOf('stdClass', $result);
+        $this->assertEquals(AUTH_LOGIN_OK, $reason);
+
+        unset($CFG->alternateloginurl);
+        $CFG->disablelogintoken = true;
+
+        $sink = $this->redirectEvents();
+        $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken');
+        $events = $sink->get_events();
+        $sink->close();
+        $this->assertEmpty($events);
+        $this->assertInstanceOf('stdClass', $result);
+        $this->assertEquals(AUTH_LOGIN_OK, $reason);
+
+        unset($CFG->disablelogintoken);
+        // Normal login with valid token.
+        $reason = null;
+        $token = \core\session\manager::get_login_token();
+        $sink = $this->redirectEvents();
+        $result = authenticate_user_login('username1', 'password1', false, $reason, $token);
+        $events = $sink->get_events();
+        $sink->close();
+        $this->assertEmpty($events);
+        $this->assertInstanceOf('stdClass', $result);
+        $this->assertEquals(AUTH_LOGIN_OK, $reason);
+
         $reason = null;
         // Capture failed login event.
         $sink = $this->redirectEvents();
index 66654f3..5064690 100644 (file)
@@ -85,9 +85,10 @@ class login_change_password_form extends moodleform {
     function validation($data, $files) {
         global $USER;
         $errors = parent::validation($data, $files);
+        $reason = null;
 
         // ignore submitted username
-        if (!$user = authenticate_user_login($USER->username, $data['password'], true)) {
+        if (!$user = authenticate_user_login($USER->username, $data['password'], true, $reason, false)) {
             $errors['password'] = get_string('invalidlogin');
             return $errors;
         }
index 923cb43..d62d7e1 100644 (file)
@@ -31,6 +31,8 @@ redirect_if_major_upgrade_required();
 
 $testsession = optional_param('testsession', 0, PARAM_INT); // test session works properly
 $anchor      = optional_param('anchor', '', PARAM_RAW);      // Used to restore hash anchor to wantsurl.
+$logintoken  = optional_param('logintoken', '', PARAM_RAW);       // Used to validate the request.
+
 $resendconfirmemail = optional_param('resendconfirmemail', false, PARAM_BOOL);
 
 $context = context_system::instance();
@@ -138,7 +140,7 @@ if ($frm and isset($frm->username)) {                             // Login WITH
         $frm = false;
     } else {
         if (empty($errormsg)) {
-            $user = authenticate_user_login($frm->username, $frm->password, false, $errorcode);
+            $user = authenticate_user_login($frm->username, $frm->password, false, $errorcode, $logintoken);
         }
     }
 
index a67b4b1..bf6fec5 100644 (file)
@@ -47,7 +47,8 @@ if (is_restored_user($username)) {
 
 $systemcontext = context_system::instance();
 
-$user = authenticate_user_login($username, $password);
+$reason = null;
+$user = authenticate_user_login($username, $password, false, $reason, false);
 if (!empty($user)) {
 
     // Cannot authenticate unless maintenance access is granted.
index b732e03..62995e3 100644 (file)
@@ -151,6 +151,11 @@ class api {
     /**
      * Handles searching for user in a particular course in the message area.
      *
+     * TODO: This function should be removed once new group messaging UI is in place and old messaging UI is removed.
+     * For now we are not removing/deprecating this function for backwards compatibility with messaging UI.
+     * But we are deprecating data_for_messagearea_search_users_in_course external function.
+     * Followup: MDL-63915
+     *
      * @param int $userid The user id doing the searching
      * @param int $courseid The id of the course we are searching in
      * @param string $search The string the user is searching
@@ -192,6 +197,11 @@ class api {
     /**
      * Handles searching for user in the message area.
      *
+     * TODO: This function should be removed once new group messaging UI is in place and old messaging UI is removed.
+     * For now we are not removing/deprecating this function for backwards compatibility with messaging UI.
+     * But we are deprecating data_for_messagearea_search_users external function.
+     * Followup: MDL-63915
+     *
      * @param int $userid The user id doing the searching
      * @param string $search The string the user is searching
      * @param int $limitnum
@@ -208,22 +218,23 @@ class api {
         $excludeusers = array($userid, $CFG->siteguest);
         list($exclude, $excludeparams) = $DB->get_in_or_equal($excludeusers, SQL_PARAMS_NAMED, 'param', false);
 
+        $params = array('search' => '%' . $search . '%', 'userid1' => $userid, 'userid2' => $userid, 'userid3' => $userid);
+
         // Ok, let's search for contacts first.
         $contacts = array();
         $sql = "SELECT $ufields, mub.id as isuserblocked
-                  FROM {user} u
-                  JOIN {message_contacts} mc
-                    ON u.id = mc.contactid
-             LEFT JOIN {message_users_blocked} mub
-                    ON (mub.userid = :userid2 AND mub.blockeduserid = u.id)
-                 WHERE mc.userid = :userid
-                   AND u.deleted = 0
-                   AND u.confirmed = 1
-                   AND " . $DB->sql_like($fullname, ':search', false) . "
-                   AND u.id $exclude
-              ORDER BY " . $DB->sql_fullname();
-        if ($users = $DB->get_records_sql($sql, array('userid' => $userid, 'userid2' => $userid,
-                'search' => '%' . $search . '%') + $excludeparams, 0, $limitnum)) {
+                FROM {user} u
+                JOIN {message_contacts} mc
+                  ON (u.id = mc.contactid AND mc.userid = :userid1) OR (u.id = mc.userid AND mc.contactid = :userid2)
+           LEFT JOIN {message_users_blocked} mub
+                  ON (mub.userid = :userid3 AND mub.blockeduserid = u.id)
+               WHERE u.deleted = 0
+                 AND u.confirmed = 1
+                 AND " . $DB->sql_like($fullname, ':search', false) . "
+                 AND u.id $exclude
+            ORDER BY " . $DB->sql_fullname();
+
+        if ($users = $DB->get_records_sql($sql, $params + $excludeparams, 0, $limitnum)) {
             foreach ($users as $user) {
                 $user->blocked = $user->isuserblocked ? 1 : 0;
                 $contacts[] = helper::create_contact($user);
@@ -251,39 +262,162 @@ class api {
             }
         }
 
-        // Let's get those non-contacts. Toast them gears boi.
-        // Note - you can only block contacts, so these users will not be blocked, so no need to get that
-        // extra detail from the database.
+        // Let's get those non-contacts.
         $noncontacts = array();
-        $sql = "SELECT $ufields
+        if ($CFG->messagingallusers) {
+            // In case $CFG->messagingallusers is enabled, search for all users site-wide but are not user's contact.
+            $sql = "SELECT $ufields
+                      FROM {user} u
+                 LEFT JOIN {message_users_blocked} mub
+                        ON (mub.userid = :userid1 AND mub.blockeduserid = u.id)
+                     WHERE u.deleted = 0
+                       AND u.confirmed = 1
+                       AND " . $DB->sql_like($fullname, ':search', false) . "
+                       AND u.id $exclude
+                       AND NOT EXISTS (SELECT mc.id
+                                         FROM {message_contacts} mc
+                                        WHERE (mc.userid = u.id AND mc.contactid = :userid2)
+                                           OR (mc.userid = :userid3 AND mc.contactid = u.id))
+                  ORDER BY " . $DB->sql_fullname();
+        } else {
+            // In case $CFG->messagingallusers is disabled, search for users you have a conversation with.
+            // Messaging setting could change, so could exist an old conversation with users you cannot message anymore.
+            $sql = "SELECT $ufields, mub.id as isuserblocked
+                      FROM {user} u
+                 LEFT JOIN {message_users_blocked} mub
+                        ON (mub.userid = :userid1 AND mub.blockeduserid = u.id)
+                INNER JOIN {message_conversation_members} cm
+                        ON u.id = cm.userid
+                INNER JOIN {message_conversation_members} cm2
+                        ON cm.conversationid = cm2.conversationid AND cm2.userid = :userid
+                     WHERE u.deleted = 0
+                       AND u.confirmed = 1
+                       AND " . $DB->sql_like($fullname, ':search', false) . "
+                       AND u.id $exclude
+                       AND NOT EXISTS (SELECT mc.id
+                                         FROM {message_contacts} mc
+                                        WHERE (mc.userid = u.id AND mc.contactid = :userid2)
+                                           OR (mc.userid = :userid3 AND mc.contactid = u.id))
+                  ORDER BY " . $DB->sql_fullname();
+            $params['userid'] = $userid;
+        }
+        if ($users = $DB->get_records_sql($sql,  $params + $excludeparams, 0, $limitnum)) {
+            foreach ($users as $user) {
+                $noncontacts[] = helper::create_contact($user);
+            }
+        }
+
+        return array($contacts, $courses, $noncontacts);
+    }
+
+    /**
+     * Handles searching for user.
+     *
+     * @param int $userid The user id doing the searching
+     * @param string $search The string the user is searching
+     * @param int $limitfrom
+     * @param int $limitnum
+     * @return array
+     */
+    public static function message_search_users(int $userid, string $search, int $limitfrom = 0, int $limitnum = 1000) : array {
+        global $CFG, $DB;
+
+        // Used to search for contacts.
+        $fullname = $DB->sql_fullname();
+
+        // Users not to include.
+        $excludeusers = array($userid, $CFG->siteguest);
+        list($exclude, $excludeparams) = $DB->get_in_or_equal($excludeusers, SQL_PARAMS_NAMED, 'param', false);
+
+        $params = array('search' => '%' . $search . '%', 'userid1' => $userid, 'userid2' => $userid);
+
+        // Ok, let's search for contacts first.
+        $sql = "SELECT u.id
                   FROM {user} u
+                  JOIN {message_contacts} mc
+                    ON (u.id = mc.contactid AND mc.userid = :userid1) OR (u.id = mc.userid AND mc.contactid = :userid2)
                  WHERE u.deleted = 0
                    AND u.confirmed = 1
                    AND " . $DB->sql_like($fullname, ':search', false) . "
                    AND u.id $exclude
-                   AND u.id NOT IN (SELECT contactid
-                                      FROM {message_contacts}
-                                     WHERE userid = :userid)
               ORDER BY " . $DB->sql_fullname();
-        if ($users = $DB->get_records_sql($sql,  array('userid' => $userid, 'search' => '%' . $search . '%') + $excludeparams,
-                0, $limitnum)) {
-            foreach ($users as $user) {
-                $noncontacts[] = helper::create_contact($user);
+        $foundusers = $DB->get_records_sql_menu($sql, $params + $excludeparams, $limitfrom, $limitnum);
+
+        $orderedcontacs = array();
+        if (!empty($foundusers)) {
+            $contacts = helper::get_member_info($userid, array_keys($foundusers));
+            // The get_member_info returns an associative array, so is not ordered in the same way.
+            // We need to reorder it again based on query's result.
+            foreach ($foundusers as $key => $value) {
+                $contact = $contacts[$key];
+                $contact->conversations = self::get_conversations_between_users($userid, $key, 0, 1000);
+                $orderedcontacs[] = $contact;
             }
         }
 
-        return array($contacts, $courses, $noncontacts);
+        // Let's get those non-contacts.
+        if ($CFG->messagingallusers) {
+            // In case $CFG->messagingallusers is enabled, search for all users site-wide but are not user's contact.
+            $sql = "SELECT u.id
+                      FROM {user} u
+                     WHERE u.deleted = 0
+                       AND u.confirmed = 1
+                       AND " . $DB->sql_like($fullname, ':search', false) . "
+                       AND u.id $exclude
+                       AND NOT EXISTS (SELECT mc.id
+                                         FROM {message_contacts} mc
+                                        WHERE (mc.userid = u.id AND mc.contactid = :userid1)
+                                           OR (mc.userid = :userid2 AND mc.contactid = u.id))
+                  ORDER BY " . $DB->sql_fullname();
+        } else {
+            // In case $CFG->messagingallusers is disabled, search for users you have a conversation with.
+            // Messaging setting could change, so could exist an old conversation with users you cannot message anymore.
+            $sql = "SELECT u.id
+                      FROM {user} u
+                INNER JOIN {message_conversation_members} cm
+                        ON u.id = cm.userid
+                INNER JOIN {message_conversation_members} cm2
+                        ON cm.conversationid = cm2.conversationid AND cm2.userid = :userid
+                     WHERE u.deleted = 0
+                       AND u.confirmed = 1
+                       AND " . $DB->sql_like($fullname, ':search', false) . "
+                       AND u.id $exclude
+                       AND NOT EXISTS (SELECT mc.id
+                                         FROM {message_contacts} mc
+                                        WHERE (mc.userid = u.id AND mc.contactid = :userid1)
+                                           OR (mc.userid = :userid2 AND mc.contactid = u.id))
+                  ORDER BY " . $DB->sql_fullname();
+            $params['userid'] = $userid;
+        }
+        $foundusers = $DB->get_records_sql_menu($sql, $params + $excludeparams, $limitfrom, $limitnum);
+
+        $orderednoncontacs = array();
+        if (!empty($foundusers)) {
+            $noncontacts = helper::get_member_info($userid, array_keys($foundusers));
+            // The get_member_info returns an associative array, so is not ordered in the same way.
+            // We need to reorder it again based on query's result.
+            foreach ($foundusers as $key => $value) {
+                $contact = $noncontacts[$key];
+                $contact->conversations = self::get_conversations_between_users($userid, $key, 0, 1000);
+                $orderednoncontacs[] = $contact;
+            }
+        }
+
+        return array($orderedcontacs, $orderednoncontacs);
     }
 
     /**
-     * Gets the subnames for any conversations linked to components.
+     * Gets extra fields, like image url and subname for any conversations linked to components.
      *
      * The subname is like a subtitle for the conversation, to compliment it's name.
+     * The imageurl is the location of the image for the conversation, as might be seen on a listing of conversations for a user.
      *
      * @param array $conversations a list of conversations records.
      * @return array the array of subnames, index by conversation id.
+     * @throws \coding_exception
+     * @throws \dml_exception
      */
-    protected static function get_linked_conversation_subnames(array $conversations) {
+    protected static function get_linked_conversation_extra_fields(array $conversations) : array {
         global $DB;
 
         $linkedconversations = [];
@@ -299,11 +433,11 @@ class api {
 
         // TODO: MDL-63814: Working out the subname for linked conversations should be done in a generic way.
         // Get the itemid, but only for course group linked conversation for now.
-        $convsubnames = [];
+        $extrafields = [];
         if (!empty($linkeditems = $linkedconversations['core_group']['groups'])) { // Format: [conversationid => itemid].
             // Get the name of the course to which the group belongs.
             list ($groupidsql, $groupidparams) = $DB->get_in_or_equal(array_values($linkeditems), SQL_PARAMS_NAMED, 'groupid');
-            $sql = "SELECT g.id, c.shortname
+            $sql = "SELECT g.*, c.shortname as courseshortname
                       FROM {groups} g
                       JOIN {course} c
                         ON g.courseid = c.id
@@ -311,11 +445,16 @@ class api {
             $courseinfo = $DB->get_records_sql($sql, $groupidparams);
             foreach ($linkeditems as $convid => $groupid) {
                 if (array_key_exists($groupid, $courseinfo)) {
-                    $convsubnames[$convid] = format_string($courseinfo[$groupid]->shortname);
+                    $group = $courseinfo[$groupid];
+                    // Subname.
+                    $extrafields[$convid]['subname'] = format_string($courseinfo[$groupid]->courseshortname);
+
+                    // Imageurl.
+                    $extrafields[$convid]['imageurl'] = get_group_picture_url($group, $group->courseid, true)->out(false);
                 }
             }
         }
-        return $convsubnames;
+        return $extrafields;
     }
 
 
@@ -386,7 +525,8 @@ class api {
         $typesql = !is_null($type) ? " AND mc.type = :convtype " : "";
 
         $sql = "SELECT m.id as messageid, mc.id as id, mc.name as conversationname, mc.type as conversationtype, m.useridfrom,
-                       m.smallmessage, m.timecreated, mc.component, mc.itemtype, mc.itemid
+                       m.smallmessage, m.fullmessage, m.fullmessageformat, m.fullmessagehtml, m.timecreated, mc.component,
+                       mc.itemtype, mc.itemid
                   FROM {message_conversations} mc
             INNER JOIN {message_conversation_members} mcm
                     ON (mcm.conversationid = mc.id AND mcm.userid = :userid3)
@@ -431,10 +571,12 @@ class api {
             return [];
         }
 
-        // COMPONENT-LINKED CONVERSATION SUBNAME.
-        // This subname will vary, depending on the component which created the linked conversation.
+        // COMPONENT-LINKED CONVERSATION FIELDS.
+        // Conversations linked to components may have extra information, such as:
+        // - subname: Essentially a subtitle for the conversation. So you'd have "name: subname".
+        // - imageurl: A URL to the image for the linked conversation.
         // For now, this is ONLY course groups.
-        $convsubnames = self::get_linked_conversation_subnames($conversations);
+        $convextrafields = self::get_linked_conversation_extra_fields($conversations);
 
         // MEMBERS.
         // Ideally, we want to get 1 member for each conversation, but this depends on the type and whether there is a recent
@@ -555,7 +697,8 @@ class api {
             $conv = new \stdClass();
             $conv->id = $conversation->id;
             $conv->name = $conversation->conversationname;
-            $conv->subname = $convsubnames[$conv->id] ?? null;
+            $conv->subname = $convextrafields[$conv->id]['subname'] ?? null;
+            $conv->imageurl = $convextrafields[$conv->id]['imageurl'] ?? null;
             $conv->type = $conversation->conversationtype;
             $conv->membercount = $membercounts[$conv->id]->membercount;
             $conv->isfavourite = in_array($conv->id, $favouriteconversationids);
@@ -568,7 +711,7 @@ class api {
             if ($conversation->smallmessage) {
                 $msg = new \stdClass();
                 $msg->id = $conversation->messageid;
-                $msg->text = clean_param($conversation->smallmessage, PARAM_NOTAGS);
+                $msg->text = message_format_message_text($conversation);
                 $msg->useridfrom = $conversation->useridfrom;
                 $msg->timecreated = $conversation->timecreated;
                 $conv->messages[] = $msg;
@@ -579,6 +722,41 @@ class api {
         return $arrconversations;
     }
 
+    /**
+     * Returns all conversations between two users
+     *
+     * @param int $userid1 One of the user's id
+     * @param int $userid2 The other user's id
+     * @param int $limitfrom
+     * @param int $limitnum
+     * @return array
+     * @throws \dml_exception
+     */
+    public static function get_conversations_between_users(int $userid1, int $userid2,
+                                                           int $limitfrom = 0, int $limitnum = 20) : array {
+
+        global $DB;
+
+        if ($userid1 == $userid2) {
+            return array();
+        }
+
+        // Get all conversation where both user1 and user2 are members.
+        // TODO: Add subname value. Waiting for definite table structure.
+        $sql = "SELECT mc.id, mc.type, mc.name, mc.timecreated
+                  FROM {message_conversations} mc
+            INNER JOIN {message_conversation_members} mcm1
+                    ON mc.id = mcm1.conversationid
+            INNER JOIN {message_conversation_members} mcm2
+                    ON mc.id = mcm2.conversationid
+                 WHERE mcm1.userid = :userid1
+                   AND mcm2.userid = :userid2
+                   AND mc.enabled != 0
+              ORDER BY mc.timecreated DESC";
+
+        return $DB->get_records_sql($sql, array('userid1' => $userid1, 'userid2' => $userid2), $limitfrom, $limitnum);
+    }
+
     /**
      * Mark a conversation as a favourite for the given user.
      *
index 67dcf87..1927b64 100644 (file)
@@ -552,7 +552,7 @@ class helper {
             $data->profileimageurl = $conv->members[$otheruser->id]->profileimageurl;
             $data->profileimageurlsmall = $conv->members[$otheruser->id]->profileimageurlsmall;
             $data->ismessaging = isset($conv->messages[0]->text) ? true : false;
-            $data->lastmessage = $conv->messages[0]->text ?? null;
+            $data->lastmessage = $conv->messages[0]->text ? clean_param($conv->messages[0]->text, PARAM_NOTAGS) : null;
             $data->messageid = $conv->messages[0]->id ?? null;
             $data->isonline = $conv->members[$otheruser->id]->isonline ?? null;
             $data->isblocked = $conv->members[$otheruser->id]->isblocked ?? null;
index c0c55f0..af3740a 100644 (file)
@@ -949,12 +949,14 @@ class core_message_external extends external_api {
      * @return external_single_structure
      * @since Moodle 3.6
      */
+
     private static function get_conversation_structure() {
         return new external_single_structure(
             array(
                 'id' => new external_value(PARAM_INT, 'The conversation id'),
                 'name' => new external_value(PARAM_NOTAGS, 'The conversation name, if set', VALUE_DEFAULT, null),
                 'subname' => new external_value(PARAM_NOTAGS, 'A subtitle for the conversation name, if set', VALUE_DEFAULT, null),
+                'imageurl' => new external_value(PARAM_URL, 'A link to the conversation picture, if set', VALUE_DEFAULT, null),
                 'type' => new external_value(PARAM_INT, 'The type of the conversation (1=individual,2=group)'),
                 'membercount' => new external_value(PARAM_INT, 'Total number of conversation members'),
                 'isfavourite' => new external_value(PARAM_BOOL, 'If the user marked conversation this conversation as a favourite'),
@@ -975,10 +977,12 @@ class core_message_external extends external_api {
      * Return the structure of a conversation member.
      *
      * @param bool $includecontactrequests Are we including contact requests?
+     * @param bool $includeconversations Are we including conversations?
      * @return external_single_structure
      * @since Moodle 3.6
      */
-    private static function get_conversation_member_structure(bool $includecontactrequests = false) {
+    private static function get_conversation_member_structure(bool $includecontactrequests = false,
+                                                              bool $includeconversations = false) {
         $result = [
             'id' => new external_value(PARAM_INT, 'The user id'),
             'fullname' => new external_value(PARAM_NOTAGS, 'The user\'s name'),
@@ -1003,6 +1007,18 @@ class core_message_external extends external_api {
             );
         }
 
+        if ($includeconversations) {
+            $result['conversations'] = new external_multiple_structure(new external_single_structure(
+                array(
+                    'id' => new external_value(PARAM_INT, 'Conversations id'),
+                    'type' => new external_value(PARAM_INT, 'Conversation type: private or public'),
+                    'name' => new external_value(PARAM_TEXT, 'Multilang compatible conversation name'. VALUE_OPTIONAL),
+                    'timecreated' => new external_value(PARAM_INT, 'The timecreated timestamp for the conversation'),
+                ), 'information about conversation', VALUE_OPTIONAL),
+                'Conversations between users', VALUE_OPTIONAL
+            );
+        }
+
         return new external_single_structure(
             $result
         );
@@ -1051,6 +1067,8 @@ class core_message_external extends external_api {
     /**
      * Get messagearea search users in course parameters.
      *
+     * @deprecated since 3.6
+     *
      * @return external_function_parameters
      * @since 3.2
      */
@@ -1069,6 +1087,12 @@ class core_message_external extends external_api {
     /**
      * Get messagearea search users in course results.
      *
+     * @deprecated since 3.6
+     *
+     * NOTE: We are deprecating this function but not search_users_in_course API function for backwards compatibility
+     * with messaging UI. But should be removed once new group messaging UI is in place and old messaging UI is removed.
+     * Followup: MDL-63915
+     *
      * @param int $userid The id of the user who is performing the search
      * @param int $courseid The id of the course
      * @param string $search The string being searched
@@ -1113,6 +1137,8 @@ class core_message_external extends external_api {
     /**
      * Get messagearea search users in course returns.
      *
+     * @deprecated since 3.6
+     *
      * @return external_single_structure
      * @since 3.2
      */
@@ -1126,9 +1152,20 @@ class core_message_external extends external_api {
         );
     }
 
+    /**
+     * Marking the method as deprecated.
+     *
+     * @return bool
+     */
+    public static function data_for_messagearea_search_users_in_course_is_deprecated() {
+        return true;
+    }
+
     /**
      * Get messagearea search users parameters.
      *
+     * @deprecated since 3.6
+     *
      * @return external_function_parameters
      * @since 3.2
      */
@@ -1145,6 +1182,12 @@ class core_message_external extends external_api {
     /**
      * Get messagearea search users results.
      *
+     * @deprecated since 3.6
+     *
+     * NOTE: We are deprecating this function but not search_users API function for backwards compatibility
+     * with messaging UI. But should be removed once new group messaging UI is in place and old messaging UI is removed.
+     * Followup: MDL-63915
+     *
      * @param int $userid The id of the user who is performing the search
      * @param string $search The string being searched
      * @param int $limitnum
@@ -1184,6 +1227,8 @@ class core_message_external extends external_api {
     /**
      * Get messagearea search users returns.
      *
+     * @deprecated since 3.6
+     *
      * @return external_single_structure
      * @since 3.2
      */
@@ -1209,6 +1254,94 @@ class core_message_external extends external_api {
         );
     }
 
+    /**
+     * Marking the method as deprecated.
+     *
+     * @return bool
+     */
+    public static function data_for_messagearea_search_users_is_deprecated() {
+        return true;
+    }
+
+    /**
+     * Get messagearea message search users parameters.
+     *
+     * @return external_function_parameters
+     * @since 3.6
+     */
+    public static function message_search_users_parameters() {
+        return new external_function_parameters(
+            array(
+                'userid' => new external_value(PARAM_INT, 'The id of the user who is performing the search'),
+                'search' => new external_value(PARAM_RAW, 'The string being searched'),
+                'limitfrom' => new external_value(PARAM_INT, 'Limit from', VALUE_DEFAULT, 0),
+                'limitnum' => new external_value(PARAM_INT, 'Limit number', VALUE_DEFAULT, 0),
+            )
+        );
+    }
+
+    /**
+     * Get search users results.
+     *
+     * @param int $userid The id of the user who is performing the search
+     * @param string $search The string being searched
+     * @param int $limitfrom
+     * @param int $limitnum
+     * @return array
+     * @throws moodle_exception
+     * @since 3.6
+     */
+    public static function message_search_users($userid, $search, $limitfrom = 0, $limitnum = 0) {
+        global $CFG, $USER;
+
+        // Check if messaging is enabled.
+        if (empty($CFG->messaging)) {
+            throw new moodle_exception('disabled', 'message');
+        }
+
+        $systemcontext = context_system::instance();
+
+        $params = array(
+            'userid' => $userid,
+            'search' => $search,
+            'limitfrom' => $limitfrom,
+            'limitnum' => $limitnum
+        );
+        $params = self::validate_parameters(self::message_search_users_parameters(), $params);
+        self::validate_context($systemcontext);
+
+        if (($USER->id != $params['userid']) && !has_capability('moodle/site:readallmessages', $systemcontext)) {
+            throw new moodle_exception('You do not have permission to perform this action.');
+        }
+
+        list($contacts, $noncontacts) = \core_message\api::message_search_users(
+            $params['userid'],
+            $params['search'],
+            $params['limitfrom'],
+            $params['limitnum']);
+
+        return array('contacts' => $contacts, 'noncontacts' => $noncontacts);
+    }
+
+    /**
+     * Get messagearea message search users returns.
+     *
+     * @return external_single_structure
+     * @since 3.2
+     */
+    public static function message_search_users_returns() {
+        return new external_single_structure(
+            array(
+                'contacts' => new external_multiple_structure(
+                    self::get_conversation_member_structure(false, true)
+                ),
+                'noncontacts' => new external_multiple_structure(
+                    self::get_conversation_member_structure(false, true)
+                )
+            )
+        );
+    }
+
     /**
      * Get messagearea search messages parameters.
      *
index 9d30f34..e3f1083 100644 (file)
@@ -155,7 +155,11 @@ if (!empty($user2->id)) {
     $year = '';
 
     // Parse the messages to add missing fields for backward compatibility.
-    $messages = array_map(function($message) use ($user1, $user2, $USER, $day, $month, $year) {
+    $messages = array_reverse($messages);
+    $day = '';
+    $month = '';
+    $year = '';
+    foreach ($messages as $message) {
         // Add useridto.
         if (empty($message->useridto)) {
             if ($message->useridfrom == $user1->id) {
@@ -168,22 +172,21 @@ if (!empty($user2->id)) {
         // Add currentuserid.
         $message->currentuserid = $USER->id;
 
-        // Add displayblocktime.
+        // Check if we are now viewing a different block period.
+        $message->displayblocktime = false;
         $date = usergetdate($message->timecreated);
         if ($day != $date['mday'] || $month != $date['month'] || $year != $date['year']) {
             $day = $date['mday'];
             $month = $date['month'];
             $year = $date['year'];
             $message->displayblocktime = true;
-        } else {
-            $message->displayblocktime = false;
+            $message->blocktime = userdate($message->timecreated, get_string('strftimedaydate'));
         }
+
         // We don't have this information here so, for now, we leave an empty value.
         // This is a temporary solution because a new UI is being built in MDL-63303.
         $message->timeread = 0;
-
-        return $message;
-    }, $messages);
+    }
 }
 
 $pollmin = !empty($CFG->messagingminpoll) ? $CFG->messagingminpoll : MESSAGE_DEFAULT_MIN_POLL_IN_SECONDS;
index 2222a85..9dcc43c 100644 (file)
Binary files a/message/output/popup/amd/build/notification_popover_controller.min.js and b/message/output/popup/amd/build/notification_popover_controller.min.js differ
index 944f8a9..8a6b04f 100644 (file)
@@ -227,7 +227,7 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/str', 'core/url',
                 notificationid: notification.id
             };
             if (notification.contexturl) {
-                notificationurlparams.redirecturl = notification.contexturl;
+                notificationurlparams.redirecturl = encodeURIComponent(notification.contexturl);
             }
             notification.contexturl = URL.relativeUrl('message/output/popup/mark_notification_read.php', notificationurlparams);
 
index dc8d49c..d9b050f 100644 (file)
@@ -1,4 +1,4 @@
-@message @message_popup
+@core_message @message_popup
 Feature: Message popover preferences
   In order to modify my message preferences
   As a user
index b27e22e..9706aa9 100644 (file)
@@ -1,4 +1,4 @@
-@message @message_popup @javascript
+@core_message @message_popup @javascript
 Feature: Message popover unread messages
   In order to be kept informed
   As a user
@@ -16,6 +16,9 @@ Feature: Message popover unread messages
       | user     | course | role           |
       | student1 | C1     | student        |
       | student2 | C1     | student        |
+    And the following config values are set as admin:
+      | messaging | 1 |
+      | messagingallusers | 1 |
     And I log in as "student2"
     And I send "Test message" message to "Student 1" user
     And I log out
index d4743da..1ec46af 100644 (file)
@@ -1,4 +1,4 @@
-@message @message_popup
+@core_message @message_popup
 Feature: Notification popover preferences
   In order to modify my notification preferences
   As a user
index e055d04..bf1d8fd 100644 (file)
@@ -1,4 +1,4 @@
-@message @message_popup @javascript
+@core_message @message_popup @javascript
 Feature: Notification popover unread notifications
   In order to be kept informed
   As a user
index 89567e2..76f2777 100644 (file)
@@ -258,7 +258,8 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $course5context = context_course::instance($course5->id);
         assign_capability('moodle/course:viewparticipants', CAP_PROHIBIT, $role->id, $course5context->id);
 
-        // Perform a search.
+        // Perform a search $CFG->messagingallusers setting enabled.
+        set_config('messagingallusers', 1);
         list($contacts, $courses, $noncontacts) = \core_message\api::search_users($user1->id, 'search');
 
         // Check that we retrieved the correct contacts.
@@ -276,6 +277,277 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->assertEquals($user5->id, $noncontacts[0]->userid);
     }
 
+    /**
+     * Tests searching users with empty result.
+     */
+    public function test_search_users_with_empty_result() {
+
+        // Create some users.
+        $user1 = new stdClass();
+        $user1->firstname = 'User';
+        $user1->lastname = 'One';
+        $user1 = self::getDataGenerator()->create_user($user1);
+
+        // Set as the user performing the search.
+        $this->setUser($user1);
+
+        $user2 = new stdClass();
+        $user2->firstname = 'User';
+        $user2->lastname = 'Two';
+        $user2 = self::getDataGenerator()->create_user($user2);
+
+        // Perform a search $CFG->messagingallusers setting enabled.
+        set_config('messagingallusers', 1);
+        list($contacts, $courses, $noncontacts) = \core_message\api::search_users($user1->id, 'search');
+
+        // Check results are empty.
+        $this->assertEquals(0, count($contacts));
+        $this->assertEquals(0, count($courses));
+        $this->assertEquals(0, count($noncontacts));
+    }
+
+    /**
+     * Tests searching users.
+     */
+    public function test_message_search_users() {
+        // Create some users.
+        $user1 = new stdClass();
+        $user1->firstname = 'User search';
+        $user1->lastname = 'One';
+        $user1 = self::getDataGenerator()->create_user($user1);
+
+        // Set as the user performing the search.
+        $this->setUser($user1);
+
+        $user2 = new stdClass();
+        $user2->firstname = 'User search';
+        $user2->lastname = 'Two';
+        $user2 = self::getDataGenerator()->create_user($user2);
+
+        $user3 = new stdClass();
+        $user3->firstname = 'User search';
+        $user3->lastname = 'Three';
+        $user3 = self::getDataGenerator()->create_user($user3);
+
+        $user4 = new stdClass();
+        $user4->firstname = 'User';
+        $user4->lastname = 'Four';
+        $user4 = self::getDataGenerator()->create_user($user4);
+
+        $user5 = new stdClass();
+        $user5->firstname = 'User search';
+        $user5->lastname = 'Five';
+        $user5 = self::getDataGenerator()->create_user($user5);
+
+        $user6 = new stdClass();
+        $user6->firstname = 'User search';
+        $user6->lastname = 'Six';
+        $user6 = self::getDataGenerator()->create_user($user6);
+
+        $user7 = new stdClass();
+        $user7->firstname = 'User search';
+        $user7->lastname = 'Seven';
+        $user7 = self::getDataGenerator()->create_user($user7);
+
+        // Add some users as contacts.
+        \core_message\api::add_contact($user1->id, $user2->id);
+        \core_message\api::add_contact($user3->id, $user1->id);
+        \core_message\api::add_contact($user1->id, $user4->id);
+
+        // Create private conversations with some users.
+        \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            array($user1->id, $user6->id));
+        \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            array($user7->id, $user1->id));
+
+        // Perform a search $CFG->messagingallusers setting enabled.
+        set_config('messagingallusers', 1);
+        list($contacts, $noncontacts) = \core_message\api::message_search_users($user1->id, 'search');
+
+        // Check that we retrieved the correct contacts.
+        $this->assertCount(2, $contacts);
+        $this->assertEquals($user3->id, $contacts[0]->id);
+        $this->assertEquals($user2->id, $contacts[1]->id);
+
+        // Check that we retrieved the correct non-contacts.
+        $this->assertCount(3, $noncontacts);
+        $this->assertEquals($user5->id, $noncontacts[0]->id);
+        $this->assertEquals($user7->id, $noncontacts[1]->id);
+        $this->assertEquals($user6->id, $noncontacts[2]->id);
+
+        // Perform a search $CFG->messagingallusers setting disabled.
+        set_config('messagingallusers', 0);
+        list($contacts, $noncontacts) = \core_message\api::message_search_users($user1->id, 'search');
+
+        // Check that we retrieved the correct contacts.
+        $this->assertCount(2, $contacts);
+        $this->assertEquals($user3->id, $contacts[0]->id);
+        $this->assertEquals($user2->id, $contacts[1]->id);
+
+        // Check that we retrieved the correct non-contacts.
+        $this->assertCount(2, $noncontacts);
+        $this->assertEquals($user7->id, $noncontacts[0]->id);
+        $this->assertEquals($user6->id, $noncontacts[1]->id);
+    }
+
+    /**
+     * Tests getting conversations between 2 users.
+     */
+    public function test_get_conversations_between_users() {
+        // Create some users.
+        $user1 = new stdClass();
+        $user1->firstname = 'User';
+        $user1->lastname = 'One';
+        $user1 = self::getDataGenerator()->create_user($user1);
+
+        $user2 = new stdClass();
+        $user2->firstname = 'User';
+        $user2->lastname = 'Two';
+        $user2 = self::getDataGenerator()->create_user($user2);
+
+        $user3 = new stdClass();
+        $user3->firstname = 'User search';
+        $user3->lastname = 'Three';
+        $user3 = self::getDataGenerator()->create_user($user3);
+
+        $user4 = new stdClass();
+        $user4->firstname = 'User';
+        $user4->lastname = 'Four';
+        $user4 = self::getDataGenerator()->create_user($user4);
+
+        $user5 = new stdClass();
+        $user5->firstname = 'User';
+        $user5->lastname = 'Five';
+        $user5 = self::getDataGenerator()->create_user($user5);
+
+        $user6 = new stdClass();
+        $user6->firstname = 'User search';
+        $user6->lastname = 'Six';
+        $user6 = self::getDataGenerator()->create_user($user6);
+
+        // Add some users as contacts.
+        \core_message\api::add_contact($user1->id, $user2->id);
+        \core_message\api::add_contact($user6->id, $user1->id);
+
+        // Create private conversations with some users.
+        \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            array($user1->id, $user2->id));
+        \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            array($user3->id, $user1->id));
+
+        // Create a group conversation with users.
+        \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            array($user1->id, $user2->id, $user3->id, $user4->id),
+            'Project chat');
+
+        // Check that we retrieved the correct conversations.
+        $this->assertCount(2, \core_message\api::get_conversations_between_users($user1->id, $user2->id));
+        $this->assertCount(2, \core_message\api::get_conversations_between_users($user2->id, $user1->id));
+        $this->assertCount(2, \core_message\api::get_conversations_between_users($user1->id, $user3->id));
+        $this->assertCount(2, \core_message\api::get_conversations_between_users($user3->id, $user1->id));
+        $this->assertCount(1, \core_message\api::get_conversations_between_users($user1->id, $user4->id));
+        $this->assertCount(1, \core_message\api::get_conversations_between_users($user4->id, $user1->id));
+        $this->assertCount(0, \core_message\api::get_conversations_between_users($user1->id, $user5->id));
+        $this->assertCount(0, \core_message\api::get_conversations_between_users($user5->id, $user1->id));
+        $this->assertCount(0, \core_message\api::get_conversations_between_users($user1->id, $user6->id));
+        $this->assertCount(0, \core_message\api::get_conversations_between_users($user6->id, $user1->id));
+    }
+
+    /**
+     * Tests searching users with and without conversations.
+     */
+    public function test_message_search_users_with_and_without_conversations() {
+        // Create some users.
+        $user1 = new stdClass();
+        $user1->firstname = 'User search';
+        $user1->lastname = 'One';
+        $user1 = self::getDataGenerator()->create_user($user1);
+
+        // Set as the user performing the search.
+        $this->setUser($user1);
+
+        $user2 = new stdClass();
+        $user2->firstname = 'User search';
+        $user2->lastname = 'Two';
+        $user2 = self::getDataGenerator()->create_user($user2);
+
+        $user3 = new stdClass();
+        $user3->firstname = 'User search';
+        $user3->lastname = 'Three';
+        $user3 = self::getDataGenerator()->create_user($user3);
+
+        $user4 = new stdClass();
+        $user4->firstname = 'User';
+        $user4->lastname = 'Four';
+        $user4 = self::getDataGenerator()->create_user($user4);
+
+        $user5 = new stdClass();
+        $user5->firstname = 'User search';
+        $user5->lastname = 'Five';
+        $user5 = self::getDataGenerator()->create_user($user5);
+
+        // Add a user as contact.
+        \core_message\api::add_contact($user1->id, $user2->id);
+
+        // Create private conversations with some users.
+        \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            array($user1->id, $user2->id));
+        \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            array($user3->id, $user1->id));
+
+        // Create a group conversation with users.
+        \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            array($user1->id, $user2->id, $user4->id),
+            'Project chat');
+
+        // Perform a search $CFG->messagingallusers setting enabled.
+        set_config('messagingallusers', 1);
+        list($contacts, $noncontacts) = \core_message\api::message_search_users($user1->id, 'search');
+
+        // Check that we retrieved the correct contacts.
+        $this->assertCount(1, $contacts);
+
+        // Check that we retrieved the correct conversations for contacts.
+        $this->assertCount(2, $contacts[0]->conversations);
+
+        // Check that we retrieved the correct non-contacts.
+        $this->assertCount(2, $noncontacts);
+        $this->assertEquals($user5->id, $noncontacts[0]->id);
+        $this->assertEquals($user3->id, $noncontacts[1]->id);
+
+        // Check that we retrieved the correct conversations for non-contacts.
+        $this->assertCount(0, $noncontacts[0]->conversations);
+        $this->assertCount(1, $noncontacts[1]->conversations);
+    }
+
+    /**
+     * Tests searching users with empty result.
+     */
+    public function test_message_search_users_with_empty_result() {
+
+        // Create some users.
+        $user1 = new stdClass();
+        $user1->firstname = 'User';
+        $user1->lastname = 'One';
+        $user1 = self::getDataGenerator()->create_user($user1);
+
+        // Set as the user performing the search.
+        $this->setUser($user1);
+
+        $user2 = new stdClass();
+        $user2->firstname = 'User';
+        $user2->lastname = 'Two';
+        $user2 = self::getDataGenerator()->create_user($user2);
+
+        // Perform a search $CFG->messagingallusers setting enabled.
+        set_config('messagingallusers', 1);
+        list($contacts, $noncontacts) = \core_message\api::message_search_users($user1->id, 'search');
+
+        // Check results are empty.
+        $this->assertEquals(0, count($contacts));
+        $this->assertEquals(0, count($noncontacts));
+    }
+
     /**
      * Tests searching messages.
      */
@@ -715,6 +987,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
      * Test verifying get_conversations when no limits, offsets, type filters or favourite restrictions are used.
      */
     public function test_get_conversations_no_restrictions() {
+        global $DB;
         // No conversations should exist yet.
         $user1 = self::getDataGenerator()->create_user();
         $this->assertEquals([], \core_message\api::get_conversations($user1->id));
@@ -741,7 +1014,9 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->assertCount(1, $conversations[0]->members);
         $this->assertEquals(4, $conversations[0]->membercount);
         $this->assertCount(1, $conversations[0]->messages);
-        $this->assertEquals("Message 14", $conversations[0]->messages[0]->text);
+        $message = $DB->get_record('messages', ['id' => $conversations[0]->messages[0]->id]);
+        $expectedmessagetext = message_format_message_text($message);
+        $this->assertEquals($expectedmessagetext, $conversations[0]->messages[0]->text);
         $this->assertEquals($user1->id, $conversations[0]->messages[0]->useridfrom);
 
         $this->assertEquals($gc2->id, $conversations[1]->id);
@@ -750,7 +1025,9 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->assertCount(1, $conversations[1]->members);
         $this->assertEquals(3, $conversations[1]->membercount);
         $this->assertCount(1, $conversations[1]->messages);
-        $this->assertEquals("Message 11", $conversations[1]->messages[0]->text);
+        $message = $DB->get_record('messages', ['id' => $conversations[1]->messages[0]->id]);
+        $expectedmessagetext = message_format_message_text($message);
+        $this->assertEquals($expectedmessagetext, $conversations[1]->messages[0]->text);
         $this->assertEquals($user4->id, $conversations[1]->messages[0]->useridfrom);
 
         $this->assertEquals($ic2->id, $conversations[2]->id);
@@ -760,7 +1037,9 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->assertEquals($user3->id, $conversations[2]->members[$user3->id]->id);
         $this->assertEquals(2, $conversations[2]->membercount);
         $this->assertCount(1, $conversations[2]->messages);
-        $this->assertEquals("Message 4", $conversations[2]->messages[0]->text);
+        $message = $DB->get_record('messages', ['id' => $conversations[2]->messages[0]->id]);
+        $expectedmessagetext = message_format_message_text($message);
+        $this->assertEquals($expectedmessagetext, $conversations[2]->messages[0]->text);
         $this->assertEquals($user1->id, $conversations[2]->messages[0]->useridfrom);
 
         $this->assertEquals($ic1->id, $conversations[3]->id);
@@ -769,7 +1048,9 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->assertCount(1, $conversations[3]->members);
         $this->assertEquals(2, $conversations[3]->membercount);
         $this->assertCount(1, $conversations[3]->messages);
-        $this->assertEquals("Message 2", $conversations[3]->messages[0]->text);
+        $message = $DB->get_record('messages', ['id' => $conversations[3]->messages[0]->id]);
+        $expectedmessagetext = message_format_message_text($message);
+        $this->assertEquals($expectedmessagetext, $conversations[3]->messages[0]->text);
         $this->assertEquals($user2->id, $conversations[3]->messages[0]->useridfrom);
 
         // Of the groups without messages, we expect to see the most recently created first.
@@ -792,6 +1073,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
             $this->assertObjectHasAttribute('id', $conv);
             $this->assertObjectHasAttribute('name', $conv);
             $this->assertObjectHasAttribute('subname', $conv);
+            $this->assertObjectHasAttribute('imageurl', $conv);
             $this->assertObjectHasAttribute('type', $conv);
             $this->assertObjectHasAttribute('isfavourite', $conv);
             $this->assertObjectHasAttribute('membercount', $conv);
@@ -818,6 +1100,34 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         }
     }
 
+    /**
+     * Test verifying that html format messages are supported, and that message_format_message_text() is being called appropriately.
+     */
+    public function test_get_conversations_message_format() {
+        global $DB;
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        // Create conversation.
+        $conversation = \core_message\api::create_conversation(
+            \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user2->id]
+        );
+
+        // Send some messages back and forth.
+        $time = 1;
+        testhelper::send_fake_message_to_conversation($user2, $conversation->id, 'Sup mang?', $time + 1);
+        $mid = testhelper::send_fake_message_to_conversation($user1, $conversation->id, '<a href="#">A link</a>', $time + 2);
+
+        // Verify the format of the html message.
+        $message = $DB->get_record('messages', ['id' => $mid]);
+        $expectedmessagetext = message_format_message_text($message);
+        $conversations = \core_message\api::get_conversations($user1->id);
+        $messages = $conversations[0]->messages;
+        $this->assertEquals($expectedmessagetext, $messages[0]->text);
+    }
+
     /**
      * Tests retrieving conversations with a limit and offset to ensure pagination works correctly.
      */
@@ -1016,6 +1326,8 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
      * Test verifying that group linked conversations are returned and contain a subname matching the course name.
      */
     public function test_get_conversations_group_linked() {
+        global $CFG;
+
         // Create some users.
         $user1 = self::getDataGenerator()->create_user();
         $user2 = self::getDataGenerator()->create_user();
@@ -1028,14 +1340,21 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->getDataGenerator()->enrol_user($user1->id, $course1->id);
         $this->getDataGenerator()->enrol_user($user2->id, $course1->id);
         $this->getDataGenerator()->enrol_user($user3->id, $course1->id);
-        $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course1->id, 'enablemessaging' => 1));
+        $group1 = $this->getDataGenerator()->create_group([
+            'courseid' => $course1->id,
+            'enablemessaging' => 1,
+            'picturepath' => $CFG->dirroot . '/lib/tests/fixtures/gd-logo.png'
+        ]);
 
         // Add users to group1.
         $this->getDataGenerator()->create_group_member(array('groupid' => $group1->id, 'userid' => $user1->id));
         $this->getDataGenerator()->create_group_member(array('groupid' => $group1->id, 'userid' => $user2->id));
 
         $conversations = \core_message\api::get_conversations($user1->id);
+        $this->assertEquals(2, $conversations[0]->membercount);
         $this->assertEquals($course1->shortname, $conversations[0]->subname);
+        $groupimageurl = get_group_picture_url($group1, $group1->courseid, true);
+        $this->assertEquals($groupimageurl, $conversations[0]->imageurl);
     }
 
    /**
@@ -1102,14 +1421,14 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user3',
-                            'subject'           => 'S5',
+                            'subject'           => '<p>S5</p>',
                             'unreadcount'       => 0,
                         ),
                         // User1 has also conversed with user2. The most recent message is S2.
                         array(
                             'messageposition'   => 1,
                             'with'              => 'user2',
-                            'subject'           => 'S2',
+                            'subject'           => '<p>S2</p>',
                             'unreadcount'       => 1,
                         ),
                     ),
@@ -1118,7 +1437,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user1',
-                            'subject'           => 'S2',
+                            'subject'           => '<p>S2</p>',
                             'unreadcount'       => 2,
                         ),
                     ),
@@ -1127,7 +1446,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user1',
-                            'subject'           => 'S5',
+                            'subject'           => '<p>S5</p>',
                             'unreadcount'       => 0,
                         ),
                     ),
@@ -1174,7 +1493,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user2',
-                            'subject'           => 'S4',
+                            'subject'           => '<p>S4</p>',
                             'unreadcount'       => 0,
                         ),
                     ),
@@ -1183,7 +1502,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user1',
-                            'subject'           => 'S4',
+                            'subject'           => '<p>S4</p>',
                             'unreadcount'       => 2,
                         ),
                     ),
@@ -1233,7 +1552,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user2',
-                            'subject'           => 'S2',
+                            'subject'           => '<p>S2</p>',
                             'unreadcount'       => 0,
                         ),
                     ),
@@ -1241,7 +1560,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user1',
-                            'subject'           => 'S2',
+                            'subject'           => '<p>S2</p>',
                             'unreadcount'       => 2
                         ),
                     ),
@@ -1318,7 +1637,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user2',
-                            'subject'           => 'S8',
+                            'subject'           => '<p>S8</p>',
                             'unreadcount'       => 1,
                         ),
                     ),
@@ -1326,7 +1645,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user1',
-                            'subject'           => 'S8',
+                            'subject'           => '<p>S8</p>',
                             'unreadcount'       => 3,
                         ),
                     ),
index c6f3546..2aa7b26 100644 (file)
@@ -1,4 +1,4 @@
-@core @message @javascript
+@core @core_message @javascript
 Feature: Delete all messages
   In order to communicate with fellow users
   As a user
@@ -16,6 +16,9 @@ Feature: Delete all messages
       | user     | course | role        |
       | user1    | C1     | student     |
       | user2    | C1     | student     |
+    And the following config values are set as admin:
+      | messaging | 1 |
+      | messagingallusers | 1 |
     And I log in as "user2"
     And I send "User 2 to User 1 message 1" message to "User 1" user
     And I send "User 2 to User 1 message 2" message in the message area
index a97087d..ec21545 100644 (file)
@@ -1,4 +1,4 @@
-@core @message @javascript
+@core @core_message @javascript
 Feature: Delete messages
   In order to communicate with fellow users
   As a user
@@ -16,6 +16,9 @@ Feature: Delete messages
       | user     | course | role           |
       | user1    | C1     | student        |
       | user2    | C1     | student        |
+    And the following config values are set as admin:
+      | messaging | 1 |
+      | messagingallusers | 1 |
     And I log in as "user2"
     And I send "User 2 to User 1 message 1" message to "User 1" user
     And I send "User 2 to User 1 message 2" message in the message area
index 135e154..278b115 100644 (file)
@@ -1,4 +1,4 @@
-@core @message @javascript
+@core @core_message @javascript
 Feature: Manage contacts
   In order to communicate with fellow users
   As a user
@@ -10,6 +10,9 @@ Feature: Manage contacts
       | user1    | User      | 1        | user1@example.com    |
       | user2    | User      | 2        | user2@example.com    |
       | user3    | User      | 3        | user3@example.com    |
+    And the following config values are set as admin:
+      | messaging | 1 |
+      | messagingallusers | 1 |
     And I log in as "admin"
     And I set the following administration settings values:
       | messagingallusers | 1 |
index a237ac3..5f126cb 100644 (file)
@@ -1,4 +1,4 @@
-@core @message @javascript
+@core @core_message @javascript
 Feature: Reply message
   In order to communicate with fellow users
   As a user
@@ -9,6 +9,9 @@ Feature: Reply message
       | username | firstname | lastname | email            |
       | user1    | User      | 1        | user1@example.com    |
       | user2    | User      | 2        | user2@example.com    |
+    And the following config values are set as admin:
+      | messaging | 1 |
+      | messagingallusers | 1 |
     And the following "courses" exist:
       | fullname | shortname |
       | Course 1 | C1        |
@@ -16,6 +19,9 @@ Feature: Reply message
       | user     | course | role           |
       | user1    | C1     | student        |
       | user2    | C1     | student        |
+    And the following config values are set as admin:
+      | messaging | 1 |
+      | messagingallusers | 1 |
     And I log in as "user2"
     And I send "User 2 to User 1" message to "User 1" user
     And I log out
index 5c7d3e9..2925860 100644 (file)
@@ -1,4 +1,4 @@
-@core @message @javascript
+@core @core_message @javascript
 Feature: Search messages
   In order to communicate with fellow users
   As a user
@@ -18,6 +18,9 @@ Feature: Search messages
       | user1    | C1     | student        |
       | user2    | C1     | student        |
       | user3    | C1     | student        |
+    And the following config values are set as admin:
+      | messaging | 1 |
+      | messagingallusers | 1 |
     And I log in as "user2"
     And I send "User 2 to User 1" message to "User 1" user
     And I log out
index e8c0114..74a0d49 100644 (file)
@@ -1,4 +1,4 @@
-@core @message @javascript
+@core @core_message @javascript
 Feature: Search users
   In order to communicate with fellow users
   As a user
@@ -10,6 +10,9 @@ Feature: Search users
       | user1    | User      | 1        | user1@example.com    |
       | user2    | User      | 2        | user2@example.com    |
       | user3    | User      | 3        | user3@example.com    |
+    And the following config values are set as admin:
+      | messaging | 1 |
+      | messagingallusers | 1 |
 
   Scenario: Search for single user
     When I log in as "user1"
index c03d2a3..6bddb3f 100644 (file)
@@ -1,4 +1,4 @@
-@core @message @javascript
+@core @core_message @javascript
 Feature: Messaging preferences
   In order to be notified of messages
   As a user
@@ -8,6 +8,9 @@ Feature: Messaging preferences
     Given I log in as "admin"
     And I navigate to "Plugins > Message outputs > Manage message outputs" in site administration
     And I click on "//table[contains(@class, 'admintable')]/tbody/tr/td[contains(text(), 'Email')]/following-sibling::td[1]/a" "xpath_element"
+    And the following config values are set as admin:
+      | messaging | 1 |
+      | messagingallusers | 1 |
 
   Scenario: Alter my message preferences
     Given I follow "Preferences" in the user menu
index f979bd9..30ace15 100644 (file)
@@ -1,4 +1,4 @@
-@core @message @javascript
+@core @core_message @javascript
 Feature: View messages
   In order to communicate with fellow users
   As a user
@@ -18,6 +18,9 @@ Feature: View messages
       | user1    | C1     | student        |
       | user2    | C1     | student        |
       | user3    | C1     | student        |
+    And the following config values are set as admin:
+      | messaging | 1 |
+      | messagingallusers | 1 |
     And I log in as "user2"
     And I send "User 2 to User 1" message to "User 1" user
     And I log out
index 1d2667d..0781adc 100644 (file)
@@ -1821,7 +1821,7 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
     /**
      * Tests searching users in a course.
      */
-    public function test_messagearea_search_users_in_course() {
+    public function test_data_for_messagearea_search_users_in_course() {
         $this->resetAfterTest(true);
 
         // Create some users.
@@ -1885,7 +1885,7 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
     /**
      * Tests searching users in course as another user.
      */
-    public function test_messagearea_search_users_in_course_as_other_user() {
+    public function test_data_for_messagearea_search_users_in_course_as_other_user() {
         $this->resetAfterTest(true);
 
         // The person doing the search for another user.
@@ -1944,7 +1944,7 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
     /**
      * Tests searching users in course as another user without the proper capabilities.
      */
-    public function test_messagearea_search_users_in_course_as_other_user_without_cap() {
+    public function test_data_for_messagearea_search_users_in_course_as_other_user_without_cap() {
         $this->resetAfterTest(true);
 
         // Create some users.
@@ -1960,12 +1960,13 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         // Ensure an exception is thrown.
         $this->expectException('moodle_exception');
         core_message_external::data_for_messagearea_search_users_in_course($user2->id, $course->id, 'User');
+        $this->assertDebuggingCalled();
     }
 
     /**
      * Tests searching users in course with messaging disabled.
      */
-    public function test_messagearea_search_users_in_course_messaging_disabled() {
+    public function test_data_for_messagearea_search_users_in_course_messaging_disabled() {
         global $CFG;
 
         $this->resetAfterTest(true);
@@ -1983,12 +1984,13 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         // Ensure an exception is thrown.
         $this->expectException('moodle_exception');
         core_message_external::data_for_messagearea_search_users_in_course($user->id, $course->id, 'User');
+        $this->assertDebuggingCalled();
     }
 
     /**
      * Tests searching users.
      */
-    public function test_messagearea_search_users() {
+    public function test_data_for_messagearea_search_users() {
         $this->resetAfterTest(true);
 
         // Create some users.
@@ -2055,7 +2057,8 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         \core_message\api::add_contact($user1->id, $user3->id);
         \core_message\api::add_contact($user1->id, $user4->id);
 
-        // Perform a search.
+        // Perform a search $CFG->messagingallusers setting enabled.
+        set_config('messagingallusers', 1);
         $result = core_message_external::data_for_messagearea_search_users($user1->id, 'search');
 
         // We need to execute the return values cleaning process to simulate the web service server.
@@ -2085,7 +2088,7 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
     /**
      * Tests searching users as another user.
      */
-    public function test_messagearea_search_users_as_other_user() {
+    public function test_data_for_messagearea_search_users_as_other_user() {
         $this->resetAfterTest(true);
 
         // The person doing the search.
@@ -2143,7 +2146,8 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         \core_message\api::add_contact($user1->id, $user3->id);
         \core_message\api::add_contact($user1->id, $user4->id);
 
-        // Perform a search.
+        // Perform a search $CFG->messagingallusers setting enabled.
+        set_config('messagingallusers', 1);
         $result = core_message_external::data_for_messagearea_search_users($user1->id, 'search');
 
         // We need to execute the return values cleaning process to simulate the web service server.
@@ -2171,7 +2175,7 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
     /**
      * Tests searching users as another user without the proper capabilities.
      */
-    public function test_messagearea_search_users_as_other_user_without_cap() {
+    public function test_data_for_messagearea_search_users_as_other_user_without_cap() {
         $this->resetAfterTest(true);
 
         // Create some users.
@@ -2184,12 +2188,13 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         // Ensure an exception is thrown.
         $this->expectException('moodle_exception');
         core_message_external::data_for_messagearea_search_users($user2->id, 'User');
+        $this->assertDebuggingCalled();
     }
 
     /**
      * Tests searching users with messaging disabled.
      */
-    public function test_messagearea_search_users_messaging_disabled() {
+    public function test_data_for_messagearea_search_users_messaging_disabled() {
         global $CFG;
 
         $this->resetAfterTest(true);
@@ -2206,6 +2211,327 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         // Ensure an exception is thrown.
         $this->expectException('moodle_exception');
         core_message_external::data_for_messagearea_search_users($user->id, 'User');
+        $this->assertDebuggingCalled();
+    }
+
+    /**
+     * Tests searching users.
+     */
+    public function test_message_search_users() {
+        $this->resetAfterTest(true);
+
+        // Create some users.
+        $user1 = new stdClass();
+        $user1->firstname = 'User search';
+        $user1->lastname = 'One';
+        $user1 = self::getDataGenerator()->create_user($user1);
+        // Set as the user performing the search.
+        $this->setUser($user1);
+
+        $user2 = new stdClass();
+        $user2->firstname = 'User search';
+        $user2->lastname = 'Two';
+        $user2 = self::getDataGenerator()->create_user($user2);
+
+        $user3 = new stdClass();
+        $user3->firstname = 'User search';
+        $user3->lastname = 'Three';
+        $user3 = self::getDataGenerator()->create_user($user3);
+
+        $user4 = new stdClass();
+        $user4->firstname = 'User';
+        $user4->lastname = 'Four';
+        $user4 = self::getDataGenerator()->create_user($user4);
+
+        $user5 = new stdClass();
+        $user5->firstname = 'User search';
+        $user5->lastname = 'Five';
+        $user5 = self::getDataGenerator()->create_user($user5);
+
+        $user6 = new stdClass();
+        $user6->firstname = 'User search';
+        $user6->lastname = 'Six';
+        $user6 = self::getDataGenerator()->create_user($user6);
+
+        $user7 = new stdClass();
+        $user7->firstname = 'User search';
+        $user7->lastname = 'Seven';
+        $user7 = self::getDataGenerator()->create_user($user7);
+
+        // Add some users as contacts.
+        \core_message\api::add_contact($user1->id, $user2->id);
+        \core_message\api::add_contact($user3->id, $user1->id);
+        \core_message\api::add_contact($user1->id, $user4->id);
+
+        // Create private conversations with some users.
+        \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            array($user1->id, $user6->id));
+        \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            array($user7->id, $user1->id));
+
+        // Perform a search $CFG->messagingallusers setting enabled.
+        set_config('messagingallusers', 1);
+        $result = core_message_external::message_search_users($user1->id, 'search');
+
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $result = external_api::clean_returnvalue(core_message_external::message_search_users_returns(),
+            $result);
+
+        // Confirm that we returns contacts and non-contacts.
+        $contacts = $result['contacts'];
+        $noncontacts = $result['noncontacts'];
+
+        // Check that we retrieved the correct contacts.
+        $this->assertCount(2, $contacts);
+        $this->assertEquals($user3->id, $contacts[0]['id']);
+        $this->assertEquals($user2->id, $contacts[1]['id']);
+
+        // Check that we retrieved the correct non-contacts.
+        $this->assertCount(3, $noncontacts);
+        $this->assertEquals($user5->id, $noncontacts[0]['id']);
+        $this->assertEquals($user7->id, $noncontacts[1]['id']);
+        $this->assertEquals($user6->id, $noncontacts[2]['id']);
+
+        // Perform a search $CFG->messagingallusers setting disabled.
+        set_config('messagingallusers', 0);
+        $result = core_message_external::message_search_users($user1->id, 'search');
+
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $result = external_api::clean_returnvalue(core_message_external::message_search_users_returns(),
+            $result);
+
+        // Confirm that we returns contacts and non-contacts.
+        $contacts = $result['contacts'];
+        $noncontacts = $result['noncontacts'];
+
+        // Check that we retrieved the correct contacts.
+        $this->assertCount(2, $contacts);
+        $this->assertEquals($user3->id, $contacts[0]['id']);
+        $this->assertEquals($user2->id, $contacts[1]['id']);
+
+        // Check that we retrieved the correct non-contacts.
+        $this->assertCount(2, $noncontacts);
+        $this->assertEquals($user7->id, $noncontacts[0]['id']);
+        $this->assertEquals($user6->id, $noncontacts[1]['id']);
+    }
+
+    /**
+     * Tests searching users as another user.
+     */
+    public function test_message_search_users_as_other_user() {
+        $this->resetAfterTest(true);
+
+        // The person doing the search.
+        $this->setAdminUser();
+
+        // Create some users.
+        $user1 = new stdClass();
+        $user1->firstname = 'User search';
+        $user1->lastname = 'One';
+        $user1 = self::getDataGenerator()->create_user($user1);
+
+        $user2 = new stdClass();
+        $user2->firstname = 'User search';
+        $user2->lastname = 'Two';
+        $user2 = self::getDataGenerator()->create_user($user2);
+
+        $user3 = new stdClass();
+        $user3->firstname = 'User search';
+        $user3->lastname = 'Three';
+        $user3 = self::getDataGenerator()->create_user($user3);
+
+        $user4 = new stdClass();
+        $user4->firstname = 'User';
+        $user4->lastname = 'Four';
+        $user4 = self::getDataGenerator()->create_user($user4);
+
+        $user5 = new stdClass();
+        $user5->firstname = 'User search';
+        $user5->lastname = 'Five';
+        $user5 = self::getDataGenerator()->create_user($user5);
+
+        $user6 = new stdClass();
+        $user6->firstname = 'User search';
+        $user6->lastname = 'Six';
+        $user6 = self::getDataGenerator()->create_user($user6);
+
+        $user7 = new stdClass();
+        $user7->firstname = 'User search';
+        $user7->lastname = 'Seven';
+        $user7 = self::getDataGenerator()->create_user($user7);
+
+        // Add some users as contacts.
+        \core_message\api::add_contact($user1->id, $user2->id);
+        \core_message\api::add_contact($user3->id, $user1->id);
+        \core_message\api::add_contact($user1->id, $user4->id);
+
+        // Create private conversations with some users.
+        \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            array($user1->id, $user6->id));
+        \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            array($user7->id, $user1->id));
+
+        // Perform a search $CFG->messagingallusers setting enabled.
+        set_config('messagingallusers', 1);
+        $result = core_message_external::message_search_users($user1->id, 'search');
+
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $result = external_api::clean_returnvalue(core_message_external::message_search_users_returns(),
+            $result);
+
+        // Confirm that we returns contacts and non-contacts.
+        $contacts = $result['contacts'];
+        $noncontacts = $result['noncontacts'];
+
+        // Check that we retrieved the correct contacts.
+        $this->assertCount(2, $contacts);
+        $this->assertEquals($user3->id, $contacts[0]['id']);
+        $this->assertEquals($user2->id, $contacts[1]['id']);
+
+        // Check that we retrieved the correct non-contacts.
+        $this->assertCount(3, $noncontacts);
+        $this->assertEquals($user5->id, $noncontacts[0]['id']);
+        $this->assertEquals($user7->id, $noncontacts[1]['id']);
+        $this->assertEquals($user6->id, $noncontacts[2]['id']);
+
+        // Perform a search $CFG->messagingallusers setting disabled.
+        set_config('messagingallusers', 0);
+        $result = core_message_external::message_search_users($user1->id, 'search');
+
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $result = external_api::clean_returnvalue(core_message_external::message_search_users_returns(),
+            $result);
+
+        // Confirm that we returns contacts and non-contacts.
+        $contacts = $result['contacts'];
+        $noncontacts = $result['noncontacts'];
+
+        // Check that we retrieved the correct contacts.
+        $this->assertCount(2, $contacts);
+        $this->assertEquals($user3->id, $contacts[0]['id']);
+        $this->assertEquals($user2->id, $contacts[1]['id']);
+
+        // Check that we retrieved the correct non-contacts.
+        $this->assertCount(2, $noncontacts);
+        $this->assertEquals($user7->id, $noncontacts[0]['id']);
+        $this->assertEquals($user6->id, $noncontacts[1]['id']);
+    }
+
+    /**
+     * Tests searching users as another user without the proper capabilities.
+     */
+    public function test_message_search_users_as_other_user_without_cap() {
+        $this->resetAfterTest(true);
+
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        // The person doing the search for another user.
+        $this->setUser($user1);
+
+        // Ensure an exception is thrown.
+        $this->expectException('moodle_exception');
+        core_message_external::message_search_users($user2->id, 'User');
+        $this->assertDebuggingCalled();
+    }
+
+    /**
+     * Tests searching users with and without conversations.
+     */
+    public function test_message_search_users_with_and_without_conversations() {
+        $this->resetAfterTest(true);
+
+        // Create some users.
+        $user1 = new stdClass();
+        $user1->firstname = 'User search';
+        $user1->lastname = 'One';
+        $user1 = self::getDataGenerator()->create_user($user1);
+
+        // Set as the user performing the search.
+        $this->setUser($user1);
+
+        $user2 = new stdClass();
+        $user2->firstname = 'User search';
+        $user2->lastname = 'Two';
+        $user2 = self::getDataGenerator()->create_user($user2);
+
+        $user3 = new stdClass();
+        $user3->firstname = 'User search';
+        $user3->lastname = 'Three';
+        $user3 = self::getDataGenerator()->create_user($user3);
+
+        $user4 = new stdClass();
+        $user4->firstname = 'User';
+        $user4->lastname = 'Four';
+        $user4 = self::getDataGenerator()->create_user($user4);
+
+        $user5 = new stdClass();
+        $user5->firstname = 'User search';
+        $user5->lastname = 'Five';
+        $user5 = self::getDataGenerator()->create_user($user5);
+
+        // Add a user as contact.
+        \core_message\api::add_contact($user1->id, $user2->id);
+
+        // Create private conversations with some users.
+        \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            array($user1->id, $user2->id));
+        \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            array($user3->id, $user1->id));
+
+        // Create a group conversation with users.
+        \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            array($user1->id, $user2->id, $user4->id),
+            'Project chat');
+
+        // Perform a search $CFG->messagingallusers setting enabled.
+        set_config('messagingallusers', 1);
+        $result = core_message_external::message_search_users($user1->id, 'search');
+
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $result = external_api::clean_returnvalue(core_message_external::message_search_users_returns(),
+            $result);
+
+        // Confirm that we returns contacts and non-contacts.
+        $contacts = $result['contacts'];
+        $noncontacts = $result['noncontacts'];
+
+        // Check that we retrieved the correct contacts.
+        $this->assertCount(1, $contacts);
+
+        // Check that we retrieved the correct conversations for contacts.
+        $this->assertCount(2, $contacts[0]['conversations']);
+
+        // Check that we retrieved the correct non-contacts.
+        $this->assertCount(2, $noncontacts);
+        $this->assertEquals($user5->id, $noncontacts[0]['id']);
+        $this->assertEquals($user3->id, $noncontacts[1]['id']);
+
+        // Check that we retrieved the correct conversations for non-contacts.
+        $this->assertCount(0, $noncontacts[0]['conversations']);
+        $this->assertCount(1, $noncontacts[1]['conversations']);
+    }
+
+    /**
+     * Tests searching users with messaging disabled.
+     */
+    public function test_message_search_users_messaging_disabled() {
+        $this->resetAfterTest(true);
+
+        // Create some skeleton data just so we can call the WS.
+        $user = self::getDataGenerator()->create_user();
+
+        // The person doing the search.
+        $this->setUser($user);
+
+        // Disable messaging.
+        set_config('messaging', 0);
+
+        // Ensure an exception is thrown.
+        $this->expectException('moodle_exception');
+        core_message_external::message_search_users($user->id, 'User');
+        $this->assertDebuggingCalled();
     }
 
     /**
@@ -3022,14 +3348,11 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         $this->assertContains($user1->id, $membersid);
         $this->assertContains($user2->id, $membersid);
         $this->assertContains($user3->id, $membersid);
-        $this->assertNotContains($user4->id, $membersid);
-        $this->assertNotContains($user5->id, $membersid);
+
         $membersfullnames = [$members[0]['fullname'], $members[1]['fullname'], $members[2]['fullname']];
         $this->assertContains(fullname($user1), $membersfullnames);
         $this->assertContains(fullname($user2), $membersfullnames);
         $this->assertContains(fullname($user3), $membersfullnames);
-        $this->assertNotContains(fullname($user4), $membersfullnames);
-        $this->assertNotContains(fullname($user5), $membersfullnames);
 
         // Confirm the messages data is correct.
         $messages = $result['messages'];
@@ -3154,7 +3477,6 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         $this->assertContains($user1->id, $membersid);
         $this->assertContains($user2->id, $membersid);
         $this->assertContains($user3->id, $membersid);
-        $this->assertNotContains($user4->id, $membersid);
 
         // Confirm the message data is correct.
         $messages = $result['messages'];
@@ -4443,6 +4765,7 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
             $this->assertArrayHasKey('id', $conv);
             $this->assertArrayHasKey('name', $conv);
             $this->assertArrayHasKey('subname', $conv);
+            $this->assertArrayHasKey('imageurl', $conv);
             $this->assertArrayHasKey('type', $conv);
             $this->assertArrayHasKey('membercount', $conv);
             $this->assertArrayHasKey('isfavourite', $conv);
@@ -4469,6 +4792,41 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         }
     }
 
+    /**
+     * Test verifying that html format messages are supported, and that message_format_message_text() is being called appropriately.
+     */
+    public function test_get_conversations_message_format() {
+        $this->resetAfterTest();
+
+        global $DB;
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        // Create conversation.
+        $conversation = \core_message\api::create_conversation(
+            \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user2->id]
+        );
+
+        // Send some messages back and forth.
+        $time = 1;
+        testhelper::send_fake_message_to_conversation($user2, $conversation->id, 'Sup mang?', $time + 1);
+        $mid = testhelper::send_fake_message_to_conversation($user1, $conversation->id, '<a href="#">A link</a>', $time + 2);
+        $message = $DB->get_record('messages', ['id' => $mid]);
+
+        // The user in scope.
+        $this->setUser($user1);
+
+        // Verify the format of the html message.
+        $expectedmessagetext = message_format_message_text($message);
+        $result = core_message_external::get_conversations($user1->id);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+        $messages = $conversations[0]['messages'];
+        $this->assertEquals($expectedmessagetext, $messages[0]['text']);
+    }
+
     /**
      * Tests retrieving conversations with a limit and offset to ensure pagination works correctly.
      */
@@ -4679,6 +5037,45 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         }
     }
 
+    /**
+     * Test verifying that group linked conversations are returned and contain a subname matching the course name.
+     */
+    public function test_get_conversations_group_linked() {
+        $this->resetAfterTest();
+        global $CFG;
+
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+
+        $course1 = $this->getDataGenerator()->create_course();
+
+        // Create a group with a linked conversation.
+        $this->setAdminUser();
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user3->id, $course1->id);
+        $group1 = $this->getDataGenerator()->create_group([
+            'courseid' => $course1->id,
+            'enablemessaging' => 1,
+            'picturepath' => $CFG->dirroot . '/lib/tests/fixtures/gd-logo.png'
+        ]);
+
+        // Add users to group1.
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1->id, 'userid' => $user2->id));
+
+        $result = core_message_external::get_conversations($user1->id, 0, 20, null, false);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+
+        $this->assertEquals(2, $conversations[0]['membercount']);
+        $this->assertEquals($course1->shortname, $conversations[0]['subname']);
+        $groupimageurl = get_group_picture_url($group1, $group1->courseid, true);
+        $this->assertEquals($groupimageurl, $conversations[0]['imageurl']);
+    }
+
     /**
      * Test returning members in a conversation with no contact requests.
      */
index 5eaf792..eb7a159 100644 (file)
@@ -35,6 +35,7 @@ information provided here is intended especially for developers.
   - \core_message\api::get_most_recent_message()
   - \core_message\helper::get_messages()
   - \core_message\helper::create_messages()
+  - \core_message\api::search_users()
 * The method \core_message\api::can_delete_conversation() now expects a 'conversationid' to be passed
   as the second parameter.
 * The following web services have been deprecated. Please do not call these any more.
@@ -46,6 +47,9 @@ information provided here is intended especially for developers.
     core_message_external::core_message_mark_all_conversation_messages_as_read() instead.
   - core_message_external::data_for_messagearea_conversations(), please use core_message_external::get_conversations()
     instead
+  - core_message_external::data_for_messagearea_search_users_in_course().
+  - core_message_external::data_for_messagearea_search_users(),
+    please use core_message_external::message_search_users() instead.
 * The following function has been added for getting the privacy messaging preference:
   - get_user_privacy_messaging_preference()
 
index 6a1b81a..858f951 100644 (file)
@@ -1025,7 +1025,9 @@ class mod_assign_renderer extends plugin_renderer_base {
             $grade = null;
             foreach ($history->grades as $onegrade) {
                 if ($onegrade->attemptnumber == $submission->attemptnumber) {
-                    $grade = $onegrade;
+                    if ($onegrade->grade != ASSIGN_GRADE_NOT_SET) {
+                        $grade = $onegrade;
+                    }
                     break;
                 }
             }
index ccaafe5..b826e1d 100644 (file)
@@ -15,7 +15,9 @@
 }
 
 .que.ddmarker .dragitems .draghome {
+    display: inline-block;
     margin: 10px;
+    vertical-align: top;
 }
 
 .que.ddmarker .dragitems {
index 21199d4..74b2e08 100644 (file)
@@ -1257,7 +1257,13 @@ class engine extends \core_search\engine {
 
         if ($CFG->proxyhost && !is_proxybypass('http://' . $this->config->server_hostname . '/')) {
             $options['proxy_host'] = $CFG->proxyhost;
-            $options['proxy_port'] = $CFG->proxyport;
+            if (!empty($CFG->proxyport)) {
+                $options['proxy_port'] = $CFG->proxyport;
+            }
+            if (!empty($CFG->proxyuser) && !empty($CFG->proxypassword)) {
+                $options['proxy_login'] = $CFG->proxyuser;
+                $options['proxy_password'] = $CFG->proxypassword;
+            }
         }
 
         if (!class_exists('\SolrClient')) {
index dcbdfe4..b603dd2 100644 (file)
@@ -131,8 +131,10 @@ $card-gutter : $card-deck-margin * 2;
 
 .dashboard-card-deck .dashboard-card {
     margin-bottom: $card-gutter;
-    flex-basis: 100%;
+    flex-basis: auto;
+    width: 100%;
     flex-grow: 0;
+    flex-shrink: 0;
     .dashboard-card-img {
         height: 7rem;
         background-position: center;
@@ -146,22 +148,22 @@ $card-gutter : $card-deck-margin * 2;
 .dashboard-card-deck {
     @include media-breakpoint-up(sm) {
         .dashboard-card {
-            flex-basis: calc(50% - #{$card-gutter});
+            width: calc(50% - #{$card-gutter});
         }
     }
     @include media-breakpoint-up(md) {
         .dashboard-card {
-            flex-basis: calc(33.33% - #{$card-gutter});
+            width: calc(33.33% - #{$card-gutter});
         }
     }
     @include media-breakpoint-up(lg) {
         .dashboard-card {
-            flex-basis: calc(25% - #{$card-gutter});
+            width: calc(25% - #{$card-gutter});
         }
     }
     @include media-breakpoint-up(xl) {
         .dashboard-card {
-            flex-basis: calc(20% - #{$card-gutter});
+            width: calc(20% - #{$card-gutter});
         }
     }
 }
@@ -170,12 +172,12 @@ $card-gutter : $card-deck-margin * 2;
     .dashboard-card-deck {
         @include media-breakpoint-up(lg) {
             .dashboard-card {
-                flex-basis: calc(33.33% - #{$card-gutter});
+                width: calc(33.33% - #{$card-gutter});
             }
         }
         @include media-breakpoint-up(xl) {
             .dashboard-card {
-                flex-basis: calc(25% - #{$card-gutter});
+                width: calc(25% - #{$card-gutter});
             }
         }
     }
@@ -185,12 +187,12 @@ body.drawer-open-left #region-main.has-blocks {
     .dashboard-card-deck {
         @include media-breakpoint-up(lg) {
             .dashboard-card {
-                flex-basis: calc(33.33% - #{$card-gutter});
+                width: calc(33.33% - #{$card-gutter});
             }
         }
         @media (min-width: 1400px) {
             .dashboard-card {
-                flex-basis: calc(25% - #{$card-gutter});
+                width: calc(25% - #{$card-gutter});
             }
         }
     }
@@ -201,7 +203,7 @@ body.drawer-open-left #region-main.has-blocks {
         .dashboard-card-deck {
             margin: 0;
             .dashboard-card {
-                flex-basis: 100% !important;
+                width: 100% !important;
                 margin-left: 0;
                 margin-right: 0;
             }
index 00cc1e6..0bc6d4c 100644 (file)
@@ -1139,15 +1139,6 @@ span.editinstructions {
     opacity: 0.5;
 }
 
-.course-header-image-wrapper {
-    width: 100px;
-    height: 100px;
-    .course-header-image {
-        background-size: cover;
-        background-position: center;
-    }
-}
-
 /**
  * Display sizes:
  * Large displays                   1200        +
index 4670378..4723022 100644 (file)
@@ -11259,8 +11259,10 @@ div.editor_atto_toolbar button .icon {
 
 .dashboard-card-deck .dashboard-card {
   margin-bottom: 0.5rem;
-  flex-basis: 100%;
-  flex-grow: 0; }
+  flex-basis: auto;
+  width: 100%;
+  flex-grow: 0;
+  flex-shrink: 0; }
   .dashboard-card-deck .dashboard-card .dashboard-card-img {
     height: 7rem;
     background-position: center;
@@ -11270,41 +11272,41 @@ div.editor_atto_toolbar button .icon {
 
 @media (min-width: 576px) {
   .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(50% - 0.5rem); } }
+    width: calc(50% - 0.5rem); } }
 
 @media (min-width: 768px) {
   .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(33.33% - 0.5rem); } }
+    width: calc(33.33% - 0.5rem); } }
 
 @media (min-width: 992px) {
   .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(25% - 0.5rem); } }
+    width: calc(25% - 0.5rem); } }
 
 @media (min-width: 1200px) {
   .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(20% - 0.5rem); } }
+    width: calc(20% - 0.5rem); } }
 
 @media (min-width: 992px) {
   #region-main.has-blocks .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(33.33% - 0.5rem); } }
+    width: calc(33.33% - 0.5rem); } }
 
 @media (min-width: 1200px) {
   #region-main.has-blocks .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(25% - 0.5rem); } }
+    width: calc(25% - 0.5rem); } }
 
 @media (min-width: 992px) {
   body.drawer-open-left #region-main.has-blocks .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(33.33% - 0.5rem); } }
+    width: calc(33.33% - 0.5rem); } }
 
 @media (min-width: 1400px) {
   body.drawer-open-left #region-main.has-blocks .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(25% - 0.5rem); } }
+    width: calc(25% - 0.5rem); } }
 
 @media (min-width: 1200px) {
   #block-region-side-pre .dashboard-card-deck {
     margin: 0; }
     #block-region-side-pre .dashboard-card-deck .dashboard-card {
-      flex-basis: 100% !important;
+      width: 100% !important;
       margin-left: 0;
       margin-right: 0; } }
 
@@ -12432,13 +12434,6 @@ span.editinstructions {
 .course-being-dragged {
   opacity: 0.5; }
 
-.course-header-image-wrapper {
-  width: 100px;
-  height: 100px; }
-  .course-header-image-wrapper .course-header-image {
-    background-size: cover;
-    background-position: center; }
-
 /**
  * Display sizes:
  * Large displays                   1200        +
index 4f5168f..5450089 100644 (file)
@@ -39,6 +39,7 @@
     * errorformatted - Formatted error,
     * logourl - Flag, logo url,
     * sitename - Name of site.
+    * logintoken - Random token to protect login request.
 
     Example context (json):
     {
@@ -87,7 +88,8 @@
         "cookieshelpiconformatted": "",
         "errorformatted": "",
         "logourl": false,
-        "sitename": "Beer & Chips"
+        "sitename": "Beer & Chips",
+        "logintoken": "randomstring"
     }
 }}
 
                     <form class="mt-3" action="{{loginurl}}" method="post" id="login">
                         <input id="anchor" type="hidden" name="anchor" value="">
                         <script>document.getElementById('anchor').value = location.hash;</script>
+                        <input type="hidden" name="logintoken" value="{{logintoken}}">
                         <div class="form-group">
                             <label for="username" class="sr-only">
                                 {{^canloginbyemail}}
                         <div class="mt-2">
                             <p>{{#str}}someallowguest{{/str}}</p>
                             <form action="{{loginurl}}" method="post" id="guestlogin">
+                                <input type="hidden" name="logintoken" value="{{logintoken}}">
                                 <input type="hidden" name="username" value="guest" />
                                 <input type="hidden" name="password" value="guest" />
                                 <button class="btn btn-secondary btn-block" type="submit">{{#str}}loginguest{{/str}}</button>
index 8adcac2..fb70df7 100644 (file)
@@ -1,6 +1,6 @@
 {{< core_form/element-template-inline }}
     {{$element}}
-        <div class="fdate_time_selector d-flex align-items-center">
+        <div class="fdate_time_selector d-flex flex-wrap align-items-center">
         {{#element.elements}}
             {{{separator}}}
             {{{html}}}
index 7edeaa7..7492e5e 100644 (file)
                 img.actionmenu {
                     width: auto;
                 }
+                .toggle-display[role="menuitem"] img.icon {
+                    width: 22px;
+                    vertical-align: middle;
+                }
             }
         }
     }
         flex-grow: 0;
         flex-shrink: 0;
         min-width: 0;
-        flex-basis: 100%;
+        width: 100%;
+        flex-basis: auto;
     }
     @media (min-width: 576px) {
         .dashboard-card {
             flex-direction: column;
             margin-right: 0.25rem;
             margin-left: 0.25rem;
-            flex-basis: ~"calc(50% - 0.6rem)";
+            width: ~"calc(50% - 0.5rem)";
         }
     }
 
     @media (min-width: 1200px) {
         .dashboard-card {
-            flex-basis: ~"calc(33.333% - 0.5rem)";
+            width: ~"calc(33.333% - 0.5rem)";
         }
     }
 }
         margin: 0;
         height: unset;
         .dashboard-card {
-            flex-basis: 100%;
+            width: 100%;
             margin-left: 0;
             margin-right: 0;
         }
index 0b0dceb..9c26f9a 100644 (file)
@@ -1124,17 +1124,6 @@ span.editinstructions {
     .opacity(50);
 }
 
-.course-header-image-wrapper {
-    width: 100px;
-    height: 100px;
-    .course-header-image {
-        width: 100%;
-        height: 100%;
-        background-size: cover;
-        background-position: center;
-    }
-}
-
 /**
  * Display sizes:
  * Large displays                   1200        +
index 230c0b0..50dce82 100644 (file)
@@ -7004,16 +7004,6 @@ span.editinstructions {
   opacity: 0.5;
   filter: alpha(opacity=50);
 }
-.course-header-image-wrapper {
-  width: 100px;
-  height: 100px;
-}
-.course-header-image-wrapper .course-header-image {
-  width: 100%;
-  height: 100%;
-  background-size: cover;
-  background-position: center;
-}
 /**
  * Display sizes:
  * Large displays                   1200        +
@@ -16084,6 +16074,10 @@ body {
 .editing .block .header .commands img.actionmenu {
   width: auto;
 }
+.editing .block .header .commands .toggle-display[role="menuitem"] img.icon {
+  width: 22px;
+  vertical-align: middle;
+}
 .jsenabled .block.hidden .content {
   display: none;
 }
@@ -16673,7 +16667,8 @@ body {
   flex-grow: 0;
   flex-shrink: 0;
   min-width: 0;
-  flex-basis: 100%;
+  width: 100%;
+  flex-basis: auto;
 }
 @media (min-width: 576px) {
   .dashboard-card-deck .dashboard-card {
@@ -16681,12 +16676,12 @@ body {
     flex-direction: column;
     margin-right: 0.25rem;
     margin-left: 0.25rem;
-    flex-basis: calc(50% - 0.6rem);
+    width: calc(50% - 0.5rem);
   }
 }
 @media (min-width: 1200px) {
   .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(33.333% - 0.5rem);
+    width: calc(33.333% - 0.5rem);
   }
 }
 @media (min-width: 768px) {
@@ -16697,7 +16692,7 @@ body {
   }
   #block-region-side-post .dashboard-card-deck .dashboard-card,
   #block-region-side-pre .dashboard-card-deck .dashboard-card {
-    flex-basis: 100%;
+    width: 100%;
     margin-left: 0;
     margin-right: 0;
   }
index 5393486..0eb3b97 100644 (file)
@@ -20,6 +20,9 @@ Feature: Deleting users
       | user2    | C1     | student        |
       | user3    | C1     | student        |
       | user4    | C1     | student        |
+    And the following config values are set as admin:
+      | messaging | 1 |
+      | messagingallusers | 1 |
 
   @javascript
   Scenario: Deleting one user at a time
index bb75a3b..80da7d3 100644 (file)
@@ -21,6 +21,9 @@ Feature: Access to full profiles of users
       | student2 | C1 | student |
       | teacher1 | C1 | editingteacher |
       | student3 | C2 | student |
+    And the following config values are set as admin:
+      | messaging | 1 |
+      | messagingallusers | 1 |
 
   Scenario: Viewing full profiles with default settings
     When I log in as "student1"
index ed3c32b..2c47582 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2018110700.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2018110700.01;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
 
index b0b71c3..94e3858 100644 (file)
@@ -167,7 +167,7 @@ class core_webservice_external extends external_api {
 
         // Retrieve some advanced features. Only enable/disable ones (bool).
         $advancedfeatures = array("usecomments", "usetags", "enablenotes", "messaging", "enableblogs",
-                                    "enablecompletion", "enablebadges");
+                                    "enablecompletion", "enablebadges", "messagingallusers");
         foreach ($advancedfeatures as $feature) {
             if (isset($CFG->{$feature})) {
                 $siteinfo['advancedfeatures'][] = array(