Merge branch 'MDL-61882-master' of git://github.com/cescobedo/moodle
authorJun Pataleta <jun@moodle.com>
Tue, 24 Apr 2018 01:38:27 +0000 (09:38 +0800)
committerJun Pataleta <jun@moodle.com>
Tue, 24 Apr 2018 01:38:27 +0000 (09:38 +0800)
108 files changed:
.eslintignore
.stylelintignore
admin/tool/assignmentupgrade/classes/privacy/provider.php [new file with mode: 0644]
admin/tool/assignmentupgrade/lang/en/tool_assignmentupgrade.php
admin/tool/assignmentupgrade/tests/privacy_test.php [new file with mode: 0644]
admin/tool/policy/classes/api.php
admin/tool/policy/classes/form/accept_policy.php
admin/tool/policy/classes/output/page_agreedocs.php
admin/tool/policy/classes/output/page_nopermission.php
admin/tool/policy/classes/output/user_agreement.php
admin/tool/policy/index.php
admin/tool/policy/tests/behat/acceptances.feature
admin/tool/policy/tests/behat/consent.feature
blocks/myoverview/classes/output/courses_view.php
blocks/myoverview/classes/privacy/provider.php
blocks/myoverview/lang/en/block_myoverview.php
blocks/myoverview/templates/course-paging-content-item.mustache
blocks/myoverview/templates/courses-view-course-item.mustache
blocks/myoverview/tests/privacy_test.php [new file with mode: 0644]
enrol/category/classes/privacy/provider.php [new file with mode: 0644]
enrol/category/lang/en/enrol_category.php
enrol/cohort/classes/privacy/provider.php [new file with mode: 0644]
enrol/cohort/lang/en/enrol_cohort.php
enrol/database/classes/privacy/provider.php [new file with mode: 0644]
enrol/database/lang/en/enrol_database.php
enrol/flatfile/classes/privacy/provider.php [new file with mode: 0644]
enrol/flatfile/lang/en/enrol_flatfile.php
enrol/guest/classes/privacy/provider.php [new file with mode: 0644]
enrol/guest/lang/en/enrol_guest.php
enrol/imsenterprise/classes/privacy/provider.php [new file with mode: 0644]
enrol/imsenterprise/lang/en/enrol_imsenterprise.php
enrol/ldap/classes/privacy/provider.php [new file with mode: 0644]
enrol/ldap/lang/en/enrol_ldap.php
enrol/manual/classes/privacy/provider.php [new file with mode: 0644]
enrol/manual/lang/en/enrol_manual.php
enrol/meta/classes/privacy/provider.php [new file with mode: 0644]
enrol/meta/lang/en/enrol_meta.php
enrol/mnet/classes/privacy/provider.php [new file with mode: 0644]
enrol/mnet/lang/en/enrol_mnet.php
enrol/self/classes/privacy/provider.php [new file with mode: 0644]
enrol/self/lang/en/enrol_self.php
lang/en/rss.php
lang/en/search.php
lang/en/userkey.php
lib/amd/build/form-autocomplete.min.js
lib/amd/src/form-autocomplete.js
lib/classes/component.php
lib/classes/geopattern.php [new file with mode: 0644]
lib/classes/text.php
lib/classes/user.php
lib/db/services.php
lib/form/autocomplete.php
lib/geopattern-php/GeoPattern/GeoPattern.php [new file with mode: 0644]
lib/geopattern-php/GeoPattern/SVG.php [new file with mode: 0644]
lib/geopattern-php/GeoPattern/SVGElements/Base.php [new file with mode: 0644]
lib/geopattern-php/GeoPattern/SVGElements/Circle.php [new file with mode: 0644]
lib/geopattern-php/GeoPattern/SVGElements/Group.php [new file with mode: 0644]
lib/geopattern-php/GeoPattern/SVGElements/Path.php [new file with mode: 0644]
lib/geopattern-php/GeoPattern/SVGElements/Polyline.php [new file with mode: 0644]
lib/geopattern-php/GeoPattern/SVGElements/Rectangle.php [new file with mode: 0644]
lib/geopattern-php/LICENSE.md [new file with mode: 0644]
lib/geopattern-php/moodle_readme.txt [new file with mode: 0644]
lib/moodlelib.php
lib/tests/text_test.php
lib/tests/user_test.php
lib/thirdpartylibs.xml
lib/userkey/classes/privacy/provider.php [new file with mode: 0644]
lib/userkey/tests/privacy_provider.php [new file with mode: 0644]
mod/chat/classes/privacy/provider.php [new file with mode: 0644]
mod/chat/lang/en/chat.php
mod/chat/tests/privacy_test.php [new file with mode: 0644]
mod/imscp/classes/privacy/provider.php [new file with mode: 0644]
mod/imscp/lang/en/imscp.php
mod/lti/amd/build/contentitem.min.js
mod/lti/amd/src/contentitem.js
mod/lti/locallib.php
mod/lti/mod_form.js
phpunit.xml.dist
plagiarism/classes/privacy/provider.php
portfolio/classes/privacy/provider.php
privacy/classes/local/request/transform.php
privacy/classes/local/sitepolicy/default_handler.php
privacy/classes/local/sitepolicy/manager.php
privacy/classes/manager.php
privacy/tests/request_transform_test.php
privacy/tests/sitepolicy_test.php
rss/classes/privacy/provider.php
rss/tests/privacy_test.php
search/amd/build/form-search-user-selector.min.js [new file with mode: 0644]
search/amd/src/form-search-user-selector.js [new file with mode: 0644]
search/classes/document.php
search/classes/engine.php
search/classes/external.php [new file with mode: 0644]
search/classes/manager.php
search/classes/output/form/search.php
search/engine/solr/classes/engine.php
search/engine/solr/tests/engine_test.php
search/templates/form-user-selector-suggestion.mustache [new file with mode: 0644]
search/tests/behat/search_by_user.feature [new file with mode: 0644]
search/tests/external_test.php [new file with mode: 0644]
search/upgrade.txt
theme/boost/scss/moodle/blocks.scss
theme/boost/templates/core_form/element-autocomplete.mustache
theme/bootstrapbase/less/moodle/blocks.less
theme/bootstrapbase/style/moodle.css
theme/bootstrapbase/templates/block_myoverview/course-paging-content-item.mustache
theme/bootstrapbase/templates/block_myoverview/courses-view-course-item.mustache
theme/bootstrapbase/templates/block_myoverview/paging-content-item.mustache [new file with mode: 0644]

index 296343b..6345306 100644 (file)
@@ -60,6 +60,7 @@ lib/fonts/
 lib/amd/src/adapter.js
 lib/validateurlsyntax.php
 lib/amd/src/popper.js
+lib/geopattern-php/
 media/player/videojs/amd/src/video-lazy.js
 media/player/videojs/amd/src/Youtube-lazy.js
 media/player/videojs/videojs/
index bdd33f8..6368112 100644 (file)
@@ -61,6 +61,7 @@ lib/fonts/
 lib/amd/src/adapter.js
 lib/validateurlsyntax.php
 lib/amd/src/popper.js
+lib/geopattern-php/
 media/player/videojs/amd/src/video-lazy.js
 media/player/videojs/amd/src/Youtube-lazy.js
 media/player/videojs/videojs/
diff --git a/admin/tool/assignmentupgrade/classes/privacy/provider.php b/admin/tool/assignmentupgrade/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..452cc15
--- /dev/null
@@ -0,0 +1,73 @@
+<?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/>.
+
+/**
+ * Privacy Subsystem implementation for tool_assignmentupgrade.
+ *
+ * @package    tool_assignmentupgrade
+ * @copyright  2018 Zig Tan <zig@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_assignmentupgrade\privacy;
+
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\writer;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Privacy Subsystem for tool_assignmentupgrade implementing metadata, plugin, and user_preference providers.
+ *
+ * @copyright  2018 Zig Tan <zig@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+    \core_privacy\local\metadata\provider,
+    \core_privacy\local\request\user_preference_provider {
+
+    /**
+     * Returns meta data about this system.
+     *
+     * @param   collection $collection The initialised collection to add items to.
+     * @return  collection     A listing of user data stored through this system.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_user_preference(
+            'tool_assignmentupgrade_perpage',
+            'privacy:metadata:preference:perpage'
+        );
+        return $collection;
+    }
+
+    /**
+     * Export all user preferences for the plugin.
+     *
+     * @param   int $userid The userid of the user whose data is to be exported.
+     */
+    public static function export_user_preferences(int $userid) {
+        $perpage = get_user_preferences('tool_assignmentupgrade_perpage', null, $userid);
+        if ($perpage !== null) {
+            writer::export_user_preference(
+                'tool_assignmentupgrade',
+                'perpage',
+                $perpage,
+                get_string('privacy:metadata:preference:perpage', 'tool_assignmentupgrade')
+            );
+        }
+    }
+
+}
index 9dc8c6e..c7e7d26 100644 (file)
@@ -58,3 +58,4 @@ $string['upgradeallconfirm'] = 'Upgrade all assignments?';
 $string['upgradeprogress'] = 'Upgrade assignment {$a->current} of {$a->total}';
 $string['upgradesingle'] = 'Upgrade single assignment';
 $string['viewcourse'] = 'View the course with the converted assignment';
+$string['privacy:metadata:preference:perpage'] = 'The assignment upgrade records per page preference set for the user.';
diff --git a/admin/tool/assignmentupgrade/tests/privacy_test.php b/admin/tool/assignmentupgrade/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..d22770c
--- /dev/null
@@ -0,0 +1,75 @@
+<?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/>.
+
+/**
+ * Privacy tests for tool_assignmentupgrade.
+ *
+ * @package    tool_assignmentupgrade
+ * @category   test
+ * @copyright  2018 Zig Tan <zig@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_privacy\tests\provider_testcase;
+use \core_privacy\local\request\writer;
+use \tool_assignmentupgrade\privacy\provider;
+
+/**
+ * Unit tests for tool_assignmentupgrade/classes/privacy/policy
+ *
+ * @copyright  2018 Zig Tan <zig@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_assignmentupgrade_privacy_testcase extends provider_testcase {
+
+    /**
+     * Overriding setUp() function to always reset after tests.
+     */
+    public function setUp() {
+        $this->resetAfterTest(true);
+    }
+
+    /**
+     * Test for provider::test_export_user_preferences().
+     */
+    public function test_export_user_preferences() {
+        // Test setup.
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        // Add a user home page preference for the User.
+        set_user_preference('tool_assignmentupgrade_perpage', '100', $user);
+
+        // Test the user preference exists.
+        $params = [
+            'userid' => $user->id,
+            'name' => 'tool_assignmentupgrade_perpage'
+        ];
+
+        // Test the user preferences export contains 1 user preference record for the User.
+        provider::export_user_preferences($user->id);
+        $contextuser = context_user::instance($user->id);
+        $writer = writer::with_context($contextuser);
+        $this->assertTrue($writer->has_any_data());
+
+        $exportedpreferences = $writer->get_user_preferences('tool_assignmentupgrade');
+        $this->assertCount(1, (array) $exportedpreferences);
+        $this->assertEquals('100', $exportedpreferences->perpage->value);
+    }
+
+}
index fab6d03..0b668d6 100644 (file)
@@ -29,6 +29,7 @@ use coding_exception;
 use context_helper;
 use context_system;
 use context_user;
+use core\session\manager;
 use stdClass;
 use tool_policy\event\acceptance_created;
 use tool_policy\event\acceptance_updated;
@@ -773,33 +774,67 @@ class api {
     }
 
     /**
-     * Accepts the current revisions of all policies that the user has not yet accepted
+     * Checks if user can accept policies for themselves or on behalf of another user
      *
-     * @param array|int $policyversionid
-     * @param int|null $userid
-     * @param string|null $note
-     * @param string|null $lang
+     * @param int $userid
+     * @param bool $throwexception
+     * @return bool
      */
-    public static function accept_policies($policyversionid, $userid = null, $note = null, $lang = null) {
-        global $DB, $USER;
+    public static function can_accept_policies($userid = null, $throwexception = false) {
+        global $USER;
         if (!isloggedin() || isguestuser()) {
-            throw new \moodle_exception('noguest');
+            if ($throwexception) {
+                throw new \moodle_exception('noguest');
+            } else {
+                return false;
+            }
         }
         if (!$userid) {
             $userid = $USER->id;
         }
+
+        if ($userid == $USER->id && !manager::is_loggedinas()) {
+            if ($throwexception) {
+                require_capability('tool/policy:accept', context_system::instance());
+                return;
+            } else {
+                return has_capability('tool/policy:accept', context_system::instance());
+            }
+        }
+
+        // Check capability to accept on behalf as the real user.
+        $realuser = manager::get_realuser();
         $usercontext = \context_user::instance($userid);
-        if ($userid == $USER->id) {
-            require_capability('tool/policy:accept', context_system::instance());
+        if ($throwexception) {
+            require_capability('tool/policy:acceptbehalf', $usercontext, $realuser);
+            return;
         } else {
-            require_capability('tool/policy:acceptbehalf', $usercontext);
+            return has_capability('tool/policy:acceptbehalf', $usercontext, $realuser);
         }
+    }
 
+    /**
+     * Accepts the current revisions of all policies that the user has not yet accepted
+     *
+     * @param array|int $policyversionid
+     * @param int|null $userid
+     * @param string|null $note
+     * @param string|null $lang
+     */
+    public static function accept_policies($policyversionid, $userid = null, $note = null, $lang = null) {
+        global $DB, $USER;
+        // Validate arguments and capabilities.
         if (empty($policyversionid)) {
             return;
         } else if (!is_array($policyversionid)) {
             $policyversionid = [$policyversionid];
         }
+        if (!$userid) {
+            $userid = $USER->id;
+        }
+        self::can_accept_policies($userid, true);
+
+        // Retrieve the list of policy versions that need agreement (do not update existing agreements).
         list($sql, $params) = $DB->get_in_or_equal($policyversionid, SQL_PARAMS_NAMED);
         $sql = "SELECT v.id AS versionid, a.*
                   FROM {tool_policy_versions} v
@@ -807,8 +842,9 @@ class api {
                   WHERE (a.id IS NULL or a.status <> 1) AND v.id " . $sql;
         $needacceptance = $DB->get_records_sql($sql, ['userid' => $userid] + $params);
 
+        $realuser = manager::get_realuser();
         $updatedata = ['status' => 1, 'lang' => $lang ?: current_language(),
-            'timemodified' => time(), 'usermodified' => $USER->id, 'note' => $note];
+            'timemodified' => time(), 'usermodified' => $realuser->id, 'note' => $note];
         foreach ($needacceptance as $versionid => $currentacceptance) {
             unset($currentacceptance->versionid);
             if ($currentacceptance->id) {
@@ -871,23 +907,16 @@ class api {
      */
     public static function revoke_acceptance($policyversionid, $userid, $note = null) {
         global $DB, $USER;
-        if (!isloggedin() || isguestuser()) {
-            throw new \moodle_exception('noguest');
-        }
         if (!$userid) {
             $userid = $USER->id;
         }
-        $usercontext = \context_user::instance($userid);
-        if ($userid == $USER->id) {
-            require_capability('tool/policy:accept', context_system::instance());
-        } else {
-            require_capability('tool/policy:acceptbehalf', $usercontext);
-        }
+        self::can_accept_policies($userid, true);
 
         if ($currentacceptance = $DB->get_record('tool_policy_acceptances',
                 ['policyversionid' => $policyversionid, 'userid' => $userid])) {
+            $realuser = manager::get_realuser();
             $updatedata = ['id' => $currentacceptance->id, 'status' => 0, 'timemodified' => time(),
-                'usermodified' => $USER->id, 'note' => $note];
+                'usermodified' => $realuser->id, 'note' => $note];
             $DB->update_record('tool_policy_acceptances', $updatedata);
             acceptance_updated::create_from_record((object)($updatedata + (array)$currentacceptance))->trigger();
         }
index 5b54e2c..8fcc474 100644 (file)
@@ -94,7 +94,7 @@ class accept_policy extends \moodleform {
      * @return array (userid=>username)
      */
     protected function validate_and_get_users($userids) {
-        global $DB, $USER;
+        global $DB;
         $usernames = [];
         list($sql, $params) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
         $params['usercontextlevel'] = CONTEXT_USER;
@@ -103,7 +103,6 @@ class accept_policy extends \moodleform {
             " FROM {user} u JOIN {context} ctx ON ctx.contextlevel=:usercontextlevel AND ctx.instanceid = u.id
             WHERE u.id " . $sql, $params);
 
-        $acceptany = has_capability('tool/policy:acceptbehalf', \context_system::instance());
         foreach ($userids as $userid) {
             if (!isset($users[$userid])) {
                 throw new \dml_missing_record_exception('user', 'id=?', [$userid]);
@@ -112,12 +111,8 @@ class accept_policy extends \moodleform {
             if (isguestuser($user)) {
                 throw new \moodle_exception('noguest');
             }
-            if ($userid == $USER->id) {
-                require_capability('tool/policy:accept', \context_system::instance());
-            } else if (!$acceptany) {
-                \context_helper::preload_from_record($user);
-                require_capability('tool/policy:acceptbehalf', \context_user::instance($userid));
-            }
+            \context_helper::preload_from_record($user);
+            api::can_accept_policies($userid, true);
             $usernames[$userid] = fullname($user);
         }
         return $usernames;
index 171a62a..278e97b 100644 (file)
@@ -29,6 +29,7 @@ defined('MOODLE_INTERNAL') || die();
 
 use context_system;
 use core\output\notification;
+use core\session\manager;
 use core_user;
 use html_writer;
 use moodle_url;
@@ -77,21 +78,19 @@ class page_agreedocs implements renderable, templatable {
      */
     public function __construct($agreedocs = null, $behalfid = 0, $action = null) {
         global $USER;
+        $realuser = manager::get_realuser();
 
         $this->agreedocs = $agreedocs;
         if (empty($this->agreedocs)) {
             $this->agreedocs = [];
         }
 
-        $this->behalfid = $behalfid;
         $this->action = $action;
 
-        if (!empty($this->behalfid) && $USER->id != $this->behalfid) {
-            $this->behalfuser = core_user::get_user($this->behalfid, '*');
-            // If behalf user doesn't exist, behalfid parameter will be ignored.
-            if ($this->behalfuser === false) {
-                $this->behalfid = 0;
-            }
+        $behalfid = $behalfid ?: $USER->id;
+        if ($realuser->id != $behalfid) {
+            $this->behalfuser = core_user::get_user($behalfid, '*', MUST_EXIST);
+            $this->behalfid = $this->behalfuser->id;
         }
 
         $this->policies = api::list_current_versions(policy_version::AUDIENCE_LOGGEDIN);
@@ -267,12 +266,7 @@ class page_agreedocs implements renderable, templatable {
         // Check for correct user capabilities.
         if (!empty($USER->id)) {
             // For existing users, it's needed to check if they have the capability for accepting policies.
-            if (empty($this->behalfid) || $this->behalfid == $USER->id) {
-                require_capability('tool/policy:accept', context_system::instance());
-            } else {
-                $usercontext = \context_user::instance($this->behalfid);
-                require_capability('tool/policy:acceptbehalf', $usercontext);
-            }
+            api::can_accept_policies($this->behalfid, true);
         } else {
             // For new users, the behalfid parameter is ignored.
             if ($this->behalfid != $USER->id) {
index 94c0ec8..0e56487 100644 (file)
@@ -25,6 +25,7 @@
 
 namespace tool_policy\output;
 
+use core\session\manager;
 use moodle_exception;
 
 defined('MOODLE_INTERNAL') || die();
@@ -69,23 +70,16 @@ class page_nopermission implements renderable, templatable {
     public function __construct($behalfid) {
         global $USER;
 
-        $this->behalfid = $behalfid;
-        if (!empty($this->behalfid) && $USER->id != $this->behalfid) {
-            $this->behalfuser = core_user::get_user($this->behalfid, '*');
-            // If behalf user doesn't exist, behalfid parameter will be ignored.
-            if ($this->behalfuser === false) {
-                $this->behalfid = 0;
-            }
+        $behalfid = $behalfid ?: $USER->id;
+        $realuser = manager::get_realuser();
+        if ($realuser->id != $behalfid) {
+            $this->behalfuser = core_user::get_user($behalfid, '*', MUST_EXIST);
+            $this->behalfid = $this->behalfuser->id;
         }
 
         if (!empty($USER->id)) {
             // For existing users, it's needed to check if they have the capability for accepting policies.
-            if (empty($this->behalfid) || $this->behalfid == $USER->id) {
-                $this->haspermissionagreedocs = has_capability('tool/policy:accept', context_system::instance());
-            } else {
-                $usercontext = \context_user::instance($this->behalfid);
-                $this->haspermissionagreedocs = has_capability('tool/policy:acceptbehalf', $usercontext);
-            }
+            $this->haspermissionagreedocs = api::can_accept_policies($this->behalfid);
         }
 
         $this->policies = api::list_current_versions(policy_version::AUDIENCE_LOGGEDIN);
@@ -128,7 +122,7 @@ class page_nopermission implements renderable, templatable {
      * Export the page data for the mustache template.
      *
      * @param renderer_base $output renderer to be used to render the page elements.
-     * @return stdClass
+     * @return \stdClass
      */
     public function export_for_template(renderer_base $output) {
         global $CFG;
index 04db65e..b1d96c8 100644 (file)
@@ -77,8 +77,7 @@ class user_agreement implements \templatable, \renderable {
         $this->accepted = $accepted;
         $this->canaccept = $canaccept;
         if (count($this->accepted) < count($this->versions) && $canaccept === null) {
-            $this->canaccept = (has_capability('tool/policy:acceptbehalf', \context_system::instance()) ||
-                has_capability('tool/policy:acceptbehalf', \context_user::instance($this->userid)));
+            $this->canaccept = \tool_policy\api::can_accept_policies($this->userid);
         }
     }
 
index 38073ac..c420c5f 100644 (file)
@@ -44,15 +44,9 @@ $PAGE->set_context(context_system::instance());
 $PAGE->set_url('/admin/tool/policy/index.php');
 $PAGE->set_popup_notification_allowed(false);
 
-$haspermissionagreedocs = false;
 if (!empty($USER->id)) {
     // Existing user.
-    if (empty($behalfid) || $behalfid == $USER->id) {
-        $haspermissionagreedocs = has_capability('tool/policy:accept', context_system::instance());
-    } else {
-        $usercontext = \context_user::instance($behalfid);
-        $haspermissionagreedocs = has_capability('tool/policy:acceptbehalf', $usercontext);
-    }
+    $haspermissionagreedocs = api::can_accept_policies($behalfid);
 } else {
     // New user.
     $haspermissionagreedocs = true;
@@ -63,6 +57,9 @@ if (!$haspermissionagreedocs) {
 } else if ($cancel) {
     redirect(new moodle_url('/'));
 } else {
+    if (!$behalfid && \core\session\manager::is_loggedinas()) {
+        $behalfid = $USER->id;
+    }
     $outputpage = new \tool_policy\output\page_agreedocs($agreedocs, $behalfid, $submit);
 }
 
index cfca937..5bdd926 100644 (file)
@@ -228,3 +228,34 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
     And I navigate to course participants
     And I follow "User Two"
     Then I should see "Policies and agreements"
+
+  Scenario: Agree on behalf of another user as an admin who is logged in as a manager
+    Given I log in as "admin"
+    And I set the following system permissions of "Manager" role:
+      | capability | permission |
+      | tool/policy:acceptbehalf | Allow |
+    And I log out
+    When I log in as "manager"
+    And I press "Next"
+    And I set the field "I agree to the This site policy" to "1"
+    And I press "Next"
+    And I log out
+    And I log in as "admin"
+    And I navigate to "Users > Accounts > Browse list of users" in site administration
+    And I follow "Manager"
+    And I follow "Log in as"
+    And I press "Continue"
+    And I navigate to "Privacy and policies > Manage policies" in site administration
+    And I click on "1 of 4 (25%)" "link" in the "This site policy" "table_row"
+    And I click on "Not agreed" "link" in the "User One" "table_row"
+    Then I should see "Consent details"
+    And I should see "User One"
+    And I should see "This site policy"
+    And I should see "I acknowledge that consents to these policies have been acquired"
+    And I set the field "Remarks" to "Consent received from a parent"
+    And I press "I agree to the policy"
+    And "Agreed on behalf of" "icon" should exist in the "User One" "table_row"
+    And "Max Manager" "link" should not exist in the "User One" "table_row"
+    And "Admin User" "link" should exist in the "User One" "table_row"
+    And "Consent received from a parent" "text" should exist in the "User One" "table_row"
+    And "Not agreed" "icon" should exist in the "User Two" "table_row"
index 4ceaff9..ec9aae6 100644 (file)
@@ -551,3 +551,63 @@ Feature: User must accept policy managed by this plugin when logging in and sign
     When I press "Next"
     Then I should not see "I understand and agree"
     And I should see "New account"
+
+  Scenario: Accept policy while being logged in as another user
+    Given the following config values are set as admin:
+      | sitepolicyhandler | tool_policy |
+    And the following policies exist:
+      | Name                | Type | Revision | Content    | Summary     | Status   | Audience |
+      | This site policy    | 0    |          | full text2 | short text2 | active   | all      |
+      | This privacy policy | 1    |          | full text3 | short text3 | active   | loggedin |
+    And the following "users" exist:
+      | username | firstname | lastname | email            |
+      | user1    | User      | 1        | user1@example.com    |
+    When I log in as "admin"
+    And I navigate to "Users > Accounts > Browse list of users" in site administration
+    And I follow "User 1"
+    And I follow "Log in as"
+    Then I should see "You are logged in as User 1"
+    And I press "Continue"
+    And I should see "Please read our This site policy"
+    And I press "Next"
+    And I should see "Please read our This privacy policy"
+    And I press "Next"
+    And I should see "Viewing this page on behalf of User 1"
+    And I set the field "I agree to the This site policy" to "1"
+    And I set the field "I agree to the This privacy policy" to "1"
+    And I press "Next"
+    And I log out
+    And I log in as "user1"
+    And I follow "Profile" in the user menu
+    And I follow "Policies and agreements"
+    And "Admin User" "link" should exist in the "This site policy" "table_row"
+    And "Admin User" "link" should exist in the "This privacy policy" "table_row"
+
+  Scenario: Log in as another user without capability to accept policies on their behalf
+    Given the following config values are set as admin:
+      | sitepolicyhandler | tool_policy |
+    And the following policies exist:
+      | Name                | Type | Revision | Content    | Summary     | Status   | Audience |
+      | This site policy    | 0    |          | full text2 | short text2 | active   | all      |
+      | This privacy policy | 1    |          | full text3 | short text3 | active   | loggedin |
+    And the following "users" exist:
+      | username | firstname | lastname | email            |
+      | user1    | User      | 1        | user1@example.com    |
+      | manager  | Max       | Manager  | man@example.com |
+    And the following "role assigns" exist:
+      | user    | role           | contextlevel | reference |
+      | manager | manager        | System       |           |
+    When I log in as "manager"
+    And I press "Next"
+    And I press "Next"
+    And I set the field "I agree to the This site policy" to "1"
+    And I set the field "I agree to the This privacy policy" to "1"
+    And I press "Next"
+    And I navigate to "Users > Accounts > Browse list of users" in site administration
+    And I follow "User 1"
+    And I follow "Log in as"
+    Then I should see "You are logged in as User 1"
+    And I press "Continue"
+    And I should see "Policies and agreements"
+    And I should see "No permission to agree to the policies on behalf of this user"
+    And I should see "Sorry, you do not have the required permission to agree to the following policies on behalf of User 1"
index 2f8cb9a..b00741d 100644 (file)
@@ -65,6 +65,7 @@ class courses_view implements renderable, templatable {
     public function export_for_template(renderer_base $output) {
         global $CFG;
         require_once($CFG->dirroot.'/course/lib.php');
+        require_once($CFG->dirroot.'/lib/coursecatlib.php');
 
         // Build courses view data structure.
         $coursesview = [
@@ -83,6 +84,29 @@ class courses_view implements renderable, templatable {
             // Convert summary to plain text.
             $exportedcourse->summary = content_to_text($exportedcourse->summary, $exportedcourse->summaryformat);
 
+            $course = new \course_in_list($course);
+            foreach ($course->get_course_overviewfiles() as $file) {
+                $isimage = $file->is_valid_image();
+                if ($isimage) {
+                    $url = file_encode_url("$CFG->wwwroot/pluginfile.php",
+                        '/'. $file->get_contextid(). '/'. $file->get_component(). '/'.
+                        $file->get_filearea(). $file->get_filepath(). $file->get_filename(), !$isimage);
+                    $exportedcourse->courseimage = $url;
+                    $exportedcourse->classes = 'courseimage';
+                    break;
+                }
+            }
+
+            $exportedcourse->color = $this->coursecolor($course->id);
+
+            if (!isset($exportedcourse->courseimage)) {
+                $pattern = new \core_geopattern();
+                $pattern->setColor($exportedcourse->color);
+                $pattern->patternbyid($courseid);
+                $exportedcourse->classes = 'coursepattern';
+                $exportedcourse->courseimage = $pattern->datauri();
+            }
+
             // Include course visibility.
             $exportedcourse->visible = (bool)$course->visible;
 
@@ -148,4 +172,19 @@ class courses_view implements renderable, templatable {
 
         return $coursesview;
     }
+
+    /**
+     * Generate a semi-random color based on the courseid number (so it will always return
+     * the same color for a course)
+     *
+     * @param int $courseid
+     * @return string $color, hexvalue color code.
+     */
+    protected function coursecolor($courseid) {
+        // The colour palette is hardcoded for now. It would make sense to combine it with theme settings.
+        $basecolors = ['#81ecec', '#74b9ff', '#a29bfe', '#dfe6e9', '#00b894', '#0984e3', '#b2bec3', '#fdcb6e', '#fd79a8', '#6c5ce7'];
+
+        $color = $basecolors[$courseid % 10];
+        return $color;
+    }
 }
index 9f46f60..d0ee9e8 100644 (file)
@@ -27,20 +27,35 @@ namespace block_myoverview\privacy;
 defined('MOODLE_INTERNAL') || die();
 
 /**
- * Privacy Subsystem for block_myoverview implementing null_provider.
+ * Privacy Subsystem for block_myoverview.
  *
  * @copyright  2018 Zig Tan <zig@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements \core_privacy\local\metadata\null_provider {
+class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\user_preference_provider {
 
     /**
-     * Get the language string identifier with the component's language
-     * file to explain why this plugin stores no data.
+     * Returns meta-data information about the myoverview block.
      *
-     * @return  string
+     * @param  \core_privacy\local\metadata\collection $collection A collection of meta-data.
+     * @return \core_privacy\local\metadata\collection Return the collection of meta-data.
      */
-    public static function get_reason() : string {
-        return 'privacy:metadata';
+    public static function get_metadata(\core_privacy\local\metadata\collection $collection) :
+            \core_privacy\local\metadata\collection {
+        $collection->add_user_preference('block_myoverview_last_tab', 'privacy:metadata:overviewlasttab');
+        return $collection;
+    }
+
+    /**
+     * Export all user preferences for the myoverview block
+     *
+     * @param int $userid The userid of the user whose data is to be exported.
+     */
+    public static function export_user_preferences(int $userid) {
+        $preference = get_user_preferences('block_myoverview_last_tab', null, $userid);
+        if (isset($preference)) {
+            \core_privacy\local\request\writer::export_user_preference('block_myoverview', 'block_myoverview_last_tab',
+                    $preference, get_string('privacy:metadata:overviewlasttab', 'block_myoverview'));
+        }
     }
 }
index c762db0..a3ca64e 100644 (file)
@@ -44,4 +44,4 @@ $string['sortbydates'] = 'Sort by dates';
 $string['timeline'] = 'Timeline';
 $string['viewcourse'] = 'View course';
 $string['viewcoursename'] = 'View course {$a}';
-$string['privacy:metadata'] = 'The Course overview block only shows data stored in other locations.';
+$string['privacy:metadata:overviewlasttab'] = 'This stores the last tab selected by the user on the overview block.';
index e9c99f7..bbaa637 100644 (file)
@@ -38,7 +38,7 @@
     }
 }}
 {{< block_myoverview/paging-content-item }}
-    {{$classes}}row{{/classes}}
+    {{$classes}}row card-deck{{/classes}}
     {{$content}}
         {{#courses}}
             {{> block_myoverview/courses-view-course-item }}
index afe5f2f..db2034d 100644 (file)
         "summary": "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout."
     }
 }}
-<div class="col-lg-6">
-    <div class="card mb-3 courses-view-course-item">
-        <div class="card-body course-info-container" id="course-info-container-{{id}}">
+<div class="card mb-3 courses-view-course-item">
+    <a href="{{viewurl}}">
+        <div class="card-img-top myoverviewimg {{classes}}" style='background-image: url("{{{courseimage}}}");'>
+        </div>
+    </a>
+    <div class="card-body course-info-container" id="course-info-container-{{id}}">
 
-            <div class="media">
-                <div class="mr-2">
-                    {{> block_myoverview/progress-chart}}
-                </div>
-                <div class="media-body">
-                    <h4 class="h5"><a href="{{viewurl}}" class="{{^visible}}dimmed{{/visible}}">{{{fullnamedisplay}}}</a></h4>
-                </div>
+        <div class="media">
+            <div class="mr-2">
+                {{> block_myoverview/progress-chart}}
+            </div>
+            <div class="media-body">
+                <h4 class="h5"><a href="{{viewurl}}" class="{{^visible}}dimmed{{/visible}}">{{{fullnamedisplay}}}</a></h4>
             </div>
-
-            <p class="text-muted">
-                {{#shortentext}} 140, {{summary}}{{/shortentext}}
-            </p>
         </div>
+
+        <p class="text-muted">
+            {{#shortentext}} 140, {{summary}}{{/shortentext}}
+        </p>
     </div>
 </div>
\ No newline at end of file
diff --git a/blocks/myoverview/tests/privacy_test.php b/blocks/myoverview/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..875dd03
--- /dev/null
@@ -0,0 +1,80 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for the block_myoverview implementation of the privacy API.
+ *
+ * @package    block_myoverview
+ * @category   test
+ * @copyright  2018 Adrian Greeve <adriangreeve.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_privacy\local\request\writer;
+use \block_myoverview\privacy\provider;
+
+/**
+ * Unit tests for the block_myoverview implementation of the privacy API.
+ *
+ * @copyright  2018 Adrian Greeve <adriangreeve.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class block_myoverview_privacy_testcase extends \core_privacy\tests\provider_testcase {
+
+    /**
+     * Ensure that export_user_preferences returns no data if the user has not visited the myoverview block.
+     */
+    public function test_export_user_preferences_no_pref() {
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        provider::export_user_preferences($user->id);
+        $writer = writer::with_context(\context_system::instance());
+        $this->assertFalse($writer->has_any_data());
+    }
+
+    /**
+     * Test that the preference courses is exported properly.
+     */
+    public function test_export_user_preferences_course_preference() {
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        set_user_preference('block_myoverview_last_tab', 'courses', $user);
+
+        provider::export_user_preferences($user->id);
+        $writer = writer::with_context(\context_system::instance());
+        $blockpreferences = $writer->get_user_preferences('block_myoverview');
+        $this->assertEquals('courses', $blockpreferences->block_myoverview_last_tab->value);
+    }
+
+    /**
+     * Test that the preference timeline is exported properly.
+     */
+    public function test_export_user_preferences_timeline_preference() {
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        set_user_preference('block_myoverview_last_tab', 'timeline', $user);
+
+        provider::export_user_preferences($user->id);
+        $writer = writer::with_context(\context_system::instance());
+        $blockpreferences = $writer->get_user_preferences('block_myoverview');
+        $this->assertEquals('timeline', $blockpreferences->block_myoverview_last_tab->value);
+    }
+}
diff --git a/enrol/category/classes/privacy/provider.php b/enrol/category/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..47ced3e
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Privacy Subsystem implementation for enrol_category.
+ *
+ * @package    enrol_category
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace enrol_category\privacy;
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Privacy Subsystem for enrol_category implementing null_provider.
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
\ No newline at end of file
index 1c36a0f..5e139c4 100644 (file)
@@ -27,3 +27,4 @@ $string['category:synchronised'] = 'Role assignments synchronised to course enro
 $string['enrolcategorysynctask'] = 'Category enrolment sync task';
 $string['pluginname'] = 'Category enrolments';
 $string['pluginname_desc'] = 'The category enrolments plugin synchronises any role assignments in the category context for roles with the capability enrol/category:synchronised allowed.';
+$string['privacy:metadata'] = 'The Category enrolments plugin does not store any personal data.';
diff --git a/enrol/cohort/classes/privacy/provider.php b/enrol/cohort/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..7f0b932
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Privacy Subsystem implementation for enrol_cohort.
+ *
+ * @package    enrol_cohort
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace enrol_cohort\privacy;
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Privacy Subsystem for enrol_cohort implementing null_provider.
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
\ No newline at end of file
index 50fdd26..a1e23cf 100644 (file)
@@ -33,3 +33,4 @@ $string['pluginname'] = 'Cohort sync';
 $string['pluginname_desc'] = 'Cohort enrolment plugin synchronises cohort members with course participants.';
 $string['status'] = 'Active';
 $string['creategroup'] = 'Create new group';
+$string['privacy:metadata'] = 'The Cohort sync enrolments plugin does not store any personal data.';
diff --git a/enrol/database/classes/privacy/provider.php b/enrol/database/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..eb0c3cd
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Privacy Subsystem implementation for enrol_database.
+ *
+ * @package    enrol_database
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace enrol_database\privacy;
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Privacy Subsystem for enrol_database implementing null_provider.
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
\ No newline at end of file
index 8fc2e39..be21f84 100644 (file)
@@ -73,3 +73,4 @@ $string['settingsheadernewcourses'] = 'Creation of new courses';
 $string['remoteuserfield_desc'] = 'The name of the field in the remote table that we are using to match entries in the user table.';
 $string['templatecourse'] = 'New course template';
 $string['templatecourse_desc'] = 'Optional: auto-created courses can copy their settings from a template course. Type here the shortname of the template course.';
+$string['privacy:metadata'] = 'The External database enrolments plugin does not store any personal data.';
diff --git a/enrol/flatfile/classes/privacy/provider.php b/enrol/flatfile/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..5e403cd
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Privacy Subsystem implementation for enrol_flatfile.
+ *
+ * @package    enrol_flatfile
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace enrol_flatfile\privacy;
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Privacy Subsystem for enrol_flatfile implementing null_provider.
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
\ No newline at end of file
index 8861928..12043ae 100644 (file)
@@ -61,3 +61,4 @@ It could look something like this:
    del, student, 17, CF101
    add, student, 21, CF101, 1091115000, 1091215000
 </pre>';
+$string['privacy:metadata'] = 'The Flat file (CSV) enrolments plugin does not store any personal data.';
diff --git a/enrol/guest/classes/privacy/provider.php b/enrol/guest/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..cf03919
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Privacy Subsystem implementation for enrol_guest.
+ *
+ * @package    enrol_guest
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace enrol_guest\privacy;
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Privacy Subsystem for enrol_guest implementing null_provider.
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
\ No newline at end of file
index f536bb2..fa97e43 100644 (file)
@@ -43,3 +43,4 @@ $string['status_help'] = 'This setting determines whether a user can access the
 $string['status_link'] = 'enrol/guest';
 $string['usepasswordpolicy'] = 'Use password policy';
 $string['usepasswordpolicy_desc'] = 'Use standard password policy for guest access passwords.';
+$string['privacy:metadata'] = 'The Guest access enrolments plugin does not store any personal data.';
diff --git a/enrol/imsenterprise/classes/privacy/provider.php b/enrol/imsenterprise/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..ad24aac
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Privacy Subsystem implementation for enrol_imsenterprise.
+ *
+ * @package    enrol_imsenterprise
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace enrol_imsenterprise\privacy;
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Privacy Subsystem for enrol_imsenterprise implementing null_provider.
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
\ No newline at end of file
index 01caa10..7e4a444 100644 (file)
@@ -92,3 +92,4 @@ $string['zeroisnotruncation'] = '0 indicates no truncation';
 $string['roles'] = 'Roles';
 $string['ignore'] = 'Ignore';
 $string['importimsfile'] = 'Import IMS Enterprise file';
+$string['privacy:metadata'] = 'The IMS Enterprise file enrolments plugin does not store any personal data.';
diff --git a/enrol/ldap/classes/privacy/provider.php b/enrol/ldap/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..2354006
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Privacy Subsystem implementation for enrol_ldap.
+ *
+ * @package    enrol_ldap
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace enrol_ldap\privacy;
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Privacy Subsystem for enrol_ldap implementing null_provider.
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
\ No newline at end of file
index e0834e2..7b4db43 100644 (file)
@@ -128,3 +128,4 @@ $string['user_type'] = 'If the group membership contains distinguished names, sp
 $string['user_type_key'] = 'User type';
 $string['version'] = 'The version of the LDAP protocol your server is using';
 $string['version_key'] = 'Version';
+$string['privacy:metadata'] = 'The LDAP enrolments plugin does not store any personal data.';
diff --git a/enrol/manual/classes/privacy/provider.php b/enrol/manual/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..20d6f56
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Privacy Subsystem implementation for enrol_manual.
+ *
+ * @package    enrol_manual
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace enrol_manual\privacy;
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Privacy Subsystem for enrol_manual implementing null_provider.
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
\ No newline at end of file
index 1d68899..e77ddb5 100644 (file)
@@ -82,3 +82,4 @@ $string['wscannotenrol'] = 'Plugin instance cannot manually enrol a user in the
 $string['wsnoinstance'] = 'Manual enrolment plugin instance doesn\'t exist or is disabled for the course (id = {$a->courseid})';
 $string['wsusercannotassign'] = 'You don\'t have the permission to assign this role ({$a->roleid}) to this user ({$a->userid}) in this course({$a->courseid}).';
 $string['manualpluginnotinstalled'] = 'The "Manual" plugin has not yet been installed';
+$string['privacy:metadata'] = 'The Manual enrolments plugin does not store any personal data.';
diff --git a/enrol/meta/classes/privacy/provider.php b/enrol/meta/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..04db729
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Privacy Subsystem implementation for enrol_meta.
+ *
+ * @package    enrol_meta
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace enrol_meta\privacy;
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Privacy Subsystem for enrol_meta implementing null_provider.
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
\ No newline at end of file
index e2b43d3..f2a90d0 100644 (file)
@@ -38,3 +38,4 @@ $string['pluginname'] = 'Course meta link';
 $string['pluginname_desc'] = 'Course meta link enrolment plugin synchronises enrolments and roles in two different courses.';
 $string['syncall'] = 'Synchronise all enrolled users';
 $string['syncall_desc'] = 'If enabled all enrolled users are synchronised even if they have no role in parent course, if disabled only users that have at least one synchronised role are enrolled in child course.';
+$string['privacy:metadata'] = 'The Course meta link enrolments plugin does not store any personal data.';
diff --git a/enrol/mnet/classes/privacy/provider.php b/enrol/mnet/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..7af9084
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Privacy Subsystem implementation for enrol_mnet.
+ *
+ * @package    enrol_mnet
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace enrol_mnet\privacy;
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Privacy Subsystem for enrol_mnet implementing null_provider.
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
\ No newline at end of file
index 2fb3ed0..4741841 100644 (file)
@@ -35,3 +35,4 @@ $string['remotesubscriber_help'] = 'Select \'All hosts\' to open this course for
 $string['remotesubscribersall'] = 'All hosts';
 $string['roleforremoteusers'] = 'Role for their users';
 $string['roleforremoteusers_help'] = 'What role will the remote users from the selected host get.';
+$string['privacy:metadata'] = 'The MNet remote enrolments plugin does not store any personal data.';
diff --git a/enrol/self/classes/privacy/provider.php b/enrol/self/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..53fe822
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Privacy Subsystem implementation for enrol_self.
+ *
+ * @package    enrol_self
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace enrol_self\privacy;
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Privacy Subsystem for enrol_self implementing null_provider.
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
\ No newline at end of file
index c5aabd6..75fc027 100644 (file)
@@ -121,3 +121,4 @@ $string['welcometocoursetext'] = 'Welcome to {$a->coursename}!
 If you have not done so already, you should edit your profile page so that we can learn more about you:
 
   {$a->profileurl}';
+$string['privacy:metadata'] = 'The Self enrolments plugin does not store any personal data.';
index 3ed277a..809e938 100644 (file)
@@ -24,9 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$string['privacy:metadata:user_private_key'] = 'Information about the user\'s access keys used in cookieless scripts, such as RSS.';
-$string['privacy:metadata:user_private_key:timecreated'] = 'The timestamp indicating when the key was created';
-$string['privacy:metadata:user_private_key:userid'] = 'The ID of the user which is associated to this key';
-$string['privacy:metadata:user_private_key:validuntil'] = 'The timestamp indicating when the key will expire';
-$string['privacy:metadata:user_private_key:value'] = 'The token used for getting the ID of the user';
+
+$string['privacy:metadata:core_userkey'] = 'User\'s keys used to access RSS from a URL';
 $string['rss'] = 'RSS';
index 6eba816..ea13271 100644 (file)
@@ -26,6 +26,7 @@ $string['advancedsearch'] = 'Advanced search';
 $string['all'] = 'All';
 $string['allareas'] = 'All areas';
 $string['allcourses'] = 'All courses';
+$string['allusers'] = 'All users';
 $string['author'] = 'Author';
 $string['authorname'] = 'Author name';
 $string['back'] = 'Back';
index 35d772c..69614d2 100644 (file)
@@ -33,6 +33,14 @@ $string['keyvaliduntil'] = 'Key valid until';
 $string['keyvaliduntil_help'] = 'Select an optional date after which the key will no longer be valid (recommended for added security).';
 $string['keyvalue'] = 'Key value';
 $string['newuserkey'] = 'New user key';
+$string['privacy:metadata:user_private_key:script'] = 'The script which is responsible for the user key.';
+$string['privacy:metadata:user_private_key:value'] = 'The value of the key.';
+$string['privacy:metadata:user_private_key:userid'] = 'The user associated with the key.';
+$string['privacy:metadata:user_private_key:instance'] = 'The instance of the script.';
+$string['privacy:metadata:user_private_key:iprestriction'] = 'The IP address range that this key can be used from.';
+$string['privacy:metadata:user_private_key:validuntil'] = 'The date and time that the private key is valid until.';
+$string['privacy:metadata:user_private_key:timecreated'] = 'The date and time that the key was created.';
+$string['privacy:metadata:user_private_key'] = 'Private keys for the user.';
 $string['userkey'] = 'User key';
 $string['userkey_help'] = 'Select a saved key that will give users access to the data published by this export plugin, without having to log into Moodle. Select "Create a new user key" to generate a new key when submitting this form.';
 $string['userkeys'] = 'User keys';
index e5c8906..954829b 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js and b/lib/amd/build/form-autocomplete.min.js differ
index 29ae071..ea2fab3 100644 (file)
@@ -92,7 +92,13 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
         }
         originalSelect.children('option').each(function(index, ele) {
             if ($(ele).prop('selected')) {
-                items.push({label: $(ele).html(), value: $(ele).attr('value')});
+                var label;
+                if ($(ele).data('html')) {
+                    label = $(ele).data('html');
+                } else {
+                    label = $(ele).html();
+                }
+                items.push({label: label, value: $(ele).attr('value')});
             }
         });
         var context = $.extend({items: items}, options, state);
index c21fce8..c5028b1 100644 (file)
@@ -85,6 +85,7 @@ class core_component {
         'IMSGlobal\LTI' => 'lib/ltiprovider/src',
         'Phpml' => 'lib/mlbackend/php/phpml/src/Phpml',
         'PHPMailer\\PHPMailer' => 'lib/phpmailer/src',
+        'RedeyeVentures\\GeoPattern' => 'lib/geopattern-php/GeoPattern',
     );
 
     /**
@@ -483,7 +484,7 @@ $cache = '.var_export($cache, true).';
             'tag'         => $CFG->dirroot.'/tag',
             'timezones'   => null,
             'user'        => $CFG->dirroot.'/user',
-            'userkey'     => null,
+            'userkey'     => $CFG->dirroot.'/lib/userkey',
             'webservice'  => $CFG->dirroot.'/webservice',
         );
 
diff --git a/lib/classes/geopattern.php b/lib/classes/geopattern.php
new file mode 100644 (file)
index 0000000..3dc15dc
--- /dev/null
@@ -0,0 +1,44 @@
+<?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/>.
+
+/**
+ * Geopatterns for images.
+ *
+ * @package    core
+ * @copyright  2018 Moodle
+ * @author     Bas Brands
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+class core_geopattern extends \RedeyeVentures\GeoPattern\GeoPattern {
+
+    /**
+     * Add variables.
+     *
+     * @param array $scss Associative array of variables and their values.
+     * @return void
+     */
+    public function patternbyid($uniqueid) {
+        $this->setString($uniqueid);
+    }
+
+    public function datauri() {
+        return $this->toDataURI();
+    }
+
+}
index 96cb932..5efb99a 100644 (file)
@@ -48,6 +48,11 @@ defined('MOODLE_INTERNAL') || die();
  */
 class core_text {
 
+    /**
+     * @var string[] Array of strings representing Unicode non-characters
+     */
+    protected static $noncharacters;
+
     /**
      * Return t3lib helper class, which is used for conversion between charsets
      *
@@ -628,6 +633,39 @@ class core_text {
         return $str;
     }
 
+    /**
+     * There are a number of Unicode non-characters including the byte-order mark (which may appear
+     * multiple times in a string) and also other ranges. These can cause problems for some
+     * processing.
+     *
+     * This function removes the characters using string replace, so that the rest of the string
+     * remains unchanged.
+     *
+     * @param string $value Input string
+     * @return string Cleaned string value
+     * @since Moodle 3.5
+     */
+    public static function remove_unicode_non_characters($value) {
+        // Set up list of all Unicode non-characters for fast replacing.
+        if (!self::$noncharacters) {
+            self::$noncharacters = [];
+            // This list of characters is based on the Unicode standard. It includes the last two
+            // characters of each code planes 0-16 inclusive...
+            for ($plane = 0; $plane <= 16; $plane++) {
+                $base = ($plane === 0 ? '' : dechex($plane));
+                self::$noncharacters[] = html_entity_decode('&#x' . $base . 'fffe;');
+                self::$noncharacters[] = html_entity_decode('&#x' . $base . 'ffff;');
+            }
+            // ...And the character range U+FDD0 to U+FDEF.
+            for ($char = 0xfdd0; $char <= 0xfdef; $char++) {
+                self::$noncharacters[] = html_entity_decode('&#x' . dechex($char) . ';');
+            }
+        }
+
+        // Do character replacement.
+        return str_replace(self::$noncharacters, '', $value);
+    }
+
     /**
      * Returns encoding options for select boxes, utf-8 and platform encoding first
      *
index 6925e02..ce986c9 100644 (file)
@@ -172,6 +172,235 @@ class core_user {
         return $DB->get_record('user', array('username' => $username, 'mnethostid' => $mnethostid), $fields, $strictness);
     }
 
+    /**
+     * Searches for users by name, possibly within a specified context, with current user's access.
+     *
+     * Deciding which users to search is complicated because it relies on user permissions;
+     * ideally, we shouldn't show names if you aren't allowed to see their profile. The permissions
+     * for seeing profile are really complicated.
+     *
+     * Even if search is restricted to a course, it's possible that other people might have
+     * been able to contribute within the course (e.g. they were enrolled before and not now;
+     * or people with system-level roles) so if the user has permission we do want to include
+     * everyone. However, if there are multiple results then we prioritise the ones who are
+     * enrolled in the course.
+     *
+     * If you have moodle/user:viewdetails at system level, you can search everyone.
+     * Otherwise we check which courses you *do* have that permission and search everyone who is
+     * enrolled on those courses.
+     *
+     * Normally you can only search the user's name. If you have the moodle/site:viewuseridentity
+     * capability then we also let you search the fields which are listed as identity fields in
+     * the 'showuseridentity' config option. For example, this might include the user's ID number
+     * or email.
+     *
+     * The $max parameter controls the maximum number of users returned. If users are restricted
+     * from view for some reason, multiple runs of the main query might be made; the $querylimit
+     * parameter allows this to be restricted. Both parameters can be zero to remove limits.
+     *
+     * The returned user objects include id, username, all fields required for user pictures, and
+     * user identity fields.
+     *
+     * @param string $query Search query text
+     * @param \context_course|null $coursecontext Course context or null if system-wide
+     * @param int $max Max number of users to return, default 30 (zero = no limit)
+     * @param int $querylimit Max number of database queries, default 5 (zero = no limit)
+     * @return array Array of user objects with limited fields
+     */
+    public static function search($query, \context_course $coursecontext = null,
+            $max = 30, $querylimit = 5) {
+        global $CFG, $DB;
+        require_once($CFG->dirroot . '/user/lib.php');
+
+        // Allow limits to be turned off.
+        if (!$max) {
+            $max = PHP_INT_MAX;
+        }
+        if (!$querylimit) {
+            $querylimit = PHP_INT_MAX;
+        }
+
+        // Check permission to view profiles at each context.
+        $systemcontext = \context_system::instance();
+        $viewsystem = has_capability('moodle/user:viewdetails', $systemcontext);
+        if ($viewsystem) {
+            $userquery = 'SELECT id FROM {user}';
+            $userparams = [];
+        }
+        if (!$viewsystem) {
+            list($userquery, $userparams) = self::get_enrolled_sql_on_courses_with_capability(
+                    'moodle/user:viewdetails');
+            if (!$userquery) {
+                // No permissions anywhere, return nothing.
+                return [];
+            }
+        }
+
+        // Start building the WHERE clause based on name.
+        list ($where, $whereparams) = users_search_sql($query, 'u', false);
+
+        // We allow users to search with extra identity fields (as well as name) but only if they
+        // have the permission to display those identity fields.
+        $extrasql = '';
+        $extraparams = [];
+
+        if (empty($CFG->showuseridentity)) {
+            // Explode gives wrong result with empty string.
+            $extra = [];
+        } else {
+            $extra = explode(',', $CFG->showuseridentity);
+        }
+
+        // We need the username just to skip guests.
+        $extrafieldlist = $extra;
+        if (!in_array('username', $extra)) {
+            $extrafieldlist[] = 'username';
+        }
+        // The deleted flag will always be false because users_search_sql excludes deleted users,
+        // but it must be present or it causes PHP warnings in some functions below.
+        if (!in_array('deleted', $extra)) {
+            $extrafieldlist[] = 'deleted';
+        }
+        $extrafields = \user_picture::fields('u',
+                array_merge(get_all_user_name_fields(), $extrafieldlist));
+
+        $index = 1;
+        foreach ($extra as $fieldname) {
+            if ($extrasql) {
+                $extrasql .= ' OR ';
+            }
+            $extrasql .= $DB->sql_like('u.' . $fieldname, ':extra' . $index, false);
+            $extraparams['extra' . $index] = $query . '%';
+            $index++;
+        }
+
+        $identitysystem = has_capability('moodle/site:viewuseridentity', $systemcontext);
+        $usingshowidentity = false;
+        if ($identitysystem) {
+            // They have permission everywhere so just add the extra query to the normal query.
+            $where .= ' OR ' . $extrasql;
+            $whereparams = array_merge($whereparams, $extraparams);
+        } else {
+            // Get all courses where user can view full user identity.
+            list($sql, $params) = self::get_enrolled_sql_on_courses_with_capability(
+                    'moodle/site:viewuseridentity');
+            if ($sql) {
+                // Join that with the user query to get an extra field indicating if we can.
+                $userquery = "
+                        SELECT innerusers.id, COUNT(identityusers.id) AS showidentity
+                          FROM ($userquery) innerusers
+                     LEFT JOIN ($sql) identityusers ON identityusers.id = innerusers.id
+                      GROUP BY innerusers.id";
+                $userparams = array_merge($userparams, $params);
+                $usingshowidentity = true;
+
+                // Query on the extra fields only in those places.
+                $where .= ' OR (users.showidentity > 0 AND (' . $extrasql . '))';
+                $whereparams = array_merge($whereparams, $extraparams);
+            }
+        }
+
+        // Default order is just name order. But if searching within a course then we show users
+        // within the course first.
+        list ($order, $orderparams) = users_order_by_sql('u', $query, $systemcontext);
+        if ($coursecontext) {
+            list ($sql, $params) = get_enrolled_sql($coursecontext);
+            $mainfield = 'innerusers2.id';
+            if ($usingshowidentity) {
+                $mainfield .= ', innerusers2.showidentity';
+            }
+            $userquery = "
+                    SELECT $mainfield, COUNT(courseusers.id) AS incourse
+                      FROM ($userquery) innerusers2
+                 LEFT JOIN ($sql) courseusers ON courseusers.id = innerusers2.id
+                  GROUP BY $mainfield";
+            $userparams = array_merge($userparams, $params);
+
+            $order = 'incourse DESC, ' . $order;
+        }
+
+        // Get result (first 30 rows only) from database. Take a couple spare in case we have to
+        // drop some.
+        $result = [];
+        $got = 0;
+        $pos = 0;
+        $readcount = $max + 2;
+        for ($i = 0; $i < $querylimit; $i++) {
+            $rawresult = $DB->get_records_sql("
+                    SELECT users.*, $extrafields
+                      FROM ($userquery) users
+                      JOIN {user} u ON u.id = users.id
+                     WHERE $where
+                  ORDER BY $order", array_merge($userparams, $whereparams, $orderparams),
+                    $pos, $readcount);
+            foreach ($rawresult as $user) {
+                // Skip guest.
+                if ($user->username === 'guest') {
+                    continue;
+                }
+                // Check user can really view profile (there are per-user cases where this could
+                // be different for some reason, this is the same check used by the profile view pages
+                // to double-check that it is OK).
+                if (!user_can_view_profile($user)) {
+                    continue;
+                }
+                $result[] = $user;
+                $got++;
+                if ($got >= $max) {
+                    break;
+                }
+            }
+
+            if ($got >= $max) {
+                // All necessary results obtained.
+                break;
+            }
+            if (count($rawresult) < $readcount) {
+                // No more results from database.
+                break;
+            }
+            $pos += $readcount;
+        }
+
+        return $result;
+    }
+
+    /**
+     * Gets an SQL query that lists all enrolled user ids on any course where the current
+     * user has the specified capability. Helper function used for searching users.
+     *
+     * @param string $capability Required capability
+     * @return array Array containing SQL and params, or two nulls if there are no courses
+     */
+    protected static function get_enrolled_sql_on_courses_with_capability($capability) {
+        // Get all courses where user have the capability.
+        $courses = get_user_capability_course($capability, null, true,
+                'ctxid, ctxpath, ctxdepth, ctxlevel, ctxinstance');
+        if (!$courses) {
+            return [null, null];
+        }
+
+        // Loop around all courses getting the SQL for enrolled users. Note: This query could
+        // probably be more efficient (without the union) if get_enrolled_sql had a way to
+        // pass an array of courseids, but it doesn't.
+        $unionsql = '';
+        $unionparams = [];
+        foreach ($courses as $course) {
+            // Get SQL to list user ids enrolled in this course.
+            \context_helper::preload_from_record($course);
+            list ($sql, $params) = get_enrolled_sql(\context_course::instance($course->id));
+
+            // Combine to a big union query.
+            if ($unionsql) {
+                $unionsql .= ' UNION ';
+            }
+            $unionsql .= $sql;
+            $unionparams = array_merge($unionparams, $params);
+        }
+
+        return [$unionsql, $unionparams];
+    }
+
     /**
      * Helper function to return dummy noreply user record.
      *
index 3fbf53d..9761089 100644 (file)
@@ -1185,6 +1185,13 @@ $functions = array(
         'type' => 'write',
         'capabilities' => 'moodle/role:assign'
     ),
+    'core_search_get_relevant_users' => array(
+        'classname' => '\core_search\external',
+        'methodname' => 'get_relevant_users',
+        'description' => 'Gets relevant users for a search request.',
+        'type' => 'read',
+        'ajax' => true
+    ),
     'core_tag_get_tagindex' => array(
         'classname' => 'core_tag_external',
         'methodname' => 'get_tagindex',
index 0e501ab..2be3c40 100644 (file)
@@ -52,6 +52,8 @@ class MoodleQuickForm_autocomplete extends MoodleQuickForm_select {
     protected $showsuggestions = true;
     /** @var string $noselectionstring String that is shown when there are no selections. */
     protected $noselectionstring = '';
+    /** @var callable|null Function to call (with existing value) to render it to HTML */
+    protected $valuehtmlcallback = null;
 
     /**
      * constructor
@@ -95,6 +97,10 @@ class MoodleQuickForm_autocomplete extends MoodleQuickForm_select {
             $this->casesensitive = $attributes['casesensitive'] ? true : false;
             unset($attributes['casesensitive']);
         }
+        if (isset($attributes['valuehtmlcallback'])) {
+            $this->valuehtmlcallback = $attributes['valuehtmlcallback'];
+            unset($attributes['valuehtmlcallback']);
+        }
         parent::__construct($elementName, $elementLabel, $options, $attributes);
 
         $this->_type = 'autocomplete';
@@ -127,7 +133,23 @@ class MoodleQuickForm_autocomplete extends MoodleQuickForm_select {
                 $this->placeholder, $this->casesensitive, $this->showsuggestions, $this->noselectionstring));
         }
 
-        return parent::toHTML();
+        $html = parent::toHTML();
+
+        // Hacky bodge to add in the HTML code to the option tag. There is a nicer
+        // version of this code in the new template version (see export_for_template).
+        if ($this->valuehtmlcallback) {
+            $html = preg_replace_callback('~value="([^"]+)"~', function($matches) {
+                $value = html_entity_decode($matches[1]);
+                $htmlvalue = call_user_func($this->valuehtmlcallback, $value);
+                if ($htmlvalue !== false) {
+                    return $matches[0] . ' data-html="' . s($htmlvalue) . '"';
+                } else {
+                    return $matches[0];
+                }
+            }, $html);
+        }
+
+        return $html;
     }
 
     /**
@@ -209,8 +231,6 @@ class MoodleQuickForm_autocomplete extends MoodleQuickForm_select {
     }
 
     public function export_for_template(renderer_base $output) {
-        global $PAGE;
-
         $this->_generateId();
         $context = parent::export_for_template($output);
         $context['tags'] = !empty($this->tags);
@@ -219,6 +239,15 @@ class MoodleQuickForm_autocomplete extends MoodleQuickForm_select {
         $context['casesensitive'] = !empty($this->casesensitive);
         $context['showsuggestions'] = !empty($this->showsuggestions);
         $context['noselectionstring'] = $this->noselectionstring;
+        if ($this->valuehtmlcallback) {
+            foreach ($context['options'] as &$option) {
+                $value = $option['value'];
+                $html = call_user_func($this->valuehtmlcallback, $value);
+                if ($html !== false) {
+                    $option['html'] = $html;
+                }
+            }
+        }
 
         return $context;
     }
diff --git a/lib/geopattern-php/GeoPattern/GeoPattern.php b/lib/geopattern-php/GeoPattern/GeoPattern.php
new file mode 100644 (file)
index 0000000..12e6225
--- /dev/null
@@ -0,0 +1,1327 @@
+<?php namespace RedeyeVentures\GeoPattern;
+
+use RedeyeVentures\GeoPattern\SVGElements\Polyline;
+use RedeyeVentures\GeoPattern\SVGElements\Rectangle;
+use RedeyeVentures\GeoPattern\SVGElements\Group;
+
+class GeoPattern {
+
+    protected $string;
+    protected $baseColor;
+    protected $color;
+    protected $generator;
+
+    protected $hash;
+    protected $svg;
+
+    protected $patterns = [
+        'octogons',
+        'overlapping_circles',
+        'plus_signs',
+        'xes',
+        'sine_waves',
+        'hexagons',
+        'overlapping_rings',
+        'plaid',
+        'triangles',
+        'squares',
+        'concentric_circles',
+        'diamonds',
+        'tessellation',
+        'nested_squares',
+        'mosaic_squares',
+        'triangles_rotated',
+        'chevrons',
+    ];
+    const FILL_COLOR_DARK = '#222';
+    const FILL_COLOR_LIGHT = '#ddd';
+    const STROKE_COLOR = '#000';
+    const STROKE_OPACITY = '0.02';
+    const OPACITY_MIN = '0.02';
+    const OPACITY_MAX = '0.15';
+
+    function __construct($options=array())
+    {
+        // Set string if provided. If not, set default.
+        if (isset($options['string'])) {
+            $this->setString($options['string']);
+        } else {
+            $this->setString(time());
+        }
+
+        // Set base color if provided. If not, set default.
+        if (isset($options['baseColor'])) {
+            $this->setBaseColor($options['baseColor']);
+        } else {
+            $this->setBaseColor('#933c3c');
+        }
+
+        // Set color if provided.
+        if (isset($options['color'])) {
+            $this->setColor($options['color']);
+        }
+
+        // Set generator if provided. If not, leave null.
+        if (isset($options['generator']))
+            $this->setGenerator($options['generator']);
+
+        $this->svg = new SVG();
+    }
+
+    // Fluent Interfaces
+    public function setString($string)
+    {
+        $this->string = $string;
+        $this->hash = sha1($this->string);
+        return $this;
+    }
+
+    /**
+     * @return string
+     */
+    public function getString()
+    {
+        return $this->string;
+    }
+
+    public function setBaseColor($baseColor)
+    {
+        if(preg_match('/^#[a-f0-9]{6}$/i', $baseColor)) //hex color is valid
+        {
+            $this->baseColor = $baseColor;
+            return $this;
+        }
+        throw new \InvalidArgumentException("$baseColor is not a valid hex color.");
+    }
+
+    public function setColor($color)
+    {
+        if(preg_match('/^#[a-f0-9]{6}$/i', $color)) //hex color is valid
+        {
+            $this->color = $color;
+            return $this;
+        }
+        throw new \InvalidArgumentException("$color is not a valid hex color.");
+    }
+
+    public function setGenerator($generator)
+    {
+        $generator = strtolower($generator);
+        if (in_array($generator, $this->patterns) || is_null($generator)) {
+            $this->generator = $generator;
+            return $this;
+        }
+        throw new \InvalidArgumentException("$generator is not a valid generator type.");
+    }
+
+    public function toSVG()
+    {
+        $this->svg = new SVG();
+        $this->generateBackground();
+        $this->generatePattern();
+        return (string) $this->svg;
+    }
+
+    public function toBase64()
+    {
+        return base64_encode($this->toSVG());
+    }
+
+    public function toDataURI()
+    {
+        return "data:image/svg+xml;base64,{$this->toBase64()}";
+    }
+
+    public function toDataURL()
+    {
+        return "url(\"{$this->toDataURI()}\")";
+    }
+
+    public function __toString() {
+        return $this->toSVG();
+    }
+
+    // Generators
+    protected function generateBackground()
+    {
+        $hueOffset = $this->map($this->hexVal(14, 3), 0, 4095, 0, 359);
+        $satOffset = $this->hexVal(17, 1);
+        $baseColor = $this->hexToHSL($this->baseColor);
+        $color     = $this->color;
+
+        $baseColor['h'] = $baseColor['h'] - $hueOffset;
+
+
+        if ($satOffset % 2 == 0)
+            $baseColor['s'] = $baseColor['s'] + $satOffset/100;
+        else
+            $baseColor['s'] = $baseColor['s'] - $satOffset/100;
+
+        if (isset($color))
+            $rgb = $this->hexToRGB($color);
+        else
+            $rgb = $this->hslToRGB($baseColor['h'], $baseColor['s'], $baseColor['l']);
+
+        $this->svg->addRectangle(0, 0, "100%", "100%", ['fill' => "rgb({$rgb['r']}, {$rgb['g']}, {$rgb['b']})"]);
+    }
+
+    protected function generatePattern()
+    {
+        if (is_null($this->generator))
+            $pattern = $this->patterns[$this->hexVal(20, 1)];
+        else
+            $pattern = $this->generator;
+
+        $function = 'geo'.str_replace(' ', '', ucwords(str_replace('_', ' ', $pattern)));
+
+        if (method_exists($this, $function))
+            $this->$function();
+    }
+
+    // Pattern Makers
+    protected function geoHexagons()
+    {
+        $scale = $this->hexVal(0, 1);
+        $sideLength = $this->map($scale, 0, 15, 8, 60);
+        $hexHeight = $sideLength * sqrt(3);
+        $hexWidth = $sideLength * 2;
+        $hex = $this->buildHexagonShape($sideLength);
+        $this->svg->setWidth(($hexWidth * 3) + ($sideLength * 3))
+            ->setHeight($hexHeight * 6);
+
+        $i = 0;
+        for ($y = 0; $y <= 5; $y++) {
+            for ($x = 0; $x <= 5; $x++) {
+                $val = $this->hexVal($i, 1);
+                $dy = ($x % 2 == 0) ? ($y * $hexHeight) : ($y*$hexHeight + $hexHeight / 2);
+                $opacity = $this->opacity($val);
+                $fill = $this->fillColor($val);
+                $styles = [
+                    'stroke' => self::STROKE_COLOR,
+                    'stroke-opacity' => self::STROKE_OPACITY,
+                    'fill-opacity' => $opacity,
+                    'fill' => $fill,
+                ];
+
+                $onePointFiveXSideLengthMinusHalfHexWidth = $x * $sideLength * 1.5 - $hexWidth / 2;
+                $dyMinusHalfHexHeight = $dy - $hexHeight / 2;
+                $this->svg->addPolyline($hex, array_merge($styles, ['transform' => "translate($onePointFiveXSideLengthMinusHalfHexWidth, $dyMinusHalfHexHeight)"]));
+
+                // Add an extra one at top-right, for tiling.
+                if ($x == 0) {
+                    $onePointFiveSideLengthSixMinusHalfHexWidth = 6 * $sideLength * 1.5 - $hexWidth / 2;
+                    $this->svg->addPolyline($hex, array_merge($styles, ['transform' => "translate($onePointFiveSideLengthSixMinusHalfHexWidth, $dyMinusHalfHexHeight)"]));
+                }
+
+                // Add an extra row at the end that matches the first row, for tiling.
+                if ($y == 0) {
+                    $dy2 = ($x % 2 == 0) ? (6 * $hexHeight) : (6 * $hexHeight + $hexHeight / 2);
+                    $dy2MinusHalfHexHeight = $dy2 - $hexHeight / 2;
+                    $this->svg->addPolyline($hex, array_merge($styles, ['transform' => "translate($onePointFiveXSideLengthMinusHalfHexWidth, $dy2MinusHalfHexHeight)"]));
+                }
+
+                // Add an extra one at bottom-right, for tiling.
+                if ($x == 0 && $y == 0) {
+                    $onePointFiveSideLengthSixMinusHalfHexWidth = 6 * $sideLength * 1.5 - $hexWidth / 2;
+                    $fiveHexHeightPlusHalfHexHeight = 5 * $hexHeight + $hexHeight / 2;
+                    $this->svg->addPolyline($hex, array_merge($styles, ['transform' => "translate($onePointFiveSideLengthSixMinusHalfHexWidth, $fiveHexHeightPlusHalfHexHeight)"]));
+                }
+
+                $i++;
+            }
+        }
+    }
+
+    protected function geoSineWaves()
+    {
+        $period = floor($this->map($this->hexVal(0, 1), 0, 15, 100, 400));
+        $quarterPeriod = $period / 4;
+        $xOffset = $period / 4 * 0.7;
+        $amplitude = floor($this->map($this->hexVal(1, 1), 0, 15, 30, 100));
+        $waveWidth = floor($this->map($this->hexVal(2, 1), 0, 15, 3, 30));
+        $amplitudeString = number_format($amplitude);
+        $halfPeriod = number_format($period / 2);
+        $halfPeriodMinusXOffset = number_format($period / 2 - $xOffset);
+        $periodMinusXOffset = number_format($period - $xOffset);
+        $twoAmplitude = number_format(2 * $amplitude);
+        $onePointFivePeriodMinusXOffset = number_format($period * 1.5 - $xOffset);
+        $onePointFivePeriod = number_format($period * 1.5);
+        $str = "M0 $amplitudeString C $xOffset 0, $halfPeriodMinusXOffset 0, $halfPeriod $amplitudeString S $periodMinusXOffset $twoAmplitude, $period $amplitudeString S $onePointFivePeriodMinusXOffset 0, $onePointFivePeriod, $amplitudeString";
+
+        $this->svg->setWidth($period)
+            ->setHeight($waveWidth*36);
+        for ($i = 0; $i <= 35; $i++) {
+            $val = $this->hexVal($i, 1);
+            $opacity = $this->opacity($val);
+            $fill = $this->fillColor($val);
+            $styles = [
+                'fill' => 'none',
+                'stroke' => $fill,
+                'style' => [
+                    'opacity' => $opacity,
+                    'stroke-width' => "{$waveWidth}px"
+                ]
+            ];
+
+            $iWaveWidthMinusOnePointFiveAmplitude = $waveWidth * $i - $amplitude * 1.5;
+            $iWaveWidthMinusOnePointFiveAmplitudePlusThirtySixWaveWidth = $waveWidth * $i - $amplitude * 1.5 + $waveWidth * 36;
+            $this->svg->addPath($str, array_merge($styles, ['transform' => "translate(-$quarterPeriod, $iWaveWidthMinusOnePointFiveAmplitude)"]));
+            $this->svg->addPath($str, array_merge($styles, ['transform' => "translate(-$quarterPeriod, $iWaveWidthMinusOnePointFiveAmplitudePlusThirtySixWaveWidth)"]));
+
+        }
+    }
+
+    protected function geoChevrons()
+    {
+        $chevronWidth = $this->map($this->hexVal(0, 1), 0, 15, 30, 80);
+        $chevronHeight = $this->map($this->hexVal(0, 1), 0, 15, 30, 80);
+        $chevron = $this->buildChevronShape($chevronWidth, $chevronHeight);
+
+        $this->svg->setWidth($chevronWidth*6)
+            ->setHeight($chevronHeight*6*0.66);
+
+        $i = 0;
+        for ($y = 0; $y <= 5; $y++) {
+            for ($x = 0; $x <= 5; $x++) {
+                $val = $this->hexVal($i, 1);
+                $opacity = $this->opacity($val);
+                $fill = $this->fillColor($val);
+                $styles = [
+                    'stroke' => self::STROKE_COLOR,
+                    'stroke-opacity' => self::STROKE_OPACITY,
+                    'stroke-width' => '1',
+                    'fill-opacity' => $opacity,
+                    'fill' => $fill,
+                ];
+
+                $group = new Group();
+                $group->addItem($chevron[0])
+                    ->addItem($chevron[1]);
+
+                $xChevronWidth = $x * $chevronWidth;
+                $yPointSixSixChevronHeightMinusHalfChevronHeight = $y * $chevronHeight * 0.66 - $chevronHeight / 2;
+                $this->svg->addGroup($group, array_merge($styles, ['transform' => "translate($xChevronWidth,$yPointSixSixChevronHeightMinusHalfChevronHeight)"]));
+                // Add an extra row at the end that matches the first row, for tiling.
+                if ($y == 0) {
+                    $sixPointSixSixChevronHeightMinusHalfChevronHeight = 6 * $chevronHeight * 0.66 - $chevronHeight / 2;
+                    $this->svg->addGroup($group, array_merge($styles, ['transform' => "translate($xChevronWidth,$sixPointSixSixChevronHeightMinusHalfChevronHeight)"]));
+                }
+
+                $i++;
+            }
+        }
+
+    }
+
+    protected function geoPlusSigns()
+    {
+        $squareSize = $this->map($this->hexVal(0, 1), 0, 15, 10, 25);
+        $plusSize = $squareSize * 3;
+        $plusShape = $this->buildPlusShape($squareSize);
+
+        $this->svg->setWidth($squareSize*12)
+            ->setHeight($squareSize*12);
+
+        $i = 0;
+        for ($y = 0; $y <= 5; $y++) {
+            for ($x = 0; $x <= 5; $x++) {
+                $val = $this->hexVal($i, 1);
+                $opacity = $this->opacity($val);
+                $fill = $this->fillColor($val);
+                $dx = ($y % 2 == 0) ? 0 : 1;
+
+                $styles = [
+                    'fill' => $fill,
+                    'stroke' => self::STROKE_COLOR,
+                    'stroke-opacity' => self::STROKE_OPACITY,
+                    'style' => [
+                        'fill-opacity' => $opacity,
+                    ],
+                ];
+
+                $group = new Group();
+                $group->addItem($plusShape[0])
+                    ->addItem($plusShape[1]);
+
+                $t1 = $x * $plusSize - $x * $squareSize + $dx * $squareSize - $squareSize;
+                $t2 = $y * $plusSize - $y * $squareSize - $plusSize / 2;
+
+                $this->svg->addGroup($group, array_merge($styles, ['transform' => "translate($t1, $t2)"]));
+
+                // Add an extra column on the right for tiling.
+                if ($x == 0) {
+                    $xT1 = 4 * $plusSize - $x * $squareSize + $dx * $squareSize - $squareSize;
+                    $xT2 = $y * $plusSize - $y * $squareSize - $plusSize / 2;
+                    $this->svg->addGroup($group, array_merge($styles, ['transform' => "translate($xT1, $xT2)"]));
+                }
+
+                // Add an extra row on the bottom that matches the first row, for tiling.
+                if ($y == 0) {
+                    $yT1 = $x * $plusSize - $x * $squareSize + $dx * $squareSize - $squareSize;
+                    $yT2 = 4 * $plusSize - $y * $squareSize - $plusSize /2;
+                    $this->svg->addGroup($group, array_merge($styles, ['transform' => "translate($yT1, $yT2)"]));
+                }
+
+                // Add an extra one at top-right and bottom-right, for tiling.
+                if ($x == 0 && $y == 0) {
+                    $xyT1 = 4 * $plusSize - $x * $squareSize + $dx * $squareSize - $squareSize;
+                    $xyT2 = 4 * $plusSize - $y * $squareSize - $plusSize / 2;
+                    $this->svg->addGroup($group, array_merge($styles, ['transform' => "translate($xyT1, $xyT2)"]));
+                }
+
+                $i++;
+            }
+        }
+    }
+
+    protected function geoXes()
+    {
+        $squareSize = $this->map($this->hexVal(0, 1), 0, 15, 10, 25);
+        $xSize = $squareSize * 3 * 0.943;
+        $xShape = $this->buildPlusShape($squareSize);
+
+        $this->svg->setWidth($xSize*3)
+            ->setHeight($xSize*3);
+
+        $i = 0;
+        for ($y = 0; $y <= 5; $y++) {
+            for ($x = 0; $x <= 5; $x++) {
+                $val = $this->hexVal($i, 1);
+                $opacity = $this->opacity($val);
+                $fill = $this->fillColor($val);
+                $dy = ($x % 2 == 0) ? ($y * $xSize - $xSize * 0.5) : ($y * $xSize - $xSize * 0.5 + $xSize / 4);
+
+                $styles = [
+                    'fill' => $fill,
+                    'style' => [
+                        'opacity' => $opacity,
+                    ],
+                ];
+
+                $group = new Group();
+                $group->addItem($xShape[0])
+                    ->addItem($xShape[1]);
+
+                $t1 = $x * $xSize / 2 - $xSize / 2;
+                $t2 = $dy - $y * $xSize / 2;
+                $halfXSize = $xSize / 2;
+                $this->svg->addGroup($group, array_merge($styles, ['transform' => "translate($t1, $t2) rotate(45, $halfXSize, $halfXSize)"]));
+
+                // Add an extra column on the right for tiling.
+                if ($x == 0) {
+                    $xT1 = 6 * $xSize / 2 - $xSize / 2;
+                    $xT2 = $dy - $y * $xSize / 2;
+                    $this->svg->addGroup($group, array_merge($styles, ['transform' => "translate($xT1, $xT2) rotate(45, $halfXSize, $halfXSize)"]));
+                }
+
+                // Add an extra row on the bottom that matches the first row, for tiling.
+                if ($y == 0) {
+                    $dy = ($x % 2 == 0) ? (6 * $xSize - $xSize / 2) : (6 * $xSize - $xSize / 2 + $xSize / 4);
+                    $yT1 = $x * $xSize / 2 - $xSize / 2;
+                    $yT2 = $dy - 6 * $xSize / 2;
+                    $this->svg->addGroup($group, array_merge($styles, ['transform' => "translate($yT1, $yT2) rotate(45, $halfXSize, $halfXSize)"]));
+                }
+
+                // These can hang off the bottom, so put a row at the top for tiling.
+                if ($y == 5) {
+                    $y2T1 = $x * $xSize / 2 - $xSize / 2;
+                    $y2T2 = $dy - 11 * $xSize / 2;
+                    $this->svg->addGroup($group, array_merge($styles, ['transform' => "translate($y2T1, $y2T2) rotate(45, $halfXSize, $halfXSize)"]));
+                }
+
+                // Add an extra one at top-right and bottom-right, for tiling.
+                if ($x == 0 && $y == 0) {
+                    $xyT1 = 6 * $xSize / 2 - $xSize / 2;
+                    $xyT2 = $dy - 6 * $xSize / 2;
+                    $this->svg->addGroup($group, array_merge($styles, ['transform' => "translate($xyT1, $xyT2) rotate(45, $halfXSize, $halfXSize"]));
+                }
+
+                $i++;
+            }
+        }
+    }
+
+    protected function geoOverlappingCircles()
+    {
+        $scale = $this->hexVal(0, 1);
+        $diameter = $this->map($scale, 0, 15, 25, 200);
+        $radius = $diameter/2;
+
+        $this->svg->setWidth($radius*6)
+            ->setHeight($radius*6);
+
+        $i = 0;
+        for ($y = 0; $y <= 5; $y++) {
+            for ($x = 0; $x <= 5; $x++) {
+                $val = $this->hexVal($i, 1);
+                $opacity = $this->opacity($val);
+                $fill = $this->fillColor($val);
+                $styles = [
+                    'fill' => $fill,
+                    'style' => [
+                        'opacity' => $opacity,
+                    ],
+                ];
+
+                $this->svg->addCircle($x*$radius, $y*$radius, $radius, $styles);
+
+                // Add an extra one at top-right, for tiling.
+                if ($x == 0)
+                    $this->svg->addCircle(6*$radius, $y*$radius, $radius, $styles);
+
+                // Add an extra row at the end that matches the first row, for tiling.
+                if ($y == 0)
+                    $this->svg->addCircle($x*$radius, 6*$radius, $radius, $styles);
+
+                // Add an extra one at bottom-right, for tiling.
+                if ($x == 0 && $y == 0)
+                    $this->svg->addCircle(6*$radius, 6*$radius, $radius, $styles);
+
+                $i++;
+            }
+        }
+    }
+
+    protected function geoOctogons()
+    {
+        $squareSize = $this->map($this->hexVal(0, 1), 0, 15, 10, 60);
+        $tile = $this->buildOctogonShape($squareSize);
+
+        $this->svg->setWidth($squareSize*6)
+            ->setHeight($squareSize*6);
+
+        $i = 0;
+        for ($y = 0; $y <= 5; $y++) {
+            for ($x = 0; $x <= 5; $x++) {
+                $val = $this->hexVal($i, 1);
+                $opacity = $this->opacity($val);
+                $fill = $this->fillColor($val);
+
+                $xSquareSize = $x * $squareSize;
+                $ySquareSize = $y * $squareSize;
+
+                $this->svg->addPolyline($tile, [
+                    'fill' => $fill,
+                    'fill-opacity' => $opacity,
+                    'stroke' => self::STROKE_COLOR,
+                    'stroke-opacity' => self::STROKE_OPACITY,
+                    'transform' => "translate($xSquareSize, $ySquareSize)",
+                ]);
+
+                $i++;
+            }
+        }
+
+    }
+
+    protected function geoSquares()
+    {
+        $squareSize = $this->map($this->hexVal(0, 1), 0, 15, 10, 60);
+
+        $this->svg->setWidth($squareSize*6)
+            ->setHeight($squareSize*6);
+
+        $i = 0;
+        for ($y = 0; $y <= 5; $y++) {
+            for ($x = 0; $x <= 5; $x++) {
+                $val = $this->hexVal($i, 1);
+                $opacity = $this->opacity($val);
+                $fill = $this->fillColor($val);
+
+                $this->svg->addRectangle($x*$squareSize, $y*$squareSize, $squareSize, $squareSize, [
+                    'fill' => $fill,
+                    'fill-opacity' => $opacity,
+                    'stroke' => self::STROKE_COLOR,
+                    'stroke-opacity' => self::STROKE_OPACITY,
+                ]);
+
+                $i++;
+            }
+        }
+
+    }
+
+    protected function geoConcentricCircles()
+    {
+        $scale = $this->hexVal(0, 1);
+        $ringSize = $this->map($scale, 0, 15, 10, 60);
+        $strokeWidth = $ringSize / 5;
+
+        $this->svg->setWidth(($ringSize + $strokeWidth)*6)
+            ->setHeight(($ringSize + $strokeWidth)*6);
+
+        $i = 0;
+        for ($y = 0; $y <= 5; $y++) {
+            for ($x = 0; $x <= 5; $x++) {
+                $val = $this->hexVal($i, 1);
+                $opacity = $this->opacity($val);
+                $fill = $this->fillColor($val);
+
+                $cx = $x * $ringSize + $x * $strokeWidth + ($ringSize + $strokeWidth) / 2;
+                $cy = $y * $ringSize + $y * $strokeWidth + ($ringSize + $strokeWidth) / 2;
+                $halfRingSize = $ringSize / 2;
+
+                $this->svg->addCircle($cx, $cy, $halfRingSize, [
+                    'fill' => 'none',
+                    'stroke' => $fill,
+                    'style' => [
+                        'opacity' => $opacity,
+                        'stroke-width' => "{$strokeWidth}px",
+                    ],
+                ]);
+
+                $val = $this->hexVal(39-$i, 1);
+                $opacity = $this->opacity($val);
+                $fill = $this->fillColor($val);
+
+                $quarterRingSize = $ringSize / 4;
+
+                $this->svg->addCircle($cx, $cy, $quarterRingSize, [
+                    'fill' => $fill,
+                    'fill-opacity' => $opacity,
+                ]);
+
+                $i++;
+            }
+        }
+    }
+
+    protected function geoOverlappingRings()
+    {
+        $scale = $this->hexVal(0, 1);
+        $ringSize = $this->map($scale, 0, 15, 10, 60);
+        $strokeWidth = $ringSize / 4;
+
+        $this->svg->setWidth($ringSize*6)
+            ->setHeight($ringSize*6);
+
+        $i = 0;
+        for ($y = 0; $y <= 5; $y++) {
+            for ($x = 0; $x <= 5; $x++) {
+                $val = $this->hexVal($i, 1);
+                $opacity = $this->opacity($val);
+                $fill = $this->fillColor($val);
+
+                $styles = [
+                    'fill' => 'none',
+                    'stroke' => $fill,
+                    'style' => [
+                        'opacity' => $opacity,
+                        'stroke-width' => "{$strokeWidth}px",
+                    ],
+                ];
+
+                $ringSizeMinusHalfStrokeWidth = $ringSize - $strokeWidth / 2;
+
+                $this->svg->addCircle($x*$ringSize, $y*$ringSize, $ringSizeMinusHalfStrokeWidth, $styles);
+
+                // Add an extra one at top-right, for tiling.
+                if ($x == 0)
+                    $this->svg->addCircle(6*$ringSize, $y*$ringSize, $ringSizeMinusHalfStrokeWidth, $styles);
+
+                // Add an extra row at the end that matches the first row, for tiling.
+                if ($y == 0)
+                    $this->svg->addCircle($x*$ringSize, 6*$ringSize, $ringSizeMinusHalfStrokeWidth, $styles);
+
+                // Add an extra one at bottom-right, for tiling.
+                if ($x == 0 && $y == 0)
+                    $this->svg->addCircle(6*$ringSize, 6*$ringSize, $ringSizeMinusHalfStrokeWidth, $styles);
+
+                $i++;
+            }
+        }
+    }
+
+    protected function geoTriangles()
+    {
+        $scale = $this->hexVal(0, 1);
+        $sideLength = $this->map($scale, 0 ,15, 15, 80);
+        $triangleHeight = $sideLength / 2 * sqrt(3);
+        $triangle = $this->buildTriangleShape($sideLength, $triangleHeight);
+
+        $this->svg->setWidth($sideLength * 3)
+            ->setHeight($triangleHeight * 6);
+
+        $i = 0;
+        for ($y = 0; $y <= 5; $y++) {
+            for ($x = 0; $x <= 5; $x++) {
+                $val = $this->hexVal($i, 1);
+                $opacity = $this->opacity($val);
+                $fill = $this->fillColor($val);
+
+                $styles = [
+                    'fill' => $fill,
+                    'fill-opacity' => $opacity,
+                    'stroke' => self::STROKE_COLOR,
+                    'stroke-opacity' => self::STROKE_OPACITY,
+                ];
+
+                $rotation = '';
+                if ($y % 2 == 0)
+                    $rotation = ($x % 2 == 0) ? 180 : 0;
+                else
+                    $rotation = ($x % 2 != 0) ? 180 : 0;
+
+                $halfSideLength = $sideLength / 2;
+                $halfTriangleHeight = $triangleHeight / 2;
+                $yTriangleHeight = $triangleHeight * $y;
+
+                $t1 = $x * $sideLength * 0.5 - $sideLength / 2;
+                $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($t1, $yTriangleHeight) rotate($rotation, $halfSideLength, $halfTriangleHeight)"]));
+
+                // Add an extra one at top-right, for tiling.
+                if ($x == 0)
+                {
+                    $xT1 = 6 * $sideLength * 0.5 - $sideLength / 2;
+                    $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($xT1, $yTriangleHeight) rotate($rotation, $halfSideLength, $halfTriangleHeight)"]));
+                }
+
+                $i++;
+            }
+        }
+
+    }
+
+    protected function geoTrianglesRotated()
+    {
+        $scale = $this->hexVal(0, 1);
+        $sideLength = $this->map($scale, 0 ,15, 15, 80);
+        $triangleWidth = $sideLength / 2 * sqrt(3);
+        $triangle = $this->buildRotatedTriangleShape($sideLength, $triangleWidth);
+
+        $this->svg->setWidth($triangleWidth * 6)
+            ->setHeight($sideLength * 3);
+
+        $i = 0;
+        for ($y = 0; $y <= 5; $y++) {
+            for ($x = 0; $x <= 5; $x++) {
+                $val = $this->hexVal($i, 1);
+                $opacity = $this->opacity($val);
+                $fill = $this->fillColor($val);
+
+                $styles = [
+                    'fill' => $fill,
+                    'fill-opacity' => $opacity,
+                    'stroke' => self::STROKE_COLOR,
+                    'stroke-opacity' => self::STROKE_OPACITY,
+                ];
+
+                $rotation = '';
+                if ($y % 2 == 0)
+                    $rotation = ($x % 2 == 0) ? 180 : 0;
+                else
+                    $rotation = ($x % 2 != 0) ? 180 : 0;
+
+                $halfSideLength = $sideLength / 2;
+                $halfTriangleWidth = $triangleWidth / 2;
+                $xTriangleWidth = $x * $triangleWidth;
+
+                $t1 = $y * $sideLength * 0.5 - $sideLength / 2;
+                $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($xTriangleWidth, $t1) rotate($rotation, $halfTriangleWidth, $halfSideLength)"]));
+
+                // Add an extra one at top-right, for tiling.
+                if ($y == 0)
+                {
+                    $yT1 = 6 * $sideLength * 0.5 - $sideLength / 2;
+                    $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($xTriangleWidth, $yT1) rotate($rotation, $halfTriangleWidth, $halfSideLength)"]));
+                }
+
+                $i++;
+            }
+        }
+
+    }
+
+    protected function geoDiamonds()
+    {
+        $diamondWidth = $this->map($this->hexVal(0, 1), 0, 15, 10, 50);
+        $diamondHeight = $this->map($this->hexVal(1, 1), 0, 15, 10, 50);
+        $diamond = $this->buildDiamondShape($diamondWidth, $diamondHeight);
+
+        $this->svg->setWidth($diamondWidth*6)
+            ->setHeight($diamondHeight*3);
+
+        $i = 0;
+        for ($y = 0; $y <= 5; $y++) {
+            for ($x = 0; $x <= 5; $x++) {
+                $val = $this->hexVal($i, 1);
+                $opacity = $this->opacity($val);
+                $fill = $this->fillColor($val);
+
+                $styles = [
+                    'fill' => $fill,
+                    'fill-opacity' => $opacity,
+                    'stroke' => self::STROKE_COLOR,
+                    'stroke-opacity' => self::STROKE_OPACITY,
+                ];
+
+                $dx = ($y % 2 == 0) ? 0 : ($diamondWidth / 2);
+
+                $t1 = $x * $diamondWidth - $diamondWidth / 2 + $dx;
+                $t2 = $diamondHeight / 2 * $y - $diamondHeight / 2;
+                $this->svg->addPolyline($diamond, array_merge($styles, ['transform' => "translate($t1, $t2)"]));
+
+                // Add an extra one at top-right, for tiling.
+                if ($x == 0)
+                {
+                    $xT1 = 6 * $diamondWidth - $diamondWidth / 2 + $dx;
+                    $xT2 = $diamondHeight / 2 * $y - $diamondHeight / 2;
+                    $this->svg->addPolyline($diamond, array_merge($styles, ['transform' => "translate($xT1, $xT2)"]));
+                }
+
+                // Add an extra row at the end that matches the first row, for tiling.
+                if ($y == 0)
+                {
+                    $yT1 = $x * $diamondWidth - $diamondWidth / 2 + $dx;
+                    $yT2 = $diamondHeight / 2 * 6 - $diamondHeight / 2;
+                    $this->svg->addPolyline($diamond, array_merge($styles, ['transform' => "translate($yT1, $yT2)"]));
+                }
+
+                // Add an extra one at bottom-right, for tiling.
+                if ($x == 0 && $y == 0)
+                {
+                    $xyT1 = 6 * $diamondWidth - $diamondWidth / 2 + $dx;
+                    $xyT2 = $diamondHeight / 2 * 6 - $diamondHeight / 2;
+                    $this->svg->addPolyline($diamond, array_merge($styles, ['transform' => "translate($xyT1, $xyT2)"]));
+                }
+
+                $i++;
+            }
+        }
+    }
+
+    protected function geoNestedSquares()
+    {
+        $blockSize = $this->map($this->hexVal(0, 1), 0, 15, 4, 12);
+        $squareSize = $blockSize * 7;
+        $dimension = ($squareSize + $blockSize) * 6 + $blockSize * 6;
+
+        $this->svg->setWidth($dimension)
+            ->setHeight($dimension);
+
+        $i = 0;
+        for ($y = 0; $y <= 5; $y++) {
+            for ($x = 0; $x <= 5; $x++) {
+                $val = $this->hexVal($i, 1);
+                $opacity = $this->opacity($val);
+                $fill = $this->fillColor($val);
+
+                $styles = [
+                    'fill' => 'none',
+                    'stroke' => $fill,
+                    'style' => [
+                        'opacity' => $opacity,
+                        'stroke-width' => "{$blockSize}px",
+                    ],
+                ];
+
+                $rX = $x * $squareSize + $x * $blockSize * 2 + $blockSize / 2;
+                $rY = $y * $squareSize + $y * $blockSize * 2 + $blockSize / 2;
+
+                $this->svg->addRectangle($rX, $rY, $squareSize, $squareSize, $styles);
+
+                $val = $this->hexVal(39-$i, 1);
+                $opacity = $this->opacity($val);
+                $fill = $this->fillColor($val);
+
+                $styles = [
+                    'fill' => 'none',
+                    'stroke' => $fill,
+                    'style' => [
+                        'opacity' => $opacity,
+                        'stroke-width' => "{$blockSize}px",
+                    ],
+                ];
+
+                $rX2 = $x * $squareSize + $x * $blockSize * 2 + $blockSize / 2 + $blockSize * 2;
+                $rY2 = $y * $squareSize + $y * $blockSize * 2 + $blockSize / 2 + $blockSize * 2;
+
+                $this->svg->addRectangle($rX2, $rY2, $blockSize * 3, $blockSize * 3, $styles);
+
+                $i++;
+            }
+        }
+    }
+
+    protected function geoMosaicSquares()
+    {
+        $triangleSize = $this->map($this->hexVal(0, 1), 0, 15, 15, 50);
+
+        $this->svg->setWidth($triangleSize*8)
+            ->setHeight($triangleSize*8);
+
+        $i = 0;
+        for ($y = 0; $y <= 3; $y++) {
+            for ($x = 0; $x <= 3; $x++) {
+                if ($x % 2 == 0)
+                {
+                    if ($y % 2 == 0)
+                        $this->drawOuterMosaicTile($x*$triangleSize*2, $y*$triangleSize*2, $triangleSize, $this->hexVal($i, 1));
+                    else
+                        $this->drawInnerMosaicTile($x*$triangleSize*2, $y*$triangleSize*2, $triangleSize, [$this->hexVal($i, 1), $this->hexVal($i+1, 1)]);
+                }
+                else
+                {
+                    if ($y % 2 == 0)
+                        $this->drawInnerMosaicTile($x*$triangleSize*2, $y*$triangleSize*2, $triangleSize, [$this->hexVal($i, 1), $this->hexVal($i+1, 1)]);
+                    else
+                        $this->drawOuterMosaicTile($x*$triangleSize*2, $y*$triangleSize*2, $triangleSize, $this->hexVal($i, 1));
+                }
+                $i++;
+            }
+        }
+
+    }
+
+    protected function geoPlaid()
+    {
+        $height = 0;
+        $width = 0;
+
+        // Horizontal Stripes
+        $i = 0;
+        $times = 0;
+        while ($times++ <= 18)
+        {
+            $space = $this->hexVal($i, 1);
+            $height += $space + 5;
+
+            $val = $this->hexVal($i+1, 1);
+            $opacity = $this->opacity($val);
+            $fill = $this->fillColor($val);
+            $stripeHeight = $val + 5;
+
+            $this->svg->addRectangle(0, $height, "100%", $stripeHeight, [
+                'opacity' => $opacity,
+                'fill' => $fill,
+            ]);
+            $height += $stripeHeight;
+            $i += 2;
+        }
+
+        // Vertical Stripes
+        $i = 0;
+        $times = 0;
+        while ($times++ <= 18)
+        {
+            $space = $this->hexVal($i, 1);
+            $width += $space + 5;
+
+            $val = $this->hexVal($i+1, 1);
+            $opacity = $this->opacity($val);
+            $fill = $this->fillColor($val);
+            $stripeWidth = $val + 5;
+
+            $this->svg->addRectangle($width, 0, $stripeWidth, "100%", [
+                'opacity' => $opacity,
+                'fill' => $fill,
+            ]);
+            $width += $stripeWidth;
+            $i += 2;
+        }
+
+        $this->svg->setWidth($width)
+            ->setHeight($height);
+
+    }
+
+    protected function geoTessellation()
+    {
+        $sideLength = $this->map($this->hexVal(0, 1), 0, 15, 5, 40);
+        $hexHeight = $sideLength * sqrt(3);
+        $hexWidth = $sideLength * 2;
+        $triangleHeight = $sideLength / 2 * sqrt(3);
+        $triangle = $this->buildRotatedTriangleShape($sideLength, $triangleHeight);
+        $tileWidth = $sideLength * 3 + $triangleHeight * 2;
+        $tileHeight = ($hexHeight * 2) + ($sideLength * 2);
+
+        $this->svg->setWidth($tileWidth)
+            ->setHeight($tileHeight);
+
+        // Doing these variables up here, so we only have to calculate them once.
+        $halfSideLength = $sideLength / 2;
+        $negativeHalfSideLength = -$sideLength / 2;
+        $halfTriangleHeight = $triangleHeight / 2;
+        $halfHexHeight = $hexHeight / 2;
+        $tileHeightPlusHalfSideLength = $tileHeight + $sideLength / 2;
+        $halfTileHeightMinusHalfSideLength = $tileHeight / 2 - $sideLength / 2;
+        $halfTileWidthPlusHalfSideLength = $tileWidth / 2 + $sideLength / 2;
+        $tileWidthMinusHalfTileWidthMinusHalfSideLength = $tileWidth - $tileWidth/2 - $sideLength/2;
+        $tileWidthMinusHalfSideLength = $tileWidth - $sideLength / 2;
+        $tileHeightMinusHalfHexHeight = $tileHeight - $hexHeight / 2;
+        $negativeTileWidthPlusHalfSideLength = -$tileWidth + $sideLength / 2;
+        $halfTileHeightMinusHalfSideLengthMinusSideLength = $tileHeight/2-$sideLength/2-$sideLength;
+        $negativeTileHeightPlusHalfTileHeightMinusHalfSideLengthMinusSideLength = -$tileHeight+$tileHeight/2-$sideLength/2-$sideLength;
+        $negativeTileHeightPlusHalfSideLength = -$tileHeight + $sideLength / 2;
+        for ($i = 0; $i <= 19; $i++) {
+            $val = $this->hexVal($i, 1);
+            $opacity = $this->opacity($val);
+            $fill = $this->fillColor($val);
+
+            $styles = [
+                'stroke' => self::STROKE_COLOR,
+                'stroke-opacity' => self::STROKE_OPACITY,
+                'fill' => $fill,
+                'fill-opacity' => $opacity,
+                'stroke-width' => 1,
+            ];
+
+            switch ($i) {
+                case 0: # all 4 corners
+                    $this->svg->addRectangle(-$sideLength/2, -$sideLength/2, $sideLength, $sideLength, $styles);
+                    $this->svg->addRectangle($tileWidth-$sideLength/2, -$sideLength/2, $sideLength, $sideLength, $styles);
+                    $this->svg->addRectangle(-$sideLength/2, $tileHeight-$sideLength/2, $sideLength, $sideLength, $styles);
+                    $this->svg->addRectangle($tileWidth-$sideLength/2, $tileHeight-$sideLength/2, $sideLength, $sideLength, $styles);
+                    break;
+                case 1: # center / top square
+                    $this->svg->addRectangle($hexWidth/2+$triangleHeight, $hexHeight/2, $sideLength, $sideLength, $styles);
+                    break;
+                case 2: # side squares
+                    $this->svg->addRectangle(-$sideLength/2, $tileHeight/2-$sideLength/2, $sideLength, $sideLength, $styles);
+                    $this->svg->addRectangle($tileWidth-$sideLength/2, $tileHeight/2-$sideLength/2, $sideLength, $sideLength, $styles);
+                    break;
+                case 3: # center / bottom square
+                    $this->svg->addRectangle($hexWidth/2+$triangleHeight, $hexHeight*1.5+$sideLength, $sideLength, $sideLength, $styles);
+                    break;
+                case 4: # left top / bottom triangle
+                    $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($halfSideLength, $negativeHalfSideLength) rotate(0, $halfSideLength, $halfTriangleHeight)"]));
+                    $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($halfSideLength, $tileHeightPlusHalfSideLength) rotate(0, $halfSideLength, $halfTriangleHeight) scale(1, -1)"]));
+                    break;
+                case 5: # right top / bottom triangle
+                    $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($tileWidthMinusHalfSideLength, $negativeHalfSideLength) rotate(0, $halfSideLength, $halfTriangleHeight) scale(-1, 1)"]));
+                    $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($tileWidthMinusHalfSideLength, $tileHeightPlusHalfSideLength) rotate(0, $halfSideLength, $halfTriangleHeight) scale(-1, -1)"]));
+                    break;
+                case 6: # center / top / right triangle
+                    $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($halfTileWidthPlusHalfSideLength, $halfHexHeight)"]));
+                    break;
+                case 7: # center / top / left triangle
+                    $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($tileWidthMinusHalfTileWidthMinusHalfSideLength, $halfHexHeight) scale(-1, 1)"]));
+                    break;
+                case 8: # center / bottom / right triangle
+                    $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($halfTileWidthPlusHalfSideLength, $tileHeightMinusHalfHexHeight) scale(1, -1)"]));
+                    break;
+                case 9: # center / bottom / left triangle
+                    $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($tileWidthMinusHalfTileWidthMinusHalfSideLength, $tileHeightMinusHalfHexHeight) scale(-1, -1)"]));
+                    break;
+                case 10: # left / middle triangle
+                    $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($halfSideLength, $halfTileHeightMinusHalfSideLength)"]));
+                    break;
+                case 11: # right / middle triangle
+                    $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($tileWidthMinusHalfSideLength, $halfTileHeightMinusHalfSideLength) scale(-1, 1)"]));
+                    break;
+                case 12: # left / top square
+                    $this->svg->addRectangle(0, 0, $sideLength, $sideLength, array_merge($styles, ['transform' => "translate($halfSideLength, $halfSideLength) rotate(-30, 0, 0)"]));
+                    break;
+                case 13: # right / top square
+                    $this->svg->addRectangle(0, 0, $sideLength, $sideLength, array_merge($styles, ['transform' => "scale(-1, 1) translate($negativeTileWidthPlusHalfSideLength, $halfSideLength) rotate(-30, 0, 0)"]));
+                    break;
+                case 14: # left / center-top square
+                    $this->svg->addRectangle(0, 0, $sideLength, $sideLength, array_merge($styles, ['transform' => "translate($halfSideLength, $halfTileHeightMinusHalfSideLengthMinusSideLength) rotate(30, 0, $sideLength)"]));
+                    break;
+                case 15: # right / center-top square
+                    $this->svg->addRectangle(0, 0, $sideLength, $sideLength, array_merge($styles, ['transform' => "scale(-1, 1) translate($negativeTileWidthPlusHalfSideLength, $halfTileHeightMinusHalfSideLengthMinusSideLength) rotate(30, 0, $sideLength)"]));
+                    break;
+                case 16: # left / center-top square
+                    $this->svg->addRectangle(0, 0, $sideLength, $sideLength, array_merge($styles, ['transform' => "scale(1, -1) translate($halfSideLength, $negativeTileHeightPlusHalfTileHeightMinusHalfSideLengthMinusSideLength) rotate(30, 0, $sideLength)"]));
+                    break;
+                case 17: # right / center-bottom square
+                    $this->svg->addRectangle(0, 0, $sideLength, $sideLength, array_merge($styles, ['transform' => "scale(-1, -1) translate($negativeTileWidthPlusHalfSideLength, $negativeTileHeightPlusHalfTileHeightMinusHalfSideLengthMinusSideLength) rotate(30, 0, $sideLength)"]));
+                    break;
+                case 18: # left / bottom square
+                    $this->svg->addRectangle(0, 0, $sideLength, $sideLength, array_merge($styles, ['transform' => "scale(1, -1) translate($halfSideLength, $negativeTileHeightPlusHalfSideLength) rotate(-30, 0, 0)"]));
+                    break;
+                case 19: # right / bottom square
+                    $this->svg->addRectangle(0, 0, $sideLength, $sideLength, array_merge($styles, ['transform' => "scale(-1, -1) translate($negativeTileWidthPlusHalfSideLength, $negativeTileHeightPlusHalfSideLength) rotate(-30, 0, 0)"]));
+                    break;
+            }
+        }
+    }
+
+    // build* functions
+    protected function buildChevronShape($width, $height)
+    {
+        $e = $height * 0.66;
+        $halfWidth = $width / 2;
+        $heightMinusE = $height - $e;
+        return [
+            new Polyline("0,0,$halfWidth,$heightMinusE,$halfWidth,$height,0,$e,0,0"),
+            new Polyline("$halfWidth,$heightMinusE,$width,0,$width,$e,$halfWidth,$height,$halfWidth,$heightMinusE")
+        ];
+    }
+
+    protected function buildOctogonShape($squareSize)
+    {
+        $s = $squareSize;
+        $c = $s * 0.33;
+        $sMinusC = $s - $c;
+        return "$c,0,$sMinusC,0,$s,$c,$s,$sMinusC,$sMinusC,$s,$c,$s,0,$sMinusC,0,$c,$c,0";
+    }
+
+    protected function buildHexagonShape($sideLength)
+    {
+        $c = $sideLength;
+        $a = $c/2;
+        $b = sin(60 * M_PI / 180) * $c;
+        $twoB = $b * 2;
+        $twoC = $c * 2;
+        $aPlusC = $a + $c;
+        return "0,$b,$a,0,$aPlusC,0,$twoC,$b,$aPlusC,$twoB,$a,$twoB,0,$b";
+    }
+
+    protected function buildPlusShape($squareSize)
+    {
+        return [
+            new Rectangle($squareSize, 0, $squareSize, $squareSize*3),
+            new Rectangle(0, $squareSize, $squareSize*3, $squareSize),
+        ];
+    }
+
+    protected function buildTriangleShape($sideLength, $height)
+    {
+        $halfWidth = $sideLength / 2;
+        return "$halfWidth, 0, $sideLength, $height, 0, $height, $halfWidth, 0";
+    }
+
+    protected function buildRotatedTriangleShape($sideLength, $width)
+    {
+        $halfHeight = $sideLength / 2;
+        return "0, 0, $width, $halfHeight, 0, $sideLength, 0, 0";
+    }
+
+    protected function buildRightTriangleShape($sideLength)
+    {
+        return "0, 0, $sideLength, $sideLength, 0, $sideLength, 0, 0";
+    }
+
+    protected function buildDiamondShape($width, $height)
+    {
+        $halfWidth = $width / 2;
+        $halfHeight = $height / 2;
+        return "$halfWidth, 0, $width, $halfHeight, $halfWidth, $height, 0, $halfHeight";
+    }
+
+    // draw* functions
+    protected function drawInnerMosaicTile($x, $y, $triangleSize, $vals)
+    {
+        $triangle = $this->buildRightTriangleShape($triangleSize);
+        $opacity = $this->opacity($vals[0]);
+        $fill = $this->fillColor($vals[0]);
+        $styles = [
+            'stroke' => self::STROKE_COLOR,
+            'stroke-opacity' => self::STROKE_OPACITY,
+            'fill-opacity' => $opacity,
+            'fill' => $fill,
+        ];
+        $xPlusTriangleSize = $x + $triangleSize;
+        $yPlusTwoTriangleSize = $y + $triangleSize * 2;
+        $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($xPlusTriangleSize, $y) scale(-1, 1)"]))
+            ->addPolyline($triangle, array_merge($styles, ['transform' => "translate($xPlusTriangleSize, $yPlusTwoTriangleSize) scale(1, -1)"]));
+
+        $opacity = $this->opacity($vals[1]);
+        $fill = $this->fillColor($vals[1]);
+        $styles = [
+            'stroke' => self::STROKE_COLOR,
+            'stroke-opacity' => self::STROKE_OPACITY,
+            'fill-opacity' => $opacity,
+            'fill' => $fill,
+        ];
+        $xPlusTriangleSize = $x + $triangleSize;
+        $yPlusTwoTriangleSize = $y + $triangleSize * 2;
+        $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($xPlusTriangleSize, $yPlusTwoTriangleSize) scale(-1, -1)"]))
+            ->addPolyline($triangle, array_merge($styles, ['transform' => "translate($xPlusTriangleSize, $y) scale(1, 1)"]));
+
+        return $this;
+    }
+
+    protected function drawOuterMosaicTile($x, $y, $triangleSize, $val)
+    {
+        $triangle = $this->buildRightTriangleShape($triangleSize);
+        $opacity = $this->opacity($val);
+        $fill = $this->fillColor($val);
+        $styles = [
+            'stroke' => self::STROKE_COLOR,
+            'stroke-opacity' => self::STROKE_OPACITY,
+            'fill-opacity' => $opacity,
+            'fill' => $fill,
+        ];
+
+        $yPlusTriangleSize = $y + $triangleSize;
+        $xPlusTwoTriangleSize = $x + $triangleSize * 2;
+        $this->svg->addPolyline($triangle, array_merge($styles, ['transform' => "translate($x, $yPlusTriangleSize) scale(1, -1)"]))
+            ->addPolyline($triangle, array_merge($styles, ['transform' => "translate($xPlusTwoTriangleSize, $yPlusTriangleSize) scale(-1, -1)"]))
+            ->addPolyline($triangle, array_merge($styles, ['transform' => "translate($x, $yPlusTriangleSize) scale(1, 1)"]))
+            ->addPolyline($triangle, array_merge($styles, ['transform' => "translate($xPlusTwoTriangleSize, $yPlusTriangleSize) scale(-1, 1)"]));
+    }
+
+    // Utility Functions
+
+    protected function fillColor($val)
+    {
+        return ($val % 2 == 0) ? self::FILL_COLOR_LIGHT : self::FILL_COLOR_DARK;
+    }
+
+    protected function opacity($val)
+    {
+        return $this->map($val, 0, 15, self::OPACITY_MIN, self::OPACITY_MAX);
+    }
+
+    protected function hexVal($index, $len)
+    {
+        return hexdec(substr($this->hash, $index, $len));
+    }
+
+    // PHP implementation of Processing's map function
+    // http://processing.org/reference/map_.html
+    protected function map($value, $vMin, $vMax, $dMin, $dMax)
+    {
+        $vValue = floatval($value);
+        $vRange = $vMax - $vMin;
+        $dRange = $dMax - $dMin;
+        return ($vValue - $vMin) * $dRange / $vRange + $dMin;
+    }
+
+    // Color Functions
+    protected function hexToHSL($color)
+    {
+        $color = trim($color, '#');
+        $R = hexdec($color[0].$color[1]);
+        $G = hexdec($color[2].$color[3]);
+        $B = hexdec($color[4].$color[5]);
+
+        $HSL = array();
+
+        $var_R = ($R / 255);
+        $var_G = ($G / 255);
+        $var_B = ($B / 255);
+
+        $var_Min = min($var_R, $var_G, $var_B);
+        $var_Max = max($var_R, $var_G, $var_B);
+        $del_Max = $var_Max - $var_Min;
+
+        $L = ($var_Max + $var_Min)/2;
+
+        if ($del_Max == 0)
+        {
+            $H = 0;
+            $S = 0;
+        }
+        else
+        {
+            if ( $L < 0.5 ) $S = $del_Max / ( $var_Max + $var_Min );
+            else            $S = $del_Max / ( 2 - $var_Max - $var_Min );
+
+            $del_R = ( ( ( $var_Max - $var_R ) / 6 ) + ( $del_Max / 2 ) ) / $del_Max;
+            $del_G = ( ( ( $var_Max - $var_G ) / 6 ) + ( $del_Max / 2 ) ) / $del_Max;
+            $del_B = ( ( ( $var_Max - $var_B ) / 6 ) + ( $del_Max / 2 ) ) / $del_Max;
+
+            if      ($var_R == $var_Max) $H = $del_B - $del_G;
+            else if ($var_G == $var_Max) $H = ( 1 / 3 ) + $del_R - $del_B;
+            else if ($var_B == $var_Max) $H = ( 2 / 3 ) + $del_G - $del_R;
+
+            if ($H<0) $H++;
+            if ($H>1) $H--;
+        }
+
+        $HSL['h'] = ($H*360);
+        $HSL['s'] = $S;
+        $HSL['l'] = $L;
+
+        return $HSL;
+    }
+
+    protected function hexToRGB($hex) {
+        $hex = str_replace("#", "", $hex);
+        if(strlen($hex) == 3) {
+            $r = hexdec(substr($hex,0,1).substr($hex,0,1));
+            $g = hexdec(substr($hex,1,1).substr($hex,1,1));
+            $b = hexdec(substr($hex,2,1).substr($hex,2,1));
+        } else {
+            $r = hexdec(substr($hex,0,2));
+            $g = hexdec(substr($hex,2,2));
+            $b = hexdec(substr($hex,4,2));
+        }
+        return ['r' => $r, 'g' => $g, 'b' => $b];
+    }
+
+    protected function rgbToHSL($r, $g, $b) {
+        $r /= 255;
+        $g /= 255;
+        $b /= 255;
+        $max = max($r, $g, $b);
+        $min = min($r, $g, $b);
+        $l = ($max + $min) / 2;
+        if ($max == $min) {
+            $h = $s = 0;
+        } else {
+            $d = $max - $min;
+            $s = $l > 0.5 ? $d / (2 - $max - $min) : $d / ($max + $min);
+            switch ($max) {
+                case $r:
+                    $h = ($g - $b) / $d + ($g < $b ? 6 : 0);
+                    break;
+                case $g:
+                    $h = ($b - $r) / $d + 2;
+                    break;
+                case $b:
+                    $h = ($r - $g) / $d + 4;
+                    break;
+            }
+            $h /= 6;
+        }
+        $h = floor($h * 360);
+        $s = floor($s * 100);
+        $l = floor($l * 100);
+        return ['h' => $h, 's' => $s, 'l' => $l];
+    }
+
+    protected function hslToRGB ($h, $s, $l) {
+        $h += 360;
+        $c = ( 1 - abs( 2 * $l - 1 ) ) * $s;
+        $x = $c * ( 1 - abs( fmod( ( $h / 60 ), 2 ) - 1 ) );
+        $m = $l - ( $c / 2 );
+
+        if ( $h < 60 ) {
+            $r = $c;
+            $g = $x;
+            $b = 0;
+        } else if ( $h < 120 ) {
+            $r = $x;
+            $g = $c;
+            $b = 0;
+        } else if ( $h < 180 ) {
+            $r = 0;
+            $g = $c;
+            $b = $x;
+        } else if ( $h < 240 ) {
+            $r = 0;
+            $g = $x;
+            $b = $c;
+        } else if ( $h < 300 ) {
+            $r = $x;
+            $g = 0;
+            $b = $c;
+        } else {
+            $r = $c;
+            $g = 0;
+            $b = $x;
+        }
+
+        $r = ( $r + $m ) * 255;
+        $g = ( $g + $m ) * 255;
+        $b = ( $b + $m  ) * 255;
+
+        return array( 'r' => floor( $r ), 'g' => floor( $g ), 'b' => floor( $b ) );
+
+    }
+
+    //NOT USED
+    protected function rgbToHex($r, $g, $b) {
+        $hex = "#";
+        $hex .= str_pad(dechex($r), 2, "0", STR_PAD_LEFT);
+        $hex .= str_pad(dechex($g), 2, "0", STR_PAD_LEFT);
+        $hex .= str_pad(dechex($b), 2, "0", STR_PAD_LEFT);
+        return $hex;
+    }
+
+
+}
diff --git a/lib/geopattern-php/GeoPattern/SVG.php b/lib/geopattern-php/GeoPattern/SVG.php
new file mode 100644 (file)
index 0000000..5dab38c
--- /dev/null
@@ -0,0 +1,94 @@
+<?php namespace RedeyeVentures\GeoPattern;
+
+use RedeyeVentures\GeoPattern\SVGElements\Group;
+use RedeyeVentures\GeoPattern\SVGElements\Polyline;
+use RedeyeVentures\GeoPattern\SVGElements\Rectangle;
+use RedeyeVentures\GeoPattern\SVGElements\Circle;
+use RedeyeVentures\GeoPattern\SVGElements\Path;
+
+class SVG {
+
+    protected $width;
+    protected $height;
+    protected $svgString;
+
+    function __construct($options=array())
+    {
+        $this->width = 100;
+        $this->height = 100;
+        $this->svgString = '';
+    }
+
+    public function setWidth($width)
+    {
+        $this->width = floor($width);
+        return $this;
+    }
+
+    public function setHeight($height)
+    {
+        $this->height = floor($height);
+        return $this;
+    }
+
+    protected function getSvgHeader()
+    {
+        return "<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{$this->width}\" height=\"{$this->height}\">";
+    }
+
+    protected function getSvgFooter()
+    {
+        return '</svg>';
+    }
+
+    public function addRectangle($x, $y, $width, $height, $args=array())
+    {
+        $rectangle = new Rectangle($x, $y, $width, $height, $args);
+        $this->svgString .= $rectangle;
+        return $this;
+    }
+
+    public function addCircle($cx, $cy, $r, $args=array())
+    {
+        $circle = new Circle($cx, $cy, $r, $args);
+        $this->svgString .= $circle;
+        return $this;
+    }
+
+    public function addPath($d, $args=array())
+    {
+        $path = new Path($d, $args);
+        $this->svgString .= $path;
+        return $this;
+    }
+
+    public function addPolyline($points, $args=array())
+    {
+        $polyline = new Polyline($points, $args);
+        $this->svgString .= $polyline;
+        return $this;
+    }
+
+    public function addGroup($group, $args=array())
+    {
+        if ($group instanceof Group)
+        {
+            $group->setArgs($args);
+            $this->svgString .= $group;
+            return $this;
+        }
+        throw new \InvalidArgumentException("The group provided is not a valid instance of Group.");
+    }
+
+    public function getString()
+    {
+        return $this->getSvgHeader().$this->svgString.$this->getSvgFooter();
+    }
+
+    public function __toString()
+    {
+        return $this->getString();
+    }
+
+
+}
\ No newline at end of file
diff --git a/lib/geopattern-php/GeoPattern/SVGElements/Base.php b/lib/geopattern-php/GeoPattern/SVGElements/Base.php
new file mode 100644 (file)
index 0000000..c61f681
--- /dev/null
@@ -0,0 +1,55 @@
+<?php namespace RedeyeVentures\GeoPattern\SVGElements;
+
+abstract class Base
+{
+    protected $tag;
+    protected $elements;
+    protected $args;
+
+    function __construct($args)
+    {
+        $this->args = $args;
+    }
+
+    public function elementsToString()
+    {
+        $string = ' ';
+        foreach ($this->elements as $key => $value)
+        {
+            $string .= "$key=\"$value\" ";
+        }
+        return $string;
+    }
+
+    public function argsToString()
+    {
+        $string = '';
+        foreach ($this->args as $key => $value)
+        {
+            if (is_array($value))
+            {
+                $string .= "$key=\"";
+                foreach ($value as $k => $v)
+                {
+                    $string .= "$k:$v;";
+                }
+                $string .= '" ';
+            }
+            else
+            {
+                $string .= "$key=\"$value\" ";
+            }
+        }
+        return $string;
+    }
+
+    public function getString()
+    {
+        return "<{$this->tag}{$this->elementsToString()}{$this->argsToString()}/>";
+    }
+
+    function __toString()
+    {
+        return $this->getString();
+    }
+}
\ No newline at end of file
diff --git a/lib/geopattern-php/GeoPattern/SVGElements/Circle.php b/lib/geopattern-php/GeoPattern/SVGElements/Circle.php
new file mode 100644 (file)
index 0000000..4fce98b
--- /dev/null
@@ -0,0 +1,16 @@
+<?php namespace RedeyeVentures\GeoPattern\SVGElements;
+
+class Circle extends Base
+{
+    protected $tag = 'circle';
+
+    function __construct($cx, $cy, $r, $args=array())
+    {
+        $this->elements = [
+            'cx' => $cx,
+            'cy' => $cy,
+            'r' => $r,
+        ];
+        parent::__construct($args);
+    }
+}
\ No newline at end of file
diff --git a/lib/geopattern-php/GeoPattern/SVGElements/Group.php b/lib/geopattern-php/GeoPattern/SVGElements/Group.php
new file mode 100644 (file)
index 0000000..f504ef7
--- /dev/null
@@ -0,0 +1,38 @@
+<?php namespace RedeyeVentures\GeoPattern\SVGElements;
+
+class Group extends Base
+{
+    protected $tag = 'g';
+    protected $items;
+
+    function __construct($items=array(), $args=array())
+    {
+        $this->items = $items;
+        $this->args = $args;
+    }
+
+    function addItem($item)
+    {
+        $this->items[] = $item;
+        return $this;
+    }
+
+    function setArgs($args)
+    {
+        $this->args = $args;
+        return $this;
+    }
+
+    function getString()
+    {
+        $svgString = '';
+        $svgString .= "<{$this->tag} {$this->argsToString($this->args)}>";
+        foreach ($this->items as $item)
+        {
+            $svgString .= $item;
+        }
+        $svgString .= "</{$this->tag}>";
+
+        return $svgString;
+    }
+}
\ No newline at end of file
diff --git a/lib/geopattern-php/GeoPattern/SVGElements/Path.php b/lib/geopattern-php/GeoPattern/SVGElements/Path.php
new file mode 100644 (file)
index 0000000..5dfe5b2
--- /dev/null
@@ -0,0 +1,14 @@
+<?php namespace RedeyeVentures\GeoPattern\SVGElements;
+
+class Path extends Base
+{
+    protected $tag = 'path';
+
+    function __construct($d, $args=array())
+    {
+        $this->elements = [
+            'd' => $d,
+        ];
+        parent::__construct($args);
+    }
+}
\ No newline at end of file
diff --git a/lib/geopattern-php/GeoPattern/SVGElements/Polyline.php b/lib/geopattern-php/GeoPattern/SVGElements/Polyline.php
new file mode 100644 (file)
index 0000000..b9f6416
--- /dev/null
@@ -0,0 +1,14 @@
+<?php namespace RedeyeVentures\GeoPattern\SVGElements;
+
+class Polyline extends Base
+{
+    protected $tag = 'polyline';
+
+    function __construct($points, $args=array())
+    {
+        $this->elements = [
+            'points' => $points,
+        ];
+        parent::__construct($args);
+    }
+}
\ No newline at end of file
diff --git a/lib/geopattern-php/GeoPattern/SVGElements/Rectangle.php b/lib/geopattern-php/GeoPattern/SVGElements/Rectangle.php
new file mode 100644 (file)
index 0000000..8a10d5e
--- /dev/null
@@ -0,0 +1,17 @@
+<?php namespace RedeyeVentures\GeoPattern\SVGElements;
+
+class Rectangle extends Base
+{
+    protected $tag = 'rect';
+
+    function __construct($x, $y, $width, $height, $args=array())
+    {
+        $this->elements = [
+            'x' => $x,
+            'y' => $y,
+            'width' => $width,
+            'height' => $height,
+        ];
+        parent::__construct($args);
+    }
+}
\ No newline at end of file
diff --git a/lib/geopattern-php/LICENSE.md b/lib/geopattern-php/LICENSE.md
new file mode 100644 (file)
index 0000000..97db2e6
--- /dev/null
@@ -0,0 +1,20 @@
+The MIT License (MIT)
+
+Copyright (c) 2014 Redeye Ventures
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/lib/geopattern-php/moodle_readme.txt b/lib/geopattern-php/moodle_readme.txt
new file mode 100644 (file)
index 0000000..6a70b73
--- /dev/null
@@ -0,0 +1,14 @@
+scssphp
+-------
+
+Downloaded from: https://github.com/RedeyeGroup/geopattern-php
+
+Last commit on download: 26d9f8aa7a8b00c8afc120d706f6bdd7a9965b87
+
+Import procedure:
+
+- Copy all the files from the folder 'src' this directory.
+- Copy the license file from the project root.
+- Remove the geopattern_loader.php file from the src directory, it is not needed.
+
+Licensed under MIT, Copyright (c) 2015 Leaf Corcoran.
index b8104fa..04b0022 100644 (file)
@@ -9240,8 +9240,8 @@ function get_performance_info() {
                     $mode = ' <span title="request cache">[r]</span>';
                     break;
             }
-            $html .= '<ul class="cache-definition-stats list-unstyled m-l-1 cache-mode-'.$modeclass.' card d-inline-block">';
-            $html .= '<li class="cache-definition-stats-heading p-t-1 card-header bg-inverse font-weight-bold">' .
+            $html .= '<ul class="cache-definition-stats list-unstyled m-l-1 m-b-1 cache-mode-'.$modeclass.' card d-inline-block">';
+            $html .= '<li class="cache-definition-stats-heading p-t-1 card-header bg-dark bg-inverse font-weight-bold">' .
                 $definition . $mode.'</li>';
             $text .= "$definition {";
             foreach ($details['stores'] as $store => $data) {
index e0f867e..1c7400f 100644 (file)
@@ -412,6 +412,27 @@ class core_text_testcase extends advanced_testcase {
         $this->assertSame($str.$bom, core_text::trim_utf8_bom($bom.$str.$bom));
     }
 
+    /**
+     * Tests the static remove_unicode_non_characters method.
+     */
+    public function test_remove_unicode_non_characters() {
+        // Confirm that texts which don't contain these characters are unchanged.
+        $this->assertSame('Frogs!', core_text::remove_unicode_non_characters('Frogs!'));
+
+        // Even if they contain some very scary characters.
+        $example = html_entity_decode('A&#xfffd;&#x1d15f;B');
+        $this->assertSame($example, core_text::remove_unicode_non_characters($example));
+
+        // Non-characters are removed wherever they may be, with other characters left.
+        $example = html_entity_decode('&#xfffe;A&#xffff;B&#x8fffe;C&#xfdd0;D&#xfffd;E&#xfdd5;');
+        $expected = html_entity_decode('ABCD&#xfffd;E');
+        $this->assertSame($expected, core_text::remove_unicode_non_characters($example));
+
+        // If you only have a non-character, you get empty string.
+        $example = html_entity_decode('&#xfffe;');
+        $this->assertSame('', core_text::remove_unicode_non_characters($example));
+    }
+
     /**
      * Tests the static get_encodings method.
      */
index 8a443ef..a36d2e6 100644 (file)
@@ -117,6 +117,304 @@ class core_user_testcase extends advanced_testcase {
         $this->assertFalse(core_user::get_user_by_username('janedoe'));
     }
 
+    public function test_search() {
+        global $DB;
+
+        self::init_search_tests();
+
+        // Set up three courses for test.
+        $generator = $this->getDataGenerator();
+        $course1 = $generator->create_course();
+        $course2 = $generator->create_course();
+        $course3 = $generator->create_course();
+
+        // Manager user in system level.
+        $manager = $generator->create_user(['firstname' => 'Manager', 'lastname' => 'Person',
+                'email' => 'x@x.x']);
+        $systemcontext = \context_system::instance();
+        $generator->role_assign($DB->get_field('role', 'id', ['shortname' => 'manager']),
+                $manager->id, $systemcontext->id);
+
+        // Teachers in one and two courses.
+        $teacher1 = $generator->create_user(['firstname' => 'Alberto', 'lastname' => 'Unwin',
+                'email' => 'a.unwin@x.x']);
+        $generator->enrol_user($teacher1->id, $course1->id, 'teacher');
+        $teacher2and3 = $generator->create_user(['firstname' => 'Alexandra', 'lastname' => 'Penguin',
+                'email' => 'sillypenguin@x.x']);
+        $generator->enrol_user($teacher2and3->id, $course2->id, 'teacher');
+        $generator->enrol_user($teacher2and3->id, $course3->id, 'teacher');
+
+        // Students in each course and some on multiple courses.
+        $student1 = $generator->create_user(['firstname' => 'Amanda', 'lastname' => 'Hodder',
+                'email' => 'hodder_a@x.x']);
+        $generator->enrol_user($student1->id, $course1->id, 'student');
+        $student2 = $generator->create_user(['firstname' => 'Audrey', 'lastname' => 'Methuen',
+                'email' => 'audrey@x.x']);
+        $generator->enrol_user($student2->id, $course2->id, 'student');
+        $student3 = $generator->create_user(['firstname' => 'Austin', 'lastname' => 'Bloomsbury',
+                'email' => 'a.bloomsbury@x.x']);
+        $generator->enrol_user($student3->id, $course3->id, 'student');
+        $student1and2 = $generator->create_user(['firstname' => 'Augustus', 'lastname' => 'Random',
+                'email' => 'random@x.x']);
+        $generator->enrol_user($student1and2->id, $course1->id, 'student');
+        $generator->enrol_user($student1and2->id, $course2->id, 'student');
+        $studentall = $generator->create_user(['firstname' => 'Amelia', 'lastname' => 'House',
+                'email' => 'house@x.x']);
+        $generator->enrol_user($studentall->id, $course1->id, 'student');
+        $generator->enrol_user($studentall->id, $course2->id, 'student');
+        $generator->enrol_user($studentall->id, $course3->id, 'student');
+
+        // Special mixed user (name does not begin with A) is a teacher in one course and student
+        // in another.
+        $mixed = $generator->create_user(['firstname' => 'Xavier', 'lastname' => 'Harper',
+                'email' => 'xh1248@x.x']);
+        $generator->enrol_user($mixed->id, $course1->id, 'student');
+        $generator->enrol_user($mixed->id, $course3->id, 'teacher');
+
+        // As admin user, try searching for somebody at system level by first name, checking the
+        // results.
+        $this->setAdminUser();
+        $result = core_user::search('Amelia');
+        $this->assertCount(1, $result);
+
+        // Check some basic fields, and test other fields are present.
+        $this->assertEquals($studentall->id, $result[0]->id);
+        $this->assertEquals('Amelia', $result[0]->firstname);
+        $this->assertEquals('House', $result[0]->lastname);
+        $this->assertEquals('house@x.x', $result[0]->email);
+        $this->assertEquals(0, $result[0]->deleted);
+        $this->assertObjectHasAttribute('firstnamephonetic', $result[0]);
+        $this->assertObjectHasAttribute('lastnamephonetic', $result[0]);
+        $this->assertObjectHasAttribute('middlename', $result[0]);
+        $this->assertObjectHasAttribute('alternatename', $result[0]);
+        $this->assertObjectHasAttribute('imagealt', $result[0]);
+        $this->assertObjectHasAttribute('username', $result[0]);
+
+        // Now search by lastname, both names, and partials, case-insensitive.
+        $this->assertEquals($result, core_user::search('House'));
+        $this->assertEquals($result, core_user::search('Amelia house'));
+        $this->assertEquals($result, core_user::search('amelI'));
+        $this->assertEquals($result, core_user::search('hoUs'));
+        $this->assertEquals($result, core_user::search('Amelia H'));
+
+        // Admin user can also search by email (full or partial).
+        $this->assertEquals($result, core_user::search('house@x.x'));
+        $this->assertEquals($result, core_user::search('hOuse@'));
+
+        // What if we just search for A? (They all begin with A except the manager.)
+        $result = core_user::search('a');
+        $this->assertCount(7, $result);
+
+        // Au gets us Audrey, Austin, and Augustus - in alphabetical order by surname.
+        $result = core_user::search('au');
+        $this->assertCount(3, $result);
+        $this->assertEquals('Austin', $result[0]->firstname);
+        $this->assertEquals('Audrey', $result[1]->firstname);
+        $this->assertEquals('Augustus', $result[2]->firstname);
+
+        // But if we search within course 2 we'll get Audrey and Augustus first.
+        $course2context = \context_course::instance($course2->id);
+        $result = core_user::search('au', $course2context);
+        $this->assertCount(3, $result);
+        $this->assertEquals('Audrey', $result[0]->firstname);
+        $this->assertEquals('Augustus', $result[1]->firstname);
+        $this->assertEquals('Austin', $result[2]->firstname);
+
+        // Try doing a few searches as manager - we should get the same results and can still
+        // search by email too.
+        $this->setUser($manager);
+        $result = core_user::search('a');
+        $this->assertCount(7, $result);
+        $result = core_user::search('au', $course2context);
+        $this->assertCount(3, $result);
+        $result = core_user::search('house@x.x');
+        $this->assertCount(1, $result);
+
+        // Teacher 1. No site-level permission so can't see users outside the enrolled course.
+        $this->setUser($teacher1);
+        $result = core_user::search('au');
+        $this->assertCount(1, $result);
+        $this->assertEquals('Augustus', $result[0]->firstname);
+
+        // Can still search by email for that user.
+        $result = core_user::search('random@x.x');
+        $this->assertCount(1, $result);
+
+        // Search everyone - teacher can only see four users (including themself).
+        $result = core_user::search('a');
+        $this->assertCount(4, $result);
+
+        // Search within course 2 - you get the same four users (which doesn't include
+        // everyone on that course) but the two on course 2 should be first.
+        $result = core_user::search('a', $course2context);
+        $this->assertCount(4, $result);
+        $this->assertEquals('Amelia', $result[0]->firstname);
+        $this->assertEquals('Augustus', $result[1]->firstname);
+
+        // Other teacher.
+        $this->setUser($teacher2and3);
+        $result = core_user::search('au');
+        $this->assertCount(3, $result);
+
+        $result = core_user::search('a');
+        $this->assertCount(5, $result);
+
+        // Student can only see users on course 3.
+        $this->setUser($student3);
+        $result = core_user::search('a');
+        $this->assertCount(3, $result);
+
+        $result = core_user::search('au');
+        $this->assertCount(1, $result);
+        $this->assertEquals('Austin', $result[0]->firstname);
+
+        // Student cannot search by email.
+        $result = core_user::search('a.bloomsbury@x.x');
+        $this->assertCount(0, $result);
+
+        // Student on all courses can see all the A users.
+        $this->setUser($studentall);
+        $result = core_user::search('a');
+        $this->assertCount(7, $result);
+
+        // Mixed user can see users on courses 1 and 3.
+        $this->setUser($mixed);
+        $result = core_user::search('a');
+        $this->assertCount(6, $result);
+
+        // Mixed user can search by email for students on course 3 but not on course 1.
+        $result = core_user::search('hodder_a@x.x');
+        $this->assertCount(0, $result);
+        $result = core_user::search('house@x.x');
+        $this->assertCount(1, $result);
+    }
+
+    /**
+     * Tests the search() function with limits on the number to return.
+     */
+    public function test_search_with_count() {
+        self::init_search_tests();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+
+        // Check default limit (30).
+        for ($i = 0; $i < 31; $i++) {
+            $student = $generator->create_user(['firstname' => 'Guy', 'lastname' => 'Xxx' . $i,
+                    'email' => 'xxx@x.x']);
+            $generator->enrol_user($student->id, $course->id, 'student');
+        }
+        $this->setAdminUser();
+        $result = core_user::search('Guy');
+        $this->assertCount(30, $result);
+
+        // Check a small limit.
+        $result = core_user::search('Guy', null, 10);
+        $this->assertCount(10, $result);
+
+        // Check no limit.
+        $result = core_user::search('Guy', null, 0);
+        $this->assertCount(31, $result);
+    }
+
+    /**
+     * When course is in separate groups mode and user is a student, they can't see people who
+     * are not in the same group. This is checked by the user profile permission thing and not
+     * currently by the original query.
+     */
+    public function test_search_group_permissions() {
+        global $DB;
+
+        self::init_search_tests();
+
+        // Create one user to do the searching.
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course(['groupmode' => SEPARATEGROUPS]);
+        $searcher = $generator->create_user(['firstname' => 'Searchy', 'lastname' => 'Sam',
+                'email' => 'xxx@x.x']);
+        $generator->enrol_user($searcher->id, $course->id, 'student');
+        $group = $generator->create_group(['courseid' => $course->id]);
+        groups_add_member($group, $searcher);
+
+        // Create a large number of people so that we have to make multiple database reads.
+        $targets = [];
+        for ($i = 0; $i < 50; $i++) {
+            $student = $generator->create_user(['firstname' => 'Guy', 'lastname' => 'Xxx' . $i,
+                    'email' => 'xxx@x.x']);
+            $generator->enrol_user($student->id, $course->id, 'student');
+            $targets[] = $student;
+        }
+
+        // The first and last people are in the same group.
+        groups_add_member($group, $targets[0]);
+        groups_add_member($group, $targets[49]);
+
+        // As searcher, we only find the 2 in the same group.
+        $this->setUser($searcher);
+        $result = core_user::search('Guy');
+        $this->assertCount(2, $result);
+
+        // If we change the course to visible groups though, we get the max number.
+        $DB->set_field('course', 'groupmode', VISIBLEGROUPS, ['id' => $course->id]);
+        $result = core_user::search('Guy');
+        $this->assertCount(30, $result);
+    }
+
+    /**
+     * When course is in separate groups mode and user is a student, they can't see people who
+     * are not in the same group. This is checked by the user profile permission thing and not
+     * currently by the original query.
+     */
+    public function test_search_deleted_users() {
+        self::init_search_tests();
+
+        // Create one user to do the searching.
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $searcher = $generator->create_user(['firstname' => 'Searchy', 'lastname' => 'Sam',
+                'email' => 'xxx@x.x']);
+        $generator->enrol_user($searcher->id, $course->id, 'student');
+
+        // Create another two users to search for.
+        $student1 = $generator->create_user(['firstname' => 'Amelia', 'lastname' => 'Aardvark']);
+        $student2 = $generator->create_user(['firstname' => 'Amelia', 'lastname' => 'Beetle']);
+        $generator->enrol_user($student1->id, $course->id, 'student');
+        $generator->enrol_user($student2->id, $course->id, 'student');
+
+        // As searcher, we find both users.
+        $this->setUser($searcher);
+        $result = core_user::search('Amelia');
+        $this->assertCount(2, $result);
+
+        // What if one is deleted?
+        delete_user($student1);
+        $result = core_user::search('Amelia');
+        $this->assertCount(1, $result);
+        $this->assertEquals('Beetle', $result[0]->lastname);
+
+        // Delete the other, for good measure.
+        delete_user($student2);
+        $result = core_user::search('Amelia');
+        $this->assertCount(0, $result);
+    }
+
+    /**
+     * Carries out standard setup for the search test functions.
+     */
+    protected static function init_search_tests() {
+        global $DB;
+
+        // For all existing users, set their name and email to something stupid so we don't
+        // accidentally find one, confusing the test counts.
+        $DB->set_field('user', 'firstname', 'Zaphod');
+        $DB->set_field('user', 'lastname', 'Beeblebrox');
+        $DB->set_field('user', 'email', 'zaphod@beeblebrox.example.org');
+
+        // This is the default value, but let's set it just to be certain in case it changes later.
+        // It affects what fields admin (and other users with the viewuseridentity permission) can
+        // search in addition to the name.
+        set_config('showuseridentity', 'email');
+    }
+
     /**
      * Test require_active_user
      */
index f6ff2ba..2c1a9ea 100644 (file)
     <version>v1.12.6</version>
     <licenseversion></licenseversion>
   </library>
+  <library>
+    <location>geopattern-php</location>
+    <name>Generate tiling SVG patterns from a string</name>
+    <license>MIT</license>
+    <version>1.1.1</version>
+  </library>
 </libraries>
diff --git a/lib/userkey/classes/privacy/provider.php b/lib/userkey/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..39dfa45
--- /dev/null
@@ -0,0 +1,132 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Privacy class for requesting user data.
+ *
+ * @package    core_userkey
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_userkey\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_privacy\local\metadata\collection;
+use \core_privacy\local\request\transform;
+use \core_privacy\local\request\writer;
+
+/**
+ * Privacy class for requesting user data.
+ *
+ * @package    core_userkey
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+    \core_privacy\local\metadata\provider,
+
+    \core_privacy\local\request\subsystem\plugin_provider {
+
+    /**
+     * Returns meta data about this system.
+     *
+     * @param   collection     $collection The initialised collection to add items to.
+     * @return  collection     A listing of user data stored through this system.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_database_table('user_private_key', [
+                'script' => 'privacy:metadata:user_private_key:script',
+                'value' => 'privacy:metadata:user_private_key:value',
+                'userid' => 'privacy:metadata:user_private_key:userid',
+                'instance' => 'privacy:metadata:user_private_key:instance',
+                'iprestriction' => 'privacy:metadata:user_private_key:iprestriction',
+                'validuntil' => 'privacy:metadata:user_private_key:validuntil',
+                'timecreated' => 'privacy:metadata:user_private_key:timecreated',
+            ], 'privacy:metadata:user_private_key');
+
+        return $collection;
+    }
+
+    /**
+     * Exports the data relating to user keys for the specified scripts and instance, within the specified
+     * context/subcontext.
+     *
+     * @param  \context         $context Context owner of the data.
+     * @param  array            $subcontext Context owner of the data.
+     * @param  string           $script The owner of the data (usually a component name).
+     * @param  int              $instance The instance owner of the data.
+     */
+    public static function export_userkeys(\context $context, array $subcontext, string $script, $instance = null) {
+        global $DB, $USER;
+
+        $searchparams = [
+            'script' => $script,
+            'userid' => $USER->id,
+        ];
+
+        if (null !== $instance) {
+            $searchparams['instance'] = $instance;
+        }
+
+        $keys = $DB->get_recordset('user_private_key', $searchparams);
+        $keydata = [];
+        foreach ($keys as $key) {
+            $keydata[] = (object) [
+                'script' => $key->script,
+                'instance' => $key->instance,
+                'iprestriction' => $key->iprestriction,
+                'validuntil' => transform::datetime($key->validuntil),
+                'timecreated' => transform::datetime($key->timecreated),
+            ];
+        }
+        $keys->close();
+
+        if (!empty($keydata)) {
+            $data = (object) [
+                'keys' => $keydata,
+            ];
+
+            writer::with_context($context)->export_related_data($subcontext, 'userkeys', $data);
+        }
+    }
+
+    /**
+     * Deletes all userkeys for a script.
+     *
+     * @param  string           $script The owner of the data (usually a component name).
+     * @param  int              $userid The owner of the data.
+     * @param  int              $instance The instance owner of the data.
+     */
+    public static function delete_userkeys(string $script, $userid = null, $instance = null) {
+        global $DB;
+
+        $searchparams = [
+            'script' => $script,
+        ];
+
+        if (null !== $userid) {
+            $searchparams['userid'] = $userid;
+        }
+
+        if (null !== $instance) {
+            $searchparams['instance'] = $instance;
+        }
+
+        $DB->delete_records('user_private_key', $searchparams);
+    }
+}
diff --git a/lib/userkey/tests/privacy_provider.php b/lib/userkey/tests/privacy_provider.php
new file mode 100644 (file)
index 0000000..57c2760
--- /dev/null
@@ -0,0 +1,346 @@
+<?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/>.
+
+/**
+ * Privacy tests for core_userkey.
+ *
+ * @package    core_userkey
+ * @category   test
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_privacy\tests\provider_testcase;
+use \core_privacy\local\request\writer;
+use \core_userkey\privacy\provider;
+
+/**
+ * Privacy tests for core_userkey.
+ *
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_userkey_privacy_testcase extends provider_testcase {
+    /**
+     * Export for a user with no keys in the specified instance will not have any data exported.
+     */
+    public function test_export_userkeys_no_keys() {
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        $context = \context_system::instance();
+
+        provider::export_userkeys($context, [], 'core_tests');
+
+        $this->assertFalse(writer::with_context($context)->has_any_data());
+    }
+
+    /**
+     * Export for a user with a key against a script where no instance is specified.
+     */
+    public function test_export_userkeys_basic_key() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        $key = get_user_key('core_tests', $user->id);
+
+        $context = \context_system::instance();
+        $subcontext = [];
+
+        provider::export_userkeys($context, $subcontext, 'core_tests');
+
+        $writer = writer::with_context($context);
+
+        $this->assertTrue($writer->has_any_data());
+        $exported = $writer->get_related_data($subcontext, 'userkeys');
+
+        $this->assertCount(1, $exported->keys);
+
+        $firstkey = reset($exported->keys);
+        $this->assertEquals('core_tests', $firstkey->script);
+        $this->assertEquals('', $firstkey->instance);
+        $this->assertEquals('', $firstkey->iprestriction);
+        $this->assertNotEmpty($firstkey->validuntil);
+        $this->assertNotEmpty($firstkey->timecreated);
+
+        provider::delete_userkeys('core_tests', $user->id);
+
+        $this->assertCount(0, $DB->get_records('user_private_key'));
+    }
+
+    /**
+     * Export for a user with a key against a script where additional data is specified.
+     */
+    public function test_export_userkeys_complex_key() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        $key = get_user_key('core_tests', $user->id, 42, '127.0.0.1', 12345);
+
+        $context = \context_system::instance();
+        $subcontext = [];
+
+        // Export all keys in core_tests.
+        provider::export_userkeys($context, $subcontext, 'core_tests');
+
+        $writer = writer::with_context($context);
+
+        $this->assertTrue($writer->has_any_data());
+        $exported = $writer->get_related_data($subcontext, 'userkeys');
+
+        $this->assertCount(1, $exported->keys);
+
+        $firstkey = reset($exported->keys);
+        $this->assertEquals('core_tests', $firstkey->script);
+        $this->assertEquals(42, $firstkey->instance);
+        $this->assertEquals('127.0.0.1', $firstkey->iprestriction);
+        $this->assertNotEmpty($firstkey->validuntil);
+        $this->assertNotEmpty($firstkey->timecreated);
+
+        provider::delete_userkeys('core_tests', $user->id);
+
+        $this->assertCount(0, $DB->get_records('user_private_key'));
+    }
+
+    /**
+     * Export for a user with a key against a script where no instance is specified.
+     */
+    public function test_export_userkeys_basic_key_without_filter() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        $key = get_user_key('core_tests', $user->id);
+
+        $context = \context_system::instance();
+        $subcontext = [];
+
+        provider::export_userkeys($context, $subcontext, 'core_tests');
+
+        $writer = writer::with_context($context);
+
+        $this->assertTrue($writer->has_any_data());
+        $exported = $writer->get_related_data($subcontext, 'userkeys');
+
+        $this->assertCount(1, $exported->keys);
+
+        $firstkey = reset($exported->keys);
+        $this->assertEquals('core_tests', $firstkey->script);
+        $this->assertEquals('', $firstkey->instance);
+        $this->assertEquals('', $firstkey->iprestriction);
+        $this->assertNotEmpty($firstkey->validuntil);
+        $this->assertNotEmpty($firstkey->timecreated);
+
+        provider::delete_userkeys('core_tests', $user->id);
+
+        $this->assertCount(0, $DB->get_records('user_private_key'));
+    }
+
+    /**
+     * Export for a user with a key against a script where additional data is specified.
+     */
+    public function test_export_userkeys_complex_key_with_filter() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        $key = get_user_key('core_tests', $user->id, 42, '127.0.0.1', 12345);
+
+        $context = \context_system::instance();
+        $subcontext = [];
+
+        // Export all keys in core_tests against instance 43 - no keys.
+        provider::export_userkeys($context, $subcontext, 'core_tests', 43);
+        $writer = writer::with_context($context);
+        $this->assertFalse($writer->has_any_data());
+
+        // Export all keys in core_tests against instance 42.
+        provider::export_userkeys($context, $subcontext, 'core_tests', 42);
+        $writer = writer::with_context($context);
+        $this->assertTrue($writer->has_any_data());
+        $exported = $writer->get_related_data($subcontext, 'userkeys');
+
+        $this->assertCount(1, $exported->keys);
+
+        $firstkey = reset($exported->keys);
+        $this->assertEquals('core_tests', $firstkey->script);
+        $this->assertEquals(42, $firstkey->instance);
+        $this->assertEquals('127.0.0.1', $firstkey->iprestriction);
+        $this->assertNotEmpty($firstkey->validuntil);
+        $this->assertNotEmpty($firstkey->timecreated);
+
+        // Delete for instance 43 (no keys).
+        provider::delete_userkeys('core_tests', $user->id, 43);
+        $this->assertCount(1, $DB->get_records('user_private_key'));
+
+        // Delete for instance 42.
+        provider::delete_userkeys('core_tests', $user->id, 42);
+        $this->assertCount(0, $DB->get_records('user_private_key'));
+    }
+
+    /**
+     * Export for a user with keys against multiple scripts where additional data is specified.
+     */
+    public function test_export_userkeys_multiple_complex_key_with_filter() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        $key = get_user_key('core_tests', $user->id, 42, '127.0.0.1', 12345);
+        $key = get_user_key('core_userkey', $user->id, 99, '240.0.0.1', 54321);
+
+        $context = \context_system::instance();
+        $subcontext = [];
+
+        // Export all keys in core_tests against instance 43 - no keys.
+        provider::export_userkeys($context, $subcontext, 'core_tests', 43);
+        $writer = writer::with_context($context);
+        $this->assertFalse($writer->has_any_data());
+
+        // Export all keys in core_tests against instance 42.
+        provider::export_userkeys($context, $subcontext, 'core_tests', 42);
+        $writer = writer::with_context($context);
+        $this->assertTrue($writer->has_any_data());
+        $exported = $writer->get_related_data($subcontext, 'userkeys');
+
+        $this->assertCount(1, $exported->keys);
+
+        $firstkey = reset($exported->keys);
+        $this->assertEquals('core_tests', $firstkey->script);
+        $this->assertEquals(42, $firstkey->instance);
+        $this->assertEquals('127.0.0.1', $firstkey->iprestriction);
+        $this->assertNotEmpty($firstkey->validuntil);
+        $this->assertNotEmpty($firstkey->timecreated);
+
+        // Delete for instance 43 (no keys).
+        provider::delete_userkeys('core_tests', $user->id, 43);
+        $this->assertCount(2, $DB->get_records('user_private_key'));
+
+        // Delete for instance 42.
+        provider::delete_userkeys('core_tests', $user->id, 42);
+        $this->assertCount(1, $DB->get_records('user_private_key'));
+
+        // Delete for instance 99.
+        provider::delete_userkeys('core_tests', $user->id, 99);
+        $this->assertCount(1, $DB->get_records('user_private_key'));
+
+        // Delete for instance 99 of core_userkey too.
+        provider::delete_userkeys('core_userkey', $user->id, 99);
+        $this->assertCount(0, $DB->get_records('user_private_key'));
+    }
+
+    /**
+     * Export for keys against multiple users.
+     */
+    public function test_export_userkeys_multiple_users() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $otheruser = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        $key = get_user_key('core_tests', $user->id, 42, '127.0.0.1', 12345);
+        $key = get_user_key('core_tests', $otheruser->id, 42, '127.0.0.1', 12345);
+
+        $context = \context_system::instance();
+        $subcontext = [];
+
+        // Export all keys in core_tests against instance 43 - no keys.
+        provider::export_userkeys($context, $subcontext, 'core_tests', 43);
+        $writer = writer::with_context($context);
+        $this->assertFalse($writer->has_any_data());
+
+        // Export all keys in core_tests against instance 42.
+        provider::export_userkeys($context, $subcontext, 'core_tests', 42);
+        $writer = writer::with_context($context);
+        $this->assertTrue($writer->has_any_data());
+        $exported = $writer->get_related_data($subcontext, 'userkeys');
+
+        $this->assertCount(1, $exported->keys);
+
+        $firstkey = reset($exported->keys);
+        $this->assertEquals('core_tests', $firstkey->script);
+        $this->assertEquals(42, $firstkey->instance);
+        $this->assertEquals('127.0.0.1', $firstkey->iprestriction);
+        $this->assertNotEmpty($firstkey->validuntil);
+        $this->assertNotEmpty($firstkey->timecreated);
+
+        // Delete for instance 43 (no keys).
+        provider::delete_userkeys('core_tests', $user->id, 43);
+        $this->assertCount(2, $DB->get_records('user_private_key'));
+
+        // Delete for instance 42.
+        provider::delete_userkeys('core_tests', $user->id, 42);
+        $this->assertCount(1, $DB->get_records('user_private_key'));
+
+        // Delete for instance 99.
+        provider::delete_userkeys('core_tests', $user->id, 99);
+        $this->assertCount(1, $DB->get_records('user_private_key'));
+    }
+
+    /**
+     * Delete for all users in a script.
+     */
+    public function test_delete_all_userkeys_in_script() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $otheruser = $this->getDataGenerator()->create_user();
+
+        $key = get_user_key('core_tests', $user->id, 42, '127.0.0.1', 12345);
+        $key = get_user_key('core_tests', $user->id, 43, '127.0.0.1', 12345);
+        $key = get_user_key('core_userkey', $user->id, 42, '127.0.0.1', 12345);
+        $key = get_user_key('core_userkey', $user->id, 43, '127.0.0.1', 12345);
+        $key = get_user_key('core_tests', $otheruser->id, 42, '127.0.0.1', 12345);
+        $key = get_user_key('core_tests', $otheruser->id, 43, '127.0.0.1', 12345);
+        $key = get_user_key('core_userkey', $otheruser->id, 42, '127.0.0.1', 12345);
+        $key = get_user_key('core_userkey', $otheruser->id, 43, '127.0.0.1', 12345);
+
+        $context = \context_system::instance();
+        $subcontext = [];
+
+        $this->assertCount(8, $DB->get_records('user_private_key'));
+
+        // Delete for all of core_tests.
+        provider::delete_userkeys('core_tests');
+        $this->assertCount(4, $DB->get_records('user_private_key'));
+
+        // Delete for all of core_userkey where instanceid = 42.
+        provider::delete_userkeys('core_userkey', null, 42);
+        $this->assertCount(2, $DB->get_records('user_private_key'));
+
+        provider::delete_userkeys('core_userkey', $otheruser->id);
+        $this->assertCount(1, $DB->get_records('user_private_key'));
+    }
+}
diff --git a/mod/chat/classes/privacy/provider.php b/mod/chat/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..4d72767
--- /dev/null
@@ -0,0 +1,260 @@
+<?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/>.
+
+/**
+ * Data provider.
+ *
+ * @package    mod_chat
+ * @copyright  2018 Frédéric Massart
+ * @author     Frédéric Massart <fred@branchup.tech>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_chat\privacy;
+defined('MOODLE_INTERNAL') || die();
+
+use context;
+use context_helper;
+use context_module;
+use moodle_recordset;
+use stdClass;
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\helper;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+
+/**
+ * Data provider class.
+ *
+ * @package    mod_chat
+ * @copyright  2018 Frédéric Massart
+ * @author     Frédéric Massart <fred@branchup.tech>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+    \core_privacy\local\metadata\provider,
+    \core_privacy\local\request\plugin\provider {
+
+    /**
+     * Returns metadata.
+     *
+     * @param collection $collection The initialised collection to add items to.
+     * @return collection A listing of user data stored through this system.
+     */
+    public static function get_metadata(collection $collection) : collection {
+
+        $collection->add_database_table('chat_messages', [
+            'userid' => 'privacy:metadata:messages:userid',
+            'message' => 'privacy:metadata:messages:message',
+            'issystem' => 'privacy:metadata:messages:issystem',
+            'timestamp' => 'privacy:metadata:messages:timestamp',
+        ], 'privacy:metadata:messages');
+
+        // The tables chat_messages_current and chat_users are not reported here
+        // because they are considered as short-lived data and are deleted on a
+        // regular basis by cron, or during normal requests. MDL-62006 was raised
+        // to discuss and/or implement support for those tables.
+
+        return $collection;
+    }
+
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @param int $userid The user to search.
+     * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
+     */
+    public static function get_contexts_for_userid(int $userid) : \core_privacy\local\request\contextlist {
+        $contextlist = new \core_privacy\local\request\contextlist();
+
+        $sql = "
+            SELECT DISTINCT ctx.id
+              FROM {chat} c
+              JOIN {modules} m
+                ON m.name = :chat
+              JOIN {course_modules} cm
+                ON cm.instance = c.id
+               AND cm.module = m.id
+              JOIN {context} ctx
+                ON ctx.instanceid = cm.id
+               AND ctx.contextlevel = :modulelevel
+              JOIN {chat_messages} chm
+                ON chm.chatid = c.id
+             WHERE chm.userid = :userid";
+
+        $params = [
+            'chat' => 'chat',
+            'modulelevel' => CONTEXT_MODULE,
+            'userid' => $userid,
+        ];
+        $contextlist->add_from_sql($sql, $params);
+
+        return $contextlist;
+    }
+
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts to export information for.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        global $DB;
+
+        $user = $contextlist->get_user();
+        $userid = $user->id;
+        $cmids = array_reduce($contextlist->get_contexts(), function($carry, $context) {
+            if ($context->contextlevel == CONTEXT_MODULE) {
+                $carry[] = $context->instanceid;
+            }
+            return $carry;
+        }, []);
+        if (empty($cmids)) {
+            return;
+        }
+
+        $chatidstocmids = static::get_chat_ids_to_cmids_from_cmids($cmids);
+        $chatids = array_keys($chatidstocmids);
+
+        // Export the messages.
+        list($insql, $inparams) = $DB->get_in_or_equal($chatids, SQL_PARAMS_NAMED);
+        $params = array_merge($inparams, ['userid' => $userid]);
+        $recordset = $DB->get_recordset_select('chat_messages', "chatid $insql AND userid = :userid", $params, 'timestamp, id');
+        static::recordset_loop_and_export($recordset, 'chatid', [], function($carry, $record) use ($user, $chatidstocmids) {
+            $message = $record->message;
+            if ($record->issystem) {
+                $message = get_string('message' . $record->message, 'mod_chat', fullname($user));
+            }
+            $carry[] = [
+                'message' => $message,
+                'sent_at' => transform::datetime($record->timestamp),
+                'is_system_generated' => transform::yesno($record->issystem),
+            ];
+            return $carry;
+
+        }, function($chatid, $data) use ($user, $chatidstocmids) {
+            $context = context_module::instance($chatidstocmids[$chatid]);
+            $contextdata = helper::get_context_data($context, $user);
+            $finaldata = (object) array_merge((array) $contextdata, ['messages' => $data]);
+            helper::export_context_files($context, $user);
+            writer::with_context($context)->export_data([], $finaldata);
+        });
+    }
+
+    /**
+     * Delete all data for all users in the specified context.
+     *
+     * @param context $context The specific context to delete data for.
+     */
+    public static function delete_data_for_all_users_in_context(context $context) {
+        global $DB;
+
+        if ($context->contextlevel != CONTEXT_MODULE) {
+            return;
+        }
+
+        $chatid = get_coursemodule_from_id('chat', $context->instanceid, 0, false, MUST_EXIST)->instance;
+        $DB->delete_records_select('chat_messages', 'chatid = :chatid', ['chatid' => $chatid]);
+        $DB->delete_records_select('chat_messages_current', 'chatid = :chatid', ['chatid' => $chatid]);
+        $DB->delete_records_select('chat_users', 'chatid = :chatid', ['chatid' => $chatid]);
+    }
+
+    /**
+     * Delete all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
+        global $DB;
+
+        $userid = $contextlist->get_user()->id;
+        $cmids = array_reduce($contextlist->get_contexts(), function($carry, $context) {
+            if ($context->contextlevel == CONTEXT_MODULE) {
+                $carry[] = $context->instanceid;
+            }
+            return $carry;
+        }, []);
+        if (empty($cmids)) {
+            return;
+        }
+
+        $chatidstocmids = static::get_chat_ids_to_cmids_from_cmids($cmids);
+        $chatids = array_keys($chatidstocmids);
+
+        list($insql, $inparams) = $DB->get_in_or_equal($chatids, SQL_PARAMS_NAMED);
+        $sql = "chatid $insql AND userid = :userid";
+        $params = array_merge($inparams, ['userid' => $userid]);
+
+        $DB->delete_records_select('chat_messages', $sql, $params);
+        $DB->delete_records_select('chat_messages_current', $sql, $params);
+        $DB->delete_records_select('chat_users', $sql, $params);
+    }
+
+    /**
+     * Return a dict of chat IDs mapped to their course module ID.
+     *
+     * @param array $cmids The course module IDs.
+     * @return array In the form of [$chatid => $cmid].
+     */
+    protected static function get_chat_ids_to_cmids_from_cmids(array $cmids) {
+        global $DB;
+        list($insql, $inparams) = $DB->get_in_or_equal($cmids, SQL_PARAMS_NAMED);
+        $sql = "
+            SELECT c.id, cm.id AS cmid
+              FROM {chat} c
+              JOIN {modules} m
+                ON m.name = :chat
+              JOIN {course_modules} cm
+                ON cm.instance = c.id
+               AND cm.module = m.id
+             WHERE cm.id $insql";
+        $params = array_merge($inparams, ['chat' => 'chat']);
+        return $DB->get_records_sql_menu($sql, $params);
+    }
+
+    /**
+     * Loop and export from a recordset.
+     *
+     * @param moodle_recordset $recordset The recordset.
+     * @param string $splitkey The record key to determine when to export.
+     * @param mixed $initial The initial data to reduce from.
+     * @param callable $reducer The function to return the dataset, receives current dataset, and the current record.
+     * @param callable $export The function to export the dataset, receives the last value from $splitkey and the dataset.
+     * @return void
+     */
+    protected static function recordset_loop_and_export(moodle_recordset $recordset, $splitkey, $initial,
+            callable $reducer, callable $export) {
+
+        $data = $initial;
+        $lastid = null;
+
+        foreach ($recordset as $record) {
+            if ($lastid && $record->{$splitkey} != $lastid) {
+                $export($lastid, $data);
+                $data = $initial;
+            }
+            $data = $reducer($data, $record);
+            $lastid = $record->{$splitkey};
+        }
+        $recordset->close();
+
+        if (!empty($lastid)) {
+            $export($lastid, $data);
+        }
+    }
+
+}
index 9ef90ba..c78e794 100644 (file)
@@ -118,6 +118,11 @@ $string['page-mod-chat-x'] = 'Any chat module page';
 $string['pastchats'] = 'Past chat sessions';
 $string['pluginadministration'] = 'Chat administration';
 $string['pluginname'] = 'Chat';
+$string['privacy:metadata:messages'] = 'A record of the messages sent during a chat session';
+$string['privacy:metadata:messages:issystem'] = 'Whether the message is a system-generated message';
+$string['privacy:metadata:messages:message'] = 'The message';
+$string['privacy:metadata:messages:timestamp'] = 'The time at which the message was sent';
+$string['privacy:metadata:messages:userid'] = 'The user ID of the author of the message';
 $string['refreshroom'] = 'Refresh room';
 $string['refreshuserlist'] = 'Refresh user list';
 $string['removemessages'] = 'Remove all messages';
diff --git a/mod/chat/tests/privacy_test.php b/mod/chat/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..e4fdf7d
--- /dev/null
@@ -0,0 +1,311 @@
+<?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/>.
+
+/**
+ * Data provider tests.
+ *
+ * @package    mod_chat
+ * @category   test
+ * @copyright  2018 Frédéric Massart
+ * @author     Frédéric Massart <fred@branchup.tech>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+
+use core_privacy\tests\provider_testcase;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+use mod_chat\privacy\provider;
+
+require_once($CFG->dirroot . '/mod/chat/lib.php');
+
+/**
+ * Data provider testcase class.
+ *
+ * @package    mod_chat
+ * @category   test
+ * @copyright  2018 Frédéric Massart
+ * @author     Frédéric Massart <fred@branchup.tech>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mod_chat_privacy_testcase extends provider_testcase {
+
+    public function setUp() {
+        global $PAGE;
+        $this->resetAfterTest();
+        $PAGE->get_renderer('core');
+    }
+
+    public function test_get_contexts_for_userid() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+        $c1 = $dg->create_course();
+        $c2 = $dg->create_course();
+
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+
+        $chat1a = $dg->create_module('chat', ['course' => $c1]);
+        $chat1b = $dg->create_module('chat', ['course' => $c1]);
+        $chat2a = $dg->create_module('chat', ['course' => $c2]);
+
+        // Logins but no message.
+        $chatuser = $this->login_user_in_course_chat($u1, $c1, $chat1a);
+
+        // Logins and messages.
+        $chatuser = $this->login_user_in_course_chat($u1, $c1, $chat1b);
+        chat_send_chatmessage($chatuser, 'Hello world!');
+
+        // Silent login (no system message).
+        $chatuser = $this->login_user_in_course_chat($u1, $c2, $chat2a, 0, true);
+
+        // Silent login and messages.
+        $chatuser = $this->login_user_in_course_chat($u2, $c1, $chat1b, 0, true);
+        chat_send_chatmessage($chatuser, 'Ça va ?');
+        chat_send_chatmessage($chatuser, 'Moi, ça va.');
+
+        // Silent login and messages.
+        $chatuser = $this->login_user_in_course_chat($u2, $c2, $chat2a);
+        chat_send_chatmessage($chatuser, 'What\'s happening here?');
+
+        // Check contexts for user 1.
+        $contextids = provider::get_contexts_for_userid($u1->id)->get_contextids();
+        $this->assertCount(2, $contextids);
+        $this->assertTrue(in_array(context_module::instance($chat1a->cmid)->id, $contextids));
+        $this->assertTrue(in_array(context_module::instance($chat1b->cmid)->id, $contextids));
+
+        $contextids = provider::get_contexts_for_userid($u2->id)->get_contextids();
+        $this->assertCount(2, $contextids);
+        $this->assertTrue(in_array(context_module::instance($chat1b->cmid)->id, $contextids));
+        $this->assertTrue(in_array(context_module::instance($chat2a->cmid)->id, $contextids));
+    }
+
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+        $c1 = $dg->create_course();
+
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+
+        $chat1a = $dg->create_module('chat', ['course' => $c1]);
+        $chat1b = $dg->create_module('chat', ['course' => $c1]);
+        $chat1actx = context_module::instance($chat1a->cmid);
+        $chat1bctx = context_module::instance($chat1b->cmid);
+
+        $u1chat1a = $this->login_user_in_course_chat($u1, $c1, $chat1a);
+        $u2chat1a = $this->login_user_in_course_chat($u2, $c1, $chat1a);
+        chat_send_chatmessage($u1chat1a, 'Ça va ?');
+        chat_send_chatmessage($u2chat1a, 'Oui, et toi ?');
+        chat_send_chatmessage($u1chat1a, 'Bien merci.');
+        chat_send_chatmessage($u2chat1a, 'Pourquoi ils disent omelette "du" fromage ?!');
+        chat_send_chatmessage($u1chat1a, 'Aucune idée');
+        $this->assert_has_data_in_chat($u1, $chat1a);
+        $this->assert_has_data_in_chat($u2, $chat1a);
+
+        $u1chat1b = $this->login_user_in_course_chat($u1, $c1, $chat1b);
+        $u2chat1b = $this->login_user_in_course_chat($u2, $c1, $chat1b);
+        chat_send_chatmessage($u1chat1b, 'How are you going?');
+        chat_send_chatmessage($u2chat1b, 'Alright, you?');
+        chat_send_chatmessage($u1chat1b, 'Good, thanks.');
+        chat_send_chatmessage($u2chat1b, 'Sacre bleu!');
+        chat_send_chatmessage($u1chat1b, '\ö/');
+        $this->assert_has_data_in_chat($u1, $chat1b);
+        $this->assert_has_data_in_chat($u2, $chat1b);
+
+        // No change.
+        provider::delete_data_for_all_users_in_context(context_course::instance($c1->id));
+        $this->assert_has_data_in_chat($u1, $chat1a);
+        $this->assert_has_data_in_chat($u2, $chat1a);
+        $this->assert_has_data_in_chat($u1, $chat1b);
+        $this->assert_has_data_in_chat($u2, $chat1b);
+
+        // Deletinge first chat does not affect other chat.
+        provider::delete_data_for_all_users_in_context($chat1actx);
+        $this->assert_has_no_data_in_chat($u1, $chat1a);
+        $this->assert_has_no_data_in_chat($u2, $chat1a);
+        $this->assert_has_data_in_chat($u1, $chat1b);
+        $this->assert_has_data_in_chat($u2, $chat1b);
+    }
+
+    public function test_delete_data_for_user() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+        $c1 = $dg->create_course();
+
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+
+        $chat1a = $dg->create_module('chat', ['course' => $c1]);
+        $chat1b = $dg->create_module('chat', ['course' => $c1]);
+        $chat1actx = context_module::instance($chat1a->cmid);
+        $chat1bctx = context_module::instance($chat1b->cmid);
+
+        $u1chat1a = $this->login_user_in_course_chat($u1, $c1, $chat1a);
+        $u2chat1a = $this->login_user_in_course_chat($u2, $c1, $chat1a);
+        chat_send_chatmessage($u1chat1a, 'Ça va ?');
+        chat_send_chatmessage($u2chat1a, 'Oui, et toi ?');
+        chat_send_chatmessage($u1chat1a, 'Bien merci.');
+        chat_send_chatmessage($u2chat1a, 'Pourquoi ils disent omelette "du" fromage ?!');
+        chat_send_chatmessage($u1chat1a, 'Aucune idée');
+        $this->assert_has_data_in_chat($u1, $chat1a);
+        $this->assert_has_data_in_chat($u2, $chat1a);
+
+        $u1chat1b = $this->login_user_in_course_chat($u1, $c1, $chat1b);
+        $u2chat1b = $this->login_user_in_course_chat($u2, $c1, $chat1b);
+        chat_send_chatmessage($u1chat1b, 'How are you going?');
+        chat_send_chatmessage($u2chat1b, 'Alright, you?');
+        chat_send_chatmessage($u1chat1b, 'Good, thanks.');
+        chat_send_chatmessage($u2chat1b, 'Sacre bleu!');
+        chat_send_chatmessage($u1chat1b, '\ö/');
+        $this->assert_has_data_in_chat($u1, $chat1b);
+        $this->assert_has_data_in_chat($u2, $chat1b);
+
+        provider::delete_data_for_user(new approved_contextlist($u1, 'mod_chat', [$chat1actx->id]));
+        $this->assert_has_no_data_in_chat($u1, $chat1a);
+        $this->assert_has_data_in_chat($u2, $chat1a);
+        $this->assert_has_data_in_chat($u1, $chat1b);
+        $this->assert_has_data_in_chat($u2, $chat1b);
+
+        provider::delete_data_for_user(new approved_contextlist($u2, 'mod_chat', [$chat1actx->id, $chat1bctx->id]));
+        $this->assert_has_no_data_in_chat($u1, $chat1a);
+        $this->assert_has_no_data_in_chat($u2, $chat1a);
+        $this->assert_has_data_in_chat($u1, $chat1b);
+        $this->assert_has_no_data_in_chat($u2, $chat1b);
+    }
+
+    public function test_export_data_for_user() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+        $c1 = $dg->create_course();
+
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+
+        $chat1a = $dg->create_module('chat', ['course' => $c1]);
+        $chat1b = $dg->create_module('chat', ['course' => $c1]);
+        $chat1actx = context_module::instance($chat1a->cmid);
+        $chat1bctx = context_module::instance($chat1b->cmid);
+
+        $u1chat1a = $this->login_user_in_course_chat($u1, $c1, $chat1a);
+        $u2chat1a = $this->login_user_in_course_chat($u2, $c1, $chat1a);
+        chat_send_chatmessage($u1chat1a, 'Ça va ?');
+        chat_send_chatmessage($u2chat1a, 'Oui, et toi ?');
+        chat_send_chatmessage($u1chat1a, 'Bien merci.');
+        chat_send_chatmessage($u2chat1a, 'Pourquoi ils disent omelette "du" fromage ?!');
+        chat_send_chatmessage($u1chat1a, 'Aucune idée');
+        chat_send_chatmessage($u1chat1a, 'exit', true);
+
+        $u1chat1b = $this->login_user_in_course_chat($u1, $c1, $chat1b);
+        $u2chat1b = $this->login_user_in_course_chat($u2, $c1, $chat1b);
+        chat_send_chatmessage($u1chat1b, 'How are you going?');
+        chat_send_chatmessage($u2chat1b, 'Alright, you?');
+        chat_send_chatmessage($u1chat1b, 'Good, thanks.');
+        chat_send_chatmessage($u2chat1b, 'Sacre bleu!');
+        chat_send_chatmessage($u1chat1b, '\ö/');
+
+        // Export for user 1 in chat 1.
+        provider::export_user_data(new approved_contextlist($u1, 'mod_chat', [$chat1actx->id]));
+        $data = writer::with_context($chat1actx)->get_data([]);
+        $this->assertNotEmpty($data);
+        $this->assertCount(5, $data->messages);
+        $this->assertEquals(get_string('messageenter', 'mod_chat', fullname($u1)), $data->messages[0]['message']);
+        $this->assertEquals(transform::yesno(true), $data->messages[0]['is_system_generated']);
+        $this->assertEquals('Ça va ?', $data->messages[1]['message']);
+        $this->assertEquals(transform::yesno(false), $data->messages[1]['is_system_generated']);
+        $this->assertEquals('Bien merci.', $data->messages[2]['message']);
+        $this->assertEquals(transform::yesno(false), $data->messages[2]['is_system_generated']);
+        $this->assertEquals('Aucune idée', $data->messages[3]['message']);
+        $this->assertEquals(transform::yesno(false), $data->messages[3]['is_system_generated']);
+        $this->assertEquals(get_string('messageexit', 'mod_chat', fullname($u1)), $data->messages[4]['message']);
+        $this->assertEquals(transform::yesno(true), $data->messages[4]['is_system_generated']);
+        $data = writer::with_context($chat1bctx)->get_data([]);
+        $this->assertEmpty($data);
+
+        // Export for user2 in chat 1 and 2.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u2, 'mod_chat', [$chat1actx->id, $chat1bctx->id]));
+        $data = writer::with_context($chat1actx)->get_data([]);
+        $this->assertNotEmpty($data);
+        $this->assertCount(3, $data->messages);
+        $this->assertEquals(get_string('messageenter', 'mod_chat', fullname($u2)), $data->messages[0]['message']);
+        $this->assertEquals('Oui, et toi ?', $data->messages[1]['message']);
+        $this->assertEquals('Pourquoi ils disent omelette "du" fromage ?!', $data->messages[2]['message']);
+        $data = writer::with_context($chat1bctx)->get_data([]);
+        $this->assertNotEmpty($data);
+        $this->assertCount(3, $data->messages);
+        $this->assertEquals(get_string('messageenter', 'mod_chat', fullname($u2)), $data->messages[0]['message']);
+        $this->assertEquals('Alright, you?', $data->messages[1]['message']);
+        $this->assertEquals('Sacre bleu!', $data->messages[2]['message']);
+    }
+
+    /**
+     * Assert that there is data for a user in a chat.
+     *
+     * @param object $user The user.
+     * @param object $chat The chat.
+     * @return void
+     */
+    protected function assert_has_data_in_chat($user, $chat) {
+        $this->assertTrue($this->has_data_in_chat($user, $chat));
+    }
+
+    /**
+     * Assert that there isn't any data for a user in a chat.
+     *
+     * @param object $user The user.
+     * @param object $chat The chat.
+     * @return void
+     */
+    protected function assert_has_no_data_in_chat($user, $chat) {
+        $this->assertFalse($this->has_data_in_chat($user, $chat));
+    }
+
+    /**
+     * Check whether a user has data in a chat.
+     *
+     * @param object $user The user.
+     * @param object $chat The chat.
+     * @return bool
+     */
+    protected function has_data_in_chat($user, $chat) {
+        global $DB;
+        return $DB->record_exists('chat_messages', ['chatid' => $chat->id, 'userid' => $user->id]);
+    }
+
+    /**
+     * Login a user in a chat.
+     *
+     * @param object $user The user.
+     * @param object $course The course.
+     * @param object $chat The chat.
+     * @param int $group The group number.
+     * @param bool $silent Whether we should advertise that the user logs in.
+     * @return object The chat user.
+     */
+    protected function login_user_in_course_chat($user, $course, $chat, $group = 0, $silent = false) {
+        global $DB, $USER;
+        $origuser = $USER;
+        $this->setUser($user);
+        chat_login_user($chat->id, $silent ? 'sockets' : 'basic', 0, $course);
+        $chatuser = $DB->get_record('chat_users', ['userid' => $user->id, 'chatid' => $chat->id, 'groupid' => 0]);
+        $this->setUser($origuser);
+        return $chatuser;
+    }
+}
diff --git a/mod/imscp/classes/privacy/provider.php b/mod/imscp/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..58ed9e2
--- /dev/null
@@ -0,0 +1,46 @@
+<?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/>.
+
+/**
+ * Privacy Subsystem implementation for mod_imscp.
+ *
+ * @package    mod_imscp
+ * @copyright  2018 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_imscp\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Privacy Subsystem for mod_imscp implementing null_provider.
+ *
+ * @copyright  2018 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
index 8b9e5db..244e909 100644 (file)
@@ -47,5 +47,6 @@ $string['page-mod-imscp-x'] = 'Any IMS content package module page';
 $string['packagefile'] = 'Package file';
 $string['pluginadministration'] = 'IMSCP administration';
 $string['pluginname'] = 'IMS content package';
+$string['privacy:metadata'] = 'The IMS content package plugin does not store any personal data.';
 $string['search:activity'] = 'IMS content package - resource information';
 $string['toc'] = 'TOC';
index 0312415..c381e2f 100644 (file)
Binary files a/mod/lti/amd/build/contentitem.min.js and b/mod/lti/amd/build/contentitem.min.js differ
index a6dad92..f278f12 100644 (file)
@@ -38,14 +38,17 @@ define(
     ],
     function($, notification, str, templates, FormField, ModalFactory, ModalEvents) {
         var dialogue;
+        var doneCallback;
         var contentItem = {
             /**
              * Init function.
              *
              * @param {string} url The URL for the content item selection.
              * @param {object} postData The data to be sent for the content item selection request.
+             * @param {Function} cb The callback to run once the content item has been processed.
              */
-            init: function(url, postData) {
+            init: function(url, postData, cb) {
+                doneCallback = cb;
                 var context = {
                     url: url,
                     postData: postData
@@ -100,7 +103,9 @@ define(
             new FormField('instructorcustomparameters', FormField.TYPES.TEXT, true, ''),
             new FormField('icon', FormField.TYPES.TEXT, true, ''),
             new FormField('secureicon', FormField.TYPES.TEXT, true, ''),
-            new FormField('launchcontainer', FormField.TYPES.SELECT, true, 0)
+            new FormField('launchcontainer', FormField.TYPES.SELECT, true, 0),
+            new FormField('grade_modgrade_point', FormField.TYPES.TEXT, false, ''),
+            new FormField('cmidnumber', FormField.TYPES.TEXT, true, '')
         ];
 
         /**
@@ -123,6 +128,10 @@ define(
                 }
                 field.setFieldValue(value);
             }
+
+            if (doneCallback) {
+                doneCallback();
+            }
         };
 
         return contentItem;
index 8074fd9..7ff4363 100644 (file)
@@ -921,9 +921,35 @@ function lti_tool_configuration_from_content_item($typeid, $messagetype, $ltiver
         } else {
             $config->typeid = $typeid;
         }
+        $config->instructorchoiceacceptgrades = LTI_SETTING_NEVER;
+        if (!$islti2 && isset($typeconfig['acceptgrades'])) {
+            $acceptgrades = $typeconfig['acceptgrades'];
+            if ($acceptgrades == LTI_SETTING_ALWAYS) {
+                // We create a line item regardless if the definition contains one or not.
+                $config->instructorchoiceacceptgrades = LTI_SETTING_ALWAYS;
+            }
+            if ($acceptgrades == LTI_SETTING_DELEGATE || $acceptgrades == LTI_SETTING_ALWAYS) {
+                if (isset($item->lineItem)) {
+                    $lineitem = $item->lineItem;
+                    $config->instructorchoiceacceptgrades = LTI_SETTING_ALWAYS;
+                    $maxscore = 100;
+                    if (isset($lineitem->scoreConstraints)) {
+                        $sc = $lineitem->scoreConstraints;
+                        if (isset($sc->totalMaximum)) {
+                            $maxscore = $sc->totalMaximum;
+                        } else if (isset($sc->normalMaximum)) {
+                            $maxscore = $sc->normalMaximum;
+                        }
+                    }
+                    $config->grade_modgrade_point = $maxscore;
+                    if (isset($lineitem->assignedActivity) && isset($lineitem->assignedActivity->activityId)) {
+                        $config->cmidnumber = $lineitem->assignedActivity->activityId;
+                    }
+                }
+            }
+        }
         $config->instructorchoicesendname = LTI_SETTING_NEVER;
         $config->instructorchoicesendemailaddr = LTI_SETTING_NEVER;
-        $config->instructorchoiceacceptgrades = LTI_SETTING_NEVER;
         $config->launchcontainer = LTI_LAUNCH_CONTAINER_DEFAULT;
         if (isset($item->placementAdvice->presentationDocumentTarget)) {
             if ($item->placementAdvice->presentationDocumentTarget === 'window') {
@@ -941,6 +967,7 @@ function lti_tool_configuration_from_content_item($typeid, $messagetype, $ltiver
             }
             $config->instructorcustomparameters = implode("\n", $customparameters);
         }
+        $config->contentitemjson = json_encode($item);
     }
     return $config;
 }
index d158cd3..f341ccc 100644 (file)
@@ -90,7 +90,9 @@
                     };
 
                     require(['mod_lti/contentitem'], function(contentitem) {
-                        contentitem.init(contentItemUrl, postData);
+                        contentitem.init(contentItemUrl, postData, function() {
+                            M.mod_lti.editor.toggleGradeSection();
+                        });
                     });
                 }
             });
index f2c2894..87fe960 100644 (file)
         <testsuite name="core_repository_testsuite">
             <directory suffix="_test.php">repository/tests</directory>
         </testsuite>
+        <testsuite name="core_userkey_testsuite">
+            <directory suffix="_test.php">lib/userkey/tests</directory>
+        </testsuite>
         <testsuite name="core_user_testsuite">
             <directory suffix="_test.php">user/tests</directory>
         </testsuite>
index 2f40376..9aeff04 100644 (file)
@@ -66,23 +66,6 @@ class provider implements
         static::call_plugin_method('export_plagiarism_user_data', [$userid, $context, $subcontext, $linkarray]);
     }
 
-    /**
-     * Checks whether the component's provider class implements the specified interface.
-     * This can either be implemented directly, or by implementing a descendant (extension) of the specified interface.
-     *
-     * @param string $component the frankenstyle component name.
-     * @param string $interface the name of the interface we want to check.
-     * @return bool True if an implementation was found, false otherwise.
-     */
-    protected static function component_implements(string $providerclass, string $interface) : bool {
-        if (class_exists($providerclass)) {
-            $rc = new \ReflectionClass($providerclass);
-            return $rc->implementsInterface($interface);
-        }
-
-        return false;
-    }
-
     /**
      * Deletes all user content for a context in all plagiarism plugins.
      *
@@ -110,14 +93,6 @@ class provider implements
      */
     protected static function call_plugin_method($methodname, $params) {
         // Note: Even if plagiarism is _now_ disabled, there may be legacy data to export.
-        $plugins = \core_component::get_plugin_list('plagiarism');
-        foreach (array_keys($plugins) as $plugin) {
-            $component = "plagiarism_{$plugin}";
-            $classname = manager::get_provider_classname_for_component($component);
-            if (static::component_implements($classname, plagiarism_provider::class)) {
-                // This plagiarism plugin implements the plagiarism_provider.
-                component_class_callback($classname, $methodname, $params);
-            }
-        }
+        \core_privacy\manager::plugintype_class_callback('plagiarism', plagiarism_provider::class, $methodname, $params);
     }
 }
index 21f21c2..f9686d4 100644 (file)
@@ -83,23 +83,6 @@ class provider implements
         static::call_plugin_method('delete_portfolio_for_user', [$userid, $context]);
     }
 
-    /**
-     * Checks whether the component's provider class implements the specified interface.
-     * This can either be implemented directly, or by implementing a descendant (extension) of the specified interface.
-     *
-     * @param string $providerclass the provider class name.
-     * @param string $interface the name of the interface we want to check.
-     * @return bool True if an implementation was found, false otherwise.
-     */
-    protected static function component_implements(string $providerclass, string $interface) : bool {
-        if (class_exists($providerclass)) {
-            $rc = new \ReflectionClass($providerclass);
-            return $rc->implementsInterface($interface);
-        }
-
-        return false;
-    }
-
     /**
      * Internal method for looping through all of the portfolio plugins and calling a method.
      *
@@ -108,14 +91,6 @@ class provider implements
      */
     protected static function call_plugin_method($methodname, $params) {
         // Note: Even if portfolio is _now_ disabled, there may be legacy data to export.
-        $plugins = \core_component::get_plugin_list('portfolio');
-        foreach (array_keys($plugins) as $plugin) {
-            $component = "portfolio_{$plugin}";
-            $classname = manager::get_provider_classname_for_component($component);
-            if (static::component_implements($classname, portfolio_provider::class)) {
-                // This portfolio plugin implements the portfolio_provider.
-                component_class_callback($classname, $methodname, $params);
-            }
-        }
+        \core_privacy\manager::plugintype_class_callback('portfolio', portfolio_provider::class, $methodname, $params);
     }
 }
index 34a3bd1..48c3865 100644 (file)
@@ -64,7 +64,7 @@ class transform {
      * @return  string      The translated string.
      */
     public static function date($date) {
-        return userdate($date, get_string('strftimetime', 'langconfig'));
+        return userdate($date, get_string('strftimedate', 'langconfig'));
     }
 
     /**
index ac831fd..a1acc90 100644 (file)
@@ -45,6 +45,11 @@ class default_handler extends handler {
      */
     public static function is_defined($forguests = false) {
         global $CFG;
+        if (!empty($CFG->sitepolicyhandler)) {
+            // This handler can also be used as a fallback in case of invalid $CFG->sitepolicyhandler,
+            // in this case assume that no site policy is set.
+            return false;
+        }
         if (!$forguests) {
             return !empty($CFG->sitepolicy);
         } else {
@@ -76,6 +81,11 @@ class default_handler extends handler {
      */
     public static function get_embed_url($forguests = false) {
         global $CFG;
+        if (!empty($CFG->sitepolicyhandler)) {
+            // This handler can also be used as a fallback in case of invalid $CFG->sitepolicyhandler,
+            // in this case assume that no site policy is set.
+            return null;
+        }
         if ($forguests && !empty($CFG->sitepolicyguest)) {
             return new moodle_url($CFG->sitepolicyguest);
         } else if (!$forguests && !empty($CFG->sitepolicy)) {
index aa11c30..5b68613 100644 (file)
@@ -61,10 +61,17 @@ class manager {
      */
     public function get_handler_classname() {
         global $CFG;
+
         if (!empty($CFG->sitepolicyhandler)) {
             $sitepolicyhandlers = $this->get_all_handlers();
-            $classname = $sitepolicyhandlers[$CFG->sitepolicyhandler];
-            return $classname;
+
+            if (!isset($sitepolicyhandlers[$CFG->sitepolicyhandler])) {
+                return default_handler::class;
+
+            } else {
+                return $sitepolicyhandlers[$CFG->sitepolicyhandler];
+            }
+
         } else {
             return default_handler::class;
         }
index 04ae625..922930a 100644 (file)
@@ -339,4 +339,37 @@ class manager {
         }
         return false;
     }
+
+    /**
+     * Call the named method with the specified params on any plugintype implementing the relevant interface.
+     *
+     * @param   string  $plugintype The plugingtype to check
+     * @param   string  $interface The interface to implement
+     * @param   string  $methodname The method to call
+     * @param   array   $params The params to call
+     */
+    public static function plugintype_class_callback(string $plugintype, string $interface, string $methodname, array $params) {
+        $components = \core_component::get_plugin_list($plugintype);
+        foreach (array_keys($components) as $component) {
+            static::component_class_callback("{$plugintype}_{$component}", $interface, $methodname, $params);
+        }
+    }
+
+    /**
+     * Call the named method with the specified params on the supplied component if it implements the relevant interface on its provider.
+     *
+     * @param   string  $component The component to call
+     * @param   string  $interface The interface to implement
+     * @param   string  $methodname The method to call
+     * @param   array   $params The params to call
+     * @return  mixed
+     */
+    public static function component_class_callback(string $component, string $interface, string $methodname, array $params) {
+        $classname = static::get_provider_classname_for_component($component);
+        if (class_exists($classname) && is_subclass_of($classname, $interface)) {
+            return component_class_callback($classname, $methodname, $params);
+        }
+
+        return null;
+    }
 }
index 15876ad..3926f5f 100644 (file)
@@ -53,14 +53,37 @@ class request_transform_test extends advanced_testcase {
      * Test that the datetime is translated into a string.
      */
     public function test_datetime() {
-        $this->assertInternalType('string', transform::datetime(1));
+        $time = 1;
+
+        $datestr = transform::datetime($time);
+
+        // Assert it is a string.
+        $this->assertInternalType('string', $datestr);
+
+        // To prevent failures on MAC where we are returned with a lower-case 'am' we want to convert this to 'AM'.
+        $datestr = str_replace('am', 'AM', $datestr);
+
+        // Assert the formatted date is correct.
+        $dateobj = new DateTime();
+        $dateobj->setTimestamp($time);
+        $this->assertEquals($dateobj->format('l, j F Y, g:i A'), $datestr);
     }
 
     /**
      * Test that the date is translated into a string.
      */
     public function test_date() {
-        $this->assertInternalType('string', transform::date(1));
+        $time = 1;
+
+        $datestr = transform::date($time);
+
+        // Assert it is a string.
+        $this->assertInternalType('string', $datestr);
+
+        // Assert the formatted date is correct.
+        $dateobj = new DateTime();
+        $dateobj->setTimestamp($time);
+        $this->assertEquals($dateobj->format('j F Y'), $datestr);
     }
 
     /**
index 37dd4af..3589829 100644 (file)
@@ -37,6 +37,29 @@ global $CFG;
  */
 class sitepolicy_test extends advanced_testcase {
 
+
+    /**
+     * Tests for \core_privacy\local\sitepolicy\manager::get_handler_classname() behaviour.
+     */
+    public function test_get_handler_classname() {
+        global $CFG;
+        $this->resetAfterTest(true);
+
+        $manager = $this->get_mock_manager_with_handler();
+
+        // If no handler is specified, then we should get the default one.
+        $CFG->sitepolicyhandler = '';
+        $this->assertEquals($manager->get_handler_classname(), \core_privacy\local\sitepolicy\default_handler::class);
+
+        // If non-existing handler is specified, we should get the default one too.
+        $CFG->sitepolicyhandler = 'non_existing_plugin_which_i_really_hope_will_never_exist';
+        $this->assertEquals($manager->get_handler_classname(), \core_privacy\local\sitepolicy\default_handler::class);
+
+        // If the defined handler is among known handlers, we should get its class name.
+        $CFG->sitepolicyhandler = 'testtool_testhandler';
+        $this->assertEquals($manager->get_handler_classname(), 'mock_sitepolicy_handler');
+    }
+
     /**
      * Tests for \core_privacy\local\sitepolicy\manager::is_defined()
      */
@@ -60,6 +83,13 @@ class sitepolicy_test extends advanced_testcase {
         $CFG->sitepolicy = null;
         $this->assertTrue($manager->is_defined(true));
         $this->assertFalse($manager->is_defined(false));
+
+        // When non existing plugin is set as $CFG->sitepolicyhandler, assume that $CFG->sitepolicy* are all not set.
+        $CFG->sitepolicy = 'http://example.com/sitepolicy.html';
+        $CFG->sitepolicyguest = 'http://example.com/sitepolicyguest.html';
+        $CFG->sitepolicyhandler = 'non_existing_plugin_which_i_really_hope_will_never_exist';
+        $this->assertFalse($manager->is_defined(true));
+        $this->assertFalse($manager->is_defined(false));
     }
 
     /**
@@ -85,6 +115,13 @@ class sitepolicy_test extends advanced_testcase {
         $CFG->sitepolicy = null;
         $this->assertEquals($CFG->wwwroot.'/user/policy.php', $manager->get_redirect_url(true)->out(false));
         $this->assertEquals(null, $manager->get_redirect_url(false));
+
+        // When non existing plugin is set as $CFG->sitepolicyhandler, assume that $CFG->sitepolicy* are all not set.
+        $CFG->sitepolicy = 'http://example.com/sitepolicy.html';
+        $CFG->sitepolicyguest = 'http://example.com/sitepolicyguest.html';
+        $CFG->sitepolicyhandler = 'non_existing_plugin_which_i_really_hope_will_never_exist';
+        $this->assertEquals(null, $manager->get_redirect_url(true));
+        $this->assertEquals(null, $manager->get_redirect_url(false));
     }
 
     /**
@@ -110,6 +147,13 @@ class sitepolicy_test extends advanced_testcase {
         $CFG->sitepolicy = null;
         $this->assertEquals($CFG->sitepolicyguest, $manager->get_embed_url(true)->out(false));
         $this->assertEquals(null, $manager->get_embed_url(false));
+
+        // When non existing plugin is set as $CFG->sitepolicyhandler, assume that $CFG->sitepolicy* are all not set.
+        $CFG->sitepolicy = 'http://example.com/sitepolicy.html';
+        $CFG->sitepolicyguest = 'http://example.com/sitepolicyguest.html';
+        $CFG->sitepolicyhandler = 'non_existing_plugin_which_i_really_hope_will_never_exist';
+        $this->assertEquals(null, $manager->get_embed_url(true));
+        $this->assertEquals(null, $manager->get_embed_url(false));
     }
 
     /**
@@ -138,6 +182,15 @@ class sitepolicy_test extends advanced_testcase {
         $this->assertTrue($manager->accept());
         $this->assertEquals(1, $USER->policyagreed);
         $this->assertEquals(1, $DB->get_field('user', 'policyagreed', ['id' => $USER->id]));
+
+        // When non existing plugin is set as $CFG->sitepolicyhandler, assume that $CFG->sitepolicy* are all not set.
+        $CFG->sitepolicy = 'http://example.com/sitepolicy.html';
+        $CFG->sitepolicyhandler = 'non_existing_plugin_which_i_really_hope_will_never_exist';
+        $user3 = $this->getDataGenerator()->create_user();
+        $this->setUser($user3);
+        $this->assertEquals(0, $USER->policyagreed);
+        $this->assertFalse($manager->accept());
+        $this->assertEquals(0, $USER->policyagreed);
     }
 
     /**
@@ -162,6 +215,13 @@ class sitepolicy_test extends advanced_testcase {
         $this->assertTrue($manager->accept());
         $this->assertEquals(1, $USER->policyagreed);
         $this->assertEquals(0, $DB->get_field('user', 'policyagreed', ['id' => $USER->id]));
+
+        // When non existing plugin is set as $CFG->sitepolicyhandler, assume that $CFG->sitepolicy* are all not set.
+        $USER->policyagreed = 0; // Reset.
+        $CFG->sitepolicyguest = 'http://example.com/sitepolicyguest.html';
+        $CFG->sitepolicyhandler = 'non_existing_plugin_which_i_really_hope_will_never_exist';
+        $this->assertFalse($manager->accept());
+        $this->assertEquals(0, $USER->policyagreed);
     }
 
     /**
index b405647..6d569cf 100644 (file)
@@ -49,12 +49,8 @@ class provider implements
      * @return  collection A listing of user data stored through this system.
      */
     public static function get_metadata(collection $collection) : collection {
-        $collection->add_database_table('user_private_key', [
-                'value' => 'privacy:metadata:user_private_key:value',
-                'userid' => 'privacy:metadata:user_private_key:userid',
-                'validuntil' => 'privacy:metadata:user_private_key:validuntil',
-                'timecreated' => 'privacy:metadata:user_private_key:timecreated'
-            ], 'privacy:metadata:user_private_key');
+        $collection->add_subsystem_link('core_userkey', [], 'privacy:metadata:core_userkey');
+
         return $collection;
     }
 
@@ -84,25 +80,20 @@ class provider implements
      * @param approved_contextlist $contextlist The approved contexts to export information for.
      */
     public static function export_user_data(approved_contextlist $contextlist) {
-        $results = static::get_records($contextlist->get_user()->id);
-        $context = $contextlist->current();
-        if ($context->contextlevel == CONTEXT_USER) {
-            foreach ($results as $result) {
-                $context = \context_user::instance($result->userid);
-                $subcontext = [
-                    get_string('rss', 'rss'),
-                    transform::user($result->userid)
-                ];
-                $name = 'user_private_key-' . $result->id;
-                $data = (object)[
-                    'value' => $result->value,
-                    'iprestriction' => $result->iprestriction,
-                    'validuntil' => $result->validuntil,
-                    'timecreated' => transform::datetime($result->timecreated),
-                ];
-                writer::with_context($context)->export_related_data($subcontext, $name, $data);
-            }
+        // If the user has data, then only the CONTEXT_USER should be present so get the first context.
+        $contexts = $contextlist->get_contexts();
+        if (count($contexts) == 0) {
+            return;
+        }
+        $context = reset($contexts);
+
+        // Sanity check that context is at the user context level, then get the userid.
+        if ($context->contextlevel !== CONTEXT_USER) {
+            return;
         }
+
+        // Export associated userkeys.
+        \core_userkey\privacy\provider::export_userkeys($context, [], 'rss');
     }
 
     /**
@@ -111,7 +102,15 @@ class provider implements
      * @param context $context A user context.
      */
     public static function delete_data_for_all_users_in_context(\context $context) {
-        // The information in user_private_key table is removed automaticaly when a user is deteled.
+        // Sanity check that context is at the user context level, then get the userid.
+        if ($context->contextlevel !== CONTEXT_USER) {
+            return;
+        }
+        $userid = $context->instanceid;
+
+        // Delete all the userkeys.
+        \core_userkey\privacy\provider::delete_userkeys('rss', $userid);
+
     }
 
     /**
@@ -120,18 +119,19 @@ class provider implements
      * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
      */
     public static function delete_data_for_user(approved_contextlist $contextlist) {
-        // The information in user_private_key table is removed automaticaly when a user is deteled.
-    }
-
-    /**
-     * Get records related to this plugin and user.
-     *
-     * @param  int $userid The user ID
-     * @return array An array of records.
-     */
-    protected static function get_records(int $userid) : array {
-        global $DB;
+        // If the user has 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);
 
-        return $DB->get_records('user_private_key', ['userid' => $userid, 'script' => 'rss']);
+        // Sanity check that context is at the user context level, then get the userid.
+        if ($context->contextlevel !== CONTEXT_USER) {
+            return;
+        }
+        $userid = $context->instanceid;
+        // Delete all the userkeys.
+        \core_userkey\privacy\provider::delete_userkeys('rss', $userid);
     }
 }
index c3cc703..1fc46d4 100644 (file)
@@ -26,6 +26,7 @@ defined('MOODLE_INTERNAL') || die();
 use \core_privacy\tests\provider_testcase;
 use \core_rss\privacy\provider;
 use \core_privacy\local\request\writer;
+use \core_privacy\local\request\approved_contextlist;
 
 /**
  * Unit tests for rss\classes\privacy\provider.php
@@ -46,6 +47,7 @@ class core_rss_testcase extends provider_testcase {
      * Test getting the context for the user ID related to this plugin.
      */
     public function test_get_contexts_for_userid() {
+        // Create user and RSS user keys.
         $user = $this->getDataGenerator()->create_user();
         $context = \context_user::instance($user->id);
         $key = get_user_key('rss', $user->id);
@@ -60,15 +62,69 @@ class core_rss_testcase extends provider_testcase {
     public function test_export_user_data() {
         global $DB;
 
+        // Create user and RSS user keys.
         $user = $this->getDataGenerator()->create_user();
         $context = \context_user::instance($user->id);
         $keyvalue = get_user_key('rss', $user->id);
         $key = $DB->get_record('user_private_key', ['value' => $keyvalue]);
 
+        // Validate exported data.
+        $this->setUser($user);
         $writer = writer::with_context($context);
         $this->assertFalse($writer->has_any_data());
         $this->export_context_data_for_user($user->id, $context, 'core_rss');
-        $data = $writer->get_related_data([get_string('rss', 'rss'), $user->id], 'user_private_key-' . $key->id);
-        $this->assertEquals($key->value, $data->value);
+        $userkeydata = $writer->get_related_data([], 'userkeys');
+        $this->assertCount(1, $userkeydata->keys);
+        $this->assertEquals($key->script, reset($userkeydata->keys)->script);
+    }
+
+    /**
+     * Test for provider::delete_data_for_all_users_in_context().
+     */
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+
+        // Create user and RSS user keys.
+        $user = $this->getDataGenerator()->create_user();
+        $context = \context_user::instance($user->id);
+        $keyvalue = get_user_key('rss', $user->id);
+        $key = $DB->get_record('user_private_key', ['value' => $keyvalue]);
+
+        // Before deletion, we should have 1 user_private_key.
+        $count = $DB->count_records('user_private_key', ['script' => 'rss']);
+        $this->assertEquals(1, $count);
+
+        // Delete data.
+        provider::delete_data_for_all_users_in_context($context);
+
+        // After deletion, the user_private_key entries should have been deleted.
+        $count = $DB->count_records('user_private_key', ['script' => 'rss']);
+        $this->assertEquals(0, $count);
+    }
+
+    /**
+     * Test for provider::delete_data_for_user().
+     */
+    public function test_delete_data_for_user() {
+        global $DB;
+
+        // Create user and RSS user keys.
+        $user = $this->getDataGenerator()->create_user();
+        $context = \context_user::instance($user->id);
+        $keyvalue = get_user_key('rss', $user->id);
+        $key = $DB->get_record('user_private_key', ['value' => $keyvalue]);
+
+        // Before deletion, we should have 1 user_private_key.
+        $count = $DB->count_records('user_private_key', ['script' => 'rss']);
+        $this->assertEquals(1, $count);
+
+        // Delete data.
+        $contextlist = provider::get_contexts_for_userid($user->id);
+        $approvedcontextlist = new approved_contextlist($user, 'rss', $contextlist->get_contextids());
+        provider::delete_data_for_user($approvedcontextlist);
+
+        // After deletion, the user_private_key entries should have been deleted.
+        $count = $DB->count_records('user_private_key', ['script' => 'rss']);
+        $this->assertEquals(0, $count);
     }
 }
diff --git a/search/amd/build/form-search-user-selector.min.js b/search/amd/build/form-search-user-selector.min.js
new file mode 100644 (file)
index 0000000..a6eee77
Binary files /dev/null and b/search/amd/build/form-search-user-selector.min.js differ
diff --git a/search/amd/src/form-search-user-selector.js b/search/amd/src/form-search-user-selector.js
new file mode 100644 (file)
index 0000000..fc694d3
--- /dev/null
@@ -0,0 +1,82 @@
+// 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/>.
+
+/**
+ * Search user selector module.
+ *
+ * @module core_search/form-search-user-selector
+ * @class form-search-user-selector
+ * @package core_search
+ * @copyright 2017 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define(['jquery', 'core/ajax', 'core/templates'], function($, Ajax, Templates) {
+
+    return /** @alias module:core_search/form-search-user-selector */ {
+
+        processResults: function(selector, results) {
+            var users = [];
+            $.each(results, function(index, user) {
+                users.push({
+                    value: user.id,
+                    label: user._label
+                });
+            });
+            return users;
+        },
+
+        transport: function(selector, query, success, failure) {
+            var promise;
+
+            // Search within specific course if known and if the 'search within' dropdown is set
+            // to search within course or activity.
+            var args = {query: query};
+            var courseid = $(selector).attr('withincourseid');
+            if (typeof courseid !== "undefined" && $('#id_searchwithin').val() !== '') {
+                args.courseid = courseid;
+            } else {
+                args.courseid = 0;
+            }
+
+            // Call AJAX request.
+            promise = Ajax.call([{methodname: 'core_search_get_relevant_users', args: args}]);
+
+            // When AJAX request returns, handle the results.
+            promise[0].then(function(results) {
+                var promises = [];
+
+                // Render label with user name and picture.
+                $.each(results, function(index, user) {
+                    promises.push(Templates.render('core_search/form-user-selector-suggestion', user));
+                });
+
+                // Apply the label to the results.
+                return $.when.apply($.when, promises).then(function() {
+                    var args = arguments;
+                    var i = 0;
+                    $.each(results, function(index, user) {
+                        user._label = args[i++];
+                    });
+                    success(results);
+                    return;
+                });
+
+            }).fail(failure);
+        }
+
+    };
+
+});
index 1fea314..0808066 100644 (file)
@@ -291,6 +291,9 @@ class document implements \renderable, \templatable {
         if ($fielddata['type'] === 'int' || $fielddata['type'] === 'tdate') {
             $this->data[$fieldname] = intval($value);
         } else {
+            // Remove disallowed Unicode characters.
+            $value = \core_text::remove_unicode_non_characters($value);
+
             // Replace all groups of line breaks and spaces by single spaces.
             $this->data[$fieldname] = preg_replace("/\s+/u", " ", $value);
             if ($this->data[$fieldname] === null) {
index 433b994..3b62075 100644 (file)
@@ -589,4 +589,17 @@ abstract class engine {
     public function get_supported_orders(\context $context) {
         return ['relevance' => get_string('order_relevance', 'search')];
     }
+
+    /**
+     * Checks if the search engine supports searching by user.
+     *
+     * If it returns true to this function, the search engine should support the 'userids' option
+     * in the $filters value passed to execute_query(), returning only items where the userid in
+     * the search document matches one of those user ids.
+     *
+     * @return bool True if the search engine supports searching by user
+     */
+    public function supports_users() {
+        return false;
+    }
 }
diff --git a/search/classes/external.php b/search/classes/external.php
new file mode 100644 (file)
index 0000000..ce29bb2
--- /dev/null
@@ -0,0 +1,119 @@
+<?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/>.
+
+/**
+ * Handles external (web service) function calls related to search.
+ *
+ * @package core_search
+ * @copyright 2017 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_search;
+
+use core_user\external\user_summary_exporter;
+use \external_value;
+use \external_single_structure;
+use \external_multiple_structure;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/externallib.php');
+
+/**
+ * Handles external (web service) function calls related to search.
+ *
+ * @package core_search
+ * @copyright 2017 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class external extends \external_api {
+    /**
+     * Returns parameter types for get_relevant_users function.
+     *
+     * @return \external_function_parameters Parameters
+     */
+    public static function get_relevant_users_parameters() {
+        return new \external_function_parameters([
+                'query' => new external_value(PARAM_RAW,
+                    'Query string (full or partial user full name or other details)'),
+                'courseid' => new external_value(PARAM_INT, 'Course id (0 if none)'),
+                ]);
+    }
+
+    /**
+     * Returns result type for get_relevant_users function.
+     *
+     * @return \external_description Result type
+     */
+    public static function get_relevant_users_returns() {
+        return new external_multiple_structure(
+                new external_single_structure([
+                    'id' => new external_value(PARAM_INT, 'User id'),
+                    'fullname' => new external_value(PARAM_RAW, 'Full name as text'),
+                    'profileimageurlsmall' => new external_value(PARAM_URL, 'URL to small profile image')
+                ]));
+    }
+
+    /**
+     * Searches for users given a query, taking into account the current user's permissions and
+     * possibly a course to check within.
+     *
+     * @param string $query Query text
+     * @param int $courseid Course id or 0 if no restriction
+     * @return array Defined return structure
+     */
+    public static function get_relevant_users($query, $courseid) {
+        global $CFG, $PAGE;
+
+        // Validate parameter.
+        self::validate_parameters(self::get_relevant_users_parameters(),
+                ['query' => $query, 'courseid' => $courseid]);
+
+        // Validate the context (search page is always system context).
+        $systemcontext = \context_system::instance();
+        self::validate_context($systemcontext);
+
+        // Get course object too.
+        if ($courseid) {
+            $coursecontext = \context_course::instance($courseid);
+        } else {
+            $coursecontext = null;
+        }
+
+        // If not logged in, can't see anyone when forceloginforprofiles is on.
+        if (!empty($CFG->forceloginforprofiles)) {
+            if (!isloggedin() || isguestuser()) {
+                return [];
+            }
+        }
+
+        $users = \core_user::search($query, $coursecontext);
+
+        $result = [];
+        foreach ($users as $user) {
+            // Get a standard exported user object.
+            $fulldetails = (new user_summary_exporter($user))->export($PAGE->get_renderer('core'));
+
+            // To avoid leaking private data to students, only include the specific information we
+            // are going to display (and not the email, idnumber, etc).
+            $result[] = (object)['id' => $fulldetails->id, 'fullname' => $fulldetails->fullname,
+                    'profileimageurlsmall' => $fulldetails->profileimageurlsmall];
+        }
+        return $result;
+    }
+}
index 0f5a7f9..9a9fccb 100644 (file)
@@ -720,6 +720,7 @@ class manager {
      * - contextids (optional list of context ids to restrict)
      * - context (Moodle context object for location user searched from)
      * - order (optional ordering, one of the types supported by the search engine e.g. 'relevance')
+     * - userids (optional list of user ids to restrict)
      *
      * @param \stdClass $formdata Query input data (usually from search form)
      * @param int $limit The maximum number of documents to return
index 1f4a3b6..8d633df 100644 (file)
@@ -27,6 +27,7 @@ namespace core_search\output\form;
 defined('MOODLE_INTERNAL') || die;
 
 require_once($CFG->libdir . '/formslib.php');
+require_once($CFG->libdir . '/externallib.php');
 
 class search extends \moodleform {
 
@@ -36,7 +37,7 @@ class search extends \moodleform {
      * @return void
      */
     function definition() {
-        global $USER;
+        global $USER, $DB, $OUTPUT;
 
         $mform =& $this->_form;
         $mform->disable_form_change_checker();
@@ -95,6 +96,31 @@ class search extends \moodleform {
         $mform->addElement('course', 'courseids', get_string('courses', 'core'), $options);
         $mform->setType('courseids', PARAM_INT);
 
+        // If the search engine can search by user, and the user is logged in (so we have
+        // permission to call the user-listing web service) then show the user selector.
+        if ($search->get_engine()->supports_users() && isloggedin()) {
+            $options = [
+                'ajax' => 'core_search/form-search-user-selector',
+                'multiple' => true,
+                'noselectionstring' => get_string('allusers', 'search'),
+                'valuehtmlcallback' => function($value) {
+                    global $DB, $OUTPUT;
+                    $user = $DB->get_record('user', ['id' => (int)$value], '*', IGNORE_MISSING);
+                    if (!$user || !user_can_view_profile($user)) {
+                        return false;
+                    }
+                    $details = user_get_user_details($user);
+                    return $OUTPUT->render_from_template(
+                            'core_search/form-user-selector-suggestion', $details);
+                }
+            ];
+            if (!empty($this->_customdata['withincourseid'])) {
+                $options['withincourseid'] = $this->_customdata['withincourseid'];
+            }
+
+            $mform->addElement('autocomplete', 'userids', get_string('users'), [], $options);
+        }
+
         if (!empty($this->_customdata['searchwithin'])) {
             // Course options should be hidden if we choose to search within a specific location.
             $mform->hideIf('courseids', 'searchwithin', 'ne', '');
index 27ccf7e..21199d4 100644 (file)
@@ -290,6 +290,9 @@ class engine extends \core_search\engine {
         if (!empty($data->groupids)) {
             $query->addFilterQuery('{!cache=false}groupid:(' . implode(' OR ', $data->groupids) . ')');
         }
+        if (!empty($data->userids)) {
+            $query->addFilterQuery('{!cache=false}userid:(' . implode(' OR ', $data->userids) . ')');
+        }
 
         if (!empty($data->timestart) or !empty($data->timeend)) {
             if (empty($data->timestart)) {
@@ -1399,4 +1402,13 @@ class engine extends \core_search\engine {
 
         return $orders;
     }
+
+    /**
+     * Solr supports search by user id.
+     *
+     * @return bool True
+     */
+    public function supports_users() {
+        return true;
+    }
 }
index 95240f1..5a7692f 100644 (file)
@@ -935,6 +935,80 @@ class search_solr_engine_testcase extends advanced_testcase {
                 ['F1NG', 'F1SG-A', 'F1SG-B', 'F1VG-A', 'F1VG-B', 'F2SG-A', 'F2SG-B'], $results);
     }
 
+    /**
+     * Tests searching for results restricted to specific user id(s).
+     */
+    public function test_user_restriction() {
+        // Use real search areas.
+        $this->search->clear_static();
+        $this->search->add_core_search_areas();
+
+        // Create a course, a forum, and a glossary.
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $forum = $generator->create_module('forum', ['course' => $course->id]);
+        $glossary = $generator->create_module('glossary', ['course' => $course->id]);
+
+        // Create 3 user accounts, all enrolled as students on the course.
+        $user1 = $generator->create_user();
+        $user2 = $generator->create_user();
+        $user3 = $generator->create_user();
+        $generator->enrol_user($user1->id, $course->id, 'student');
+        $generator->enrol_user($user2->id, $course->id, 'student');
+        $generator->enrol_user($user3->id, $course->id, 'student');
+
+        // All users create a forum discussion.
+        $forumgen = $generator->get_plugin_generator('mod_forum');
+        $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
+            'userid' => $user1->id, 'name' => 'Post1', 'message' => 'plugh']);
+        $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
+                'userid' => $user2->id, 'name' => 'Post2', 'message' => 'plugh']);
+        $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id,
+                'userid' => $user3->id, 'name' => 'Post3', 'message' => 'plugh']);
+
+        // Two of the users create entries in the glossary.
+        $glossarygen = $generator->get_plugin_generator('mod_glossary');
+        $glossarygen->create_content($glossary, ['concept' => 'Entry1', 'definition' => 'plugh',
+                'userid' => $user1->id]);
+        $glossarygen->create_content($glossary, ['concept' => 'Entry3', 'definition' => 'plugh',
+                'userid' => $user3->id]);
+
+        // Index the data.
+        $this->search->index();
+
+        // Search without user restriction should find everything.
+        $querydata = new stdClass();
+        $querydata->q = 'plugh';
+        $results = $this->search->search($querydata);
+        $this->assert_result_titles(
+                ['Entry1', 'Entry3', 'Post1', 'Post2', 'Post3'], $results);
+
+        // Restriction to user 3 only.
+        $querydata->userids = [$user3->id];
+        $results = $this->search->search($querydata);
+        $this->assert_result_titles(
+                ['Entry3', 'Post3'], $results);
+
+        // Restriction to users 1 and 2.
+        $querydata->userids = [$user1->id, $user2->id];
+        $results = $this->search->search($querydata);
+        $this->assert_result_titles(
+                ['Entry1', 'Post1', 'Post2'], $results);
+
+        // Restriction to users 1 and 2 combined with context restriction.
+        $querydata->contextids = [context_module::instance($glossary->cmid)->id];
+        $results = $this->search->search($querydata);
+        $this->assert_result_titles(
+                ['Entry1'], $results);
+
+        // Restriction to users 1 and 2 combined with area restriction.
+        unset($querydata->contextids);
+        $querydata->areaids = [\core_search\manager::generate_areaid('mod_forum', 'post')];
+        $results = $this->search->search($querydata);
+        $this->assert_result_titles(
+                ['Post1', 'Post2'], $results);
+    }
+
     /**
      * Asserts that the returned documents have the expected titles (regardless of order).
      *
@@ -1060,6 +1134,43 @@ class search_solr_engine_testcase extends advanced_testcase {
         $this->assertEquals('C1P', $results[0]->get('title'));
     }
 
+    /**
+     * Tests with bogus content (that can be entered into Moodle) to see if it crashes.
+     */
+    public function test_bogus_content() {
+        $generator = $this->getDataGenerator();
+        $course1 = $generator->create_course(['fullname' => 'Course 1']);
+        $course1context = \context_course::instance($course1->id);
+
+        // It is possible to enter into a Moodle database content containing these characters,
+        // which are Unicode non-characters / byte order marks. If sent to Solr, these cause
+        // failures.
+        $boguscontent = html_entity_decode('&#xfffe;') . 'frog';
+        $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
+        $boguscontent = html_entity_decode('&#xffff;') . 'frog';
+        $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
+
+        // Unicode Standard Version 9.0 - Core Specification, section 23.7, lists 66 non-characters
+        // in total. Here are some of them - these work OK for me but it may depend on platform.
+        $boguscontent = html_entity_decode('&#xfdd0;') . 'frog';
+        $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
+        $boguscontent = html_entity_decode('&#xfdef;') . 'frog';
+        $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
+        $boguscontent = html_entity_decode('&#x1fffe;') . 'frog';
+        $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
+        $boguscontent = html_entity_decode('&#x10ffff;') . 'frog';
+        $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent);
+
+        // Do the indexing (this will check it doesn't throw warnings).
+        $this->search->index();
+
+        // Confirm that all 6 documents are found in search.
+        $querydata = new stdClass();
+        $querydata->q = 'frog';
+        $results = $this->search->search($querydata);
+        $this->assertCount(6, $results);
+    }
+
     /**
      * Adds a record to the mock search area, so that the search engine can find it later.
      *
diff --git a/search/templates/form-user-selector-suggestion.mustache b/search/templates/form-user-selector-suggestion.mustache
new file mode 100644 (file)
index 0000000..08a5912
--- /dev/null
@@ -0,0 +1,41 @@
+{{!
+    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_search/form-user-selector-suggestion
+
+    Moodle template for the list of valid options in an autocomplate form element.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * profileimageurlsmall Url to a small profile image.
+    * fullname Users full name
+
+    Example context (json):
+    { "id": "1",
+      "fullname": "Kermit Thefrog",
+      "profileimageurlsmall": "https://example.org/img"
+    }
+}}
+<span>
+    <img height="12" src="{{profileimageurlsmall}}" alt="" role="presentation">
+    <span>{{fullname}}</span>
+</span>
diff --git a/search/tests/behat/search_by_user.feature b/search/tests/behat/search_by_user.feature
new file mode 100644 (file)
index 0000000..37f7f13
--- /dev/null
@@ -0,0 +1,67 @@
+@core @core_search
+Feature: Select users when searching for user-created content
+  In order to search for content by specific users
+  As a user
+  I need to be able to add users to the select list in the search form
+
+  Background:
+    Given the following config values are set as admin:
+      | enableglobalsearch | 1    |
+      | searchengine       | solr |
+    And the following "courses" exist:
+      | shortname | fullname |
+      | C1        | Frogs    |
+      | C2        | Zombies  |
+    And the following "activities" exist:
+      | activity | name       | intro      | course | idnumber |
+      | page     | PageName1  | PageDesc1  | C1     | PAGE1    |
+    And the following "users" exist:
+      | username | firstname | lastname   |
+      | s1       | Anne      | Other      |
+      | s2       | Anne      | Additional |
+      | t        | Anne      | Ditin      |
+    And the following "course enrolments" exist:
+      | user | course | role    |
+      | s1   | C1     | student |
+      | s2   | C2     | student |
+      | t    | C1     | teacher |
+
+  @javascript
+  Scenario: As administrator, search for users from home page
+    Given I log in as "admin"
+    And global search expects the query "frogs" and will return:
+      | type     | idnumber |
+      | activity | PAGE1    |
+    And I search for "frogs" using the header global search box
+    And I expand all fieldsets
+    And I set the field with xpath "//select[@id='id_userids']/../input[@type='text']" to "Anne"
+    # Alphabetical surname order.
+    Then "Anne Additional" "text" should appear before "Anne Ditin" "text"
+    And "Anne Ditin" "text" should appear before "Anne Other" "text"
+
+  @javascript
+  Scenario: As administrator, search for users within course
+    Given I log in as "admin"
+    And I am on "Frogs" course homepage
+    And global search expects the query "frogs" and will return:
+      | type     | idnumber |
+      | activity | PAGE1    |
+    And I search for "frogs" using the header global search box
+    And I expand all fieldsets
+    And I select "Course: Frogs" from the "Search within" singleselect
+    And I set the field with xpath "//select[@id='id_userids']/../input[@type='text']" to "Anne"
+    # Users in selected course appear first.
+    And "Anne Additional" "text" should appear after "Anne Other" "text"
+
+  @javascript
+  Scenario: As student, cannot see users on other courses
+    Given I log in as "s1"
+    And I am on "Frogs" course homepage
+    And global search expects the query "frogs" and will return:
+      | type     | idnumber |
+      | activity | PAGE1    |
+    And I search for "frogs" using the header global search box
+    And I expand all fieldsets
+    And I set the field with xpath "//select[@id='id_userids']/../input[@type='text']" to "A"
+    Then "Anne Ditin" "text" should appear before "Anne Other" "text"
+    And "Anne Additional" "text" should not exist
diff --git a/search/tests/external_test.php b/search/tests/external_test.php
new file mode 100644 (file)
index 0000000..fd4fc41
--- /dev/null
@@ -0,0 +1,72 @@
+<?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/>.
+
+/**
+ * External function unit tests.
+ *
+ * @package core_search
+ * @copyright 2017 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_search;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * External function unit tests.
+ *
+ * @package core_search
+ * @copyright 2017 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class external_testcase extends \advanced_testcase {
+
+    public function setUp() {
+        $this->resetAfterTest();
+    }
+
+    /**
+     * Checks the get_relevant_users function used when selecting users in search filter.
+     */
+    public function test_get_relevant_users() {
+        // Set up two users to search for and one to do the searching.
+        $generator = $this->getDataGenerator();
+        $student1 = $generator->create_user(['firstname' => 'Amelia', 'lastname' => 'Aardvark']);
+        $student2 = $generator->create_user(['firstname' => 'Amelia', 'lastname' => 'Beetle']);
+        $student3 = $generator->create_user(['firstname' => 'Zebedee', 'lastname' => 'Boing']);
+        $course = $generator->create_course();
+        $generator->enrol_user($student1->id, $course->id, 'student');
+        $generator->enrol_user($student2->id, $course->id, 'student');
+        $generator->enrol_user($student3->id, $course->id, 'student');
+
+        // As student 3, search for the other two.
+        $this->setUser($student3);
+        $result = external::get_relevant_users('Amelia', 0);
+        $this->assertCount(2, $result);
+
+        // Check that the result contains all the expected fields.
+        $this->assertEquals($student1->id, $result[0]->id);
+        $this->assertEquals('Amelia Aardvark', $result[0]->fullname);
+        $this->assertContains('/u/f2', $result[0]->profileimageurlsmall);
+
+        // Check we aren't leaking information about user email address (for instance).
+        $this->assertObjectNotHasAttribute('email', $result[0]);
+
+        // Note: We are not checking search permissions, search by different fields, etc. as these
+        // are covered by the core_user::search unit test.
+    }
+}
index 5cf2eeb..b355730 100644 (file)
@@ -36,6 +36,11 @@ information provided here is intended especially for developers.
 * API function \core_search\manager::instance() now includes a $fast parameter to skip schema
   checks (as above).
 
+* Search engines should now implement the 'userids' option to restrict search results to those from
+  specific users, and return true to the new supports_users() function. The supplied Solr search
+  engine includes this feature. If this is not implemented, the search engine will continue to work
+  but the 'Users' search option will not appear.
+
 === 3.4 ===
 
 * Search indexing now supports time limits to make the scheduled task run more neatly. In order for
index 09e0791..1914157 100644 (file)
@@ -199,6 +199,30 @@ $doughnut-fill-colour: $brand-warning;
     }
 }
 
+.card-deck {
+    .card {
+        $card-gutter : $card-deck-margin * 2;
+        flex-grow: 0;
+        flex-shrink: 0;
+        flex-basis: calc(50% - #{$card-gutter});
+    }
+    .myoverviewimg {
+        height: 150px;
+        &.courseimage {
+            background-position: center;
+            background-size: cover;
+        }
+    }
+}
+
+@media (min-width: 1600px) {
+    .block_myoverview {
+        .courses-view-course-item {
+            height: 190px;
+        }
+    }
+}
+
 // Show expand collapse with font-awesome.
 .block_settings .block_tree [aria-expanded="true"],
 .block_settings .block_tree [aria-expanded="true"].emptybranch,
index cd16f7c..a7fea2c 100644 (file)
@@ -9,7 +9,7 @@
             {{/error}}
             {{{element.attributes}}} >
             {{#element.options}}
-            <option value="{{value}}" {{#selected}}selected{{/selected}}>{{{text}}}</option>
+            <option value="{{value}}" {{#selected}}selected{{/selected}}{{#html}} data-html="{{html}}"{{/html}}>{{{text}}}</option>
             {{/element.options}}
         </select>
         {{/element.frozen}}
index 68c70da..44c7038 100644 (file)
 .block_myoverview {
     background-color: transparent;
 
+    .row-fluid [class*="span"] {
+        margin-left: 0;
+    }
+
     .event-list-item {
         border-bottom: 1px solid @tableBorder;
         padding-bottom: 10px;
     .well {
         background-color: @white;
         box-shadow: none;
+        margin: 0 5px 10px;
+    }
+    .myoverviewimg {
+        height: 150px;
+        &.courseimage {
+            background-position: center;
+            background-size: cover;
+        }
     }
 }
index c09b834..0c97cf1 100644 (file)
@@ -16487,6 +16487,9 @@ body {
 .block_myoverview {
   background-color: transparent;
 }
+.block_myoverview .row-fluid [class*="span"] {
+  margin-left: 0;
+}
 .block_myoverview .event-list-item {
   border-bottom: 1px solid #ddd;
   padding-bottom: 10px;
@@ -16529,6 +16532,14 @@ body {
 .block_myoverview .well {
   background-color: #fff;
   box-shadow: none;
+  margin: 0 5px 10px;
+}
+.block_myoverview .myoverviewimg {
+  height: 150px;
+}
+.block_myoverview .myoverviewimg.courseimage {
+  background-position: center;
+  background-size: cover;
 }
 /**
  * Moodle forms HTML isn't changeable via renderers (yet?) so this
index 91ab279..75fe6c8 100644 (file)
@@ -42,5 +42,6 @@
         {{#courses}}
             {{> block_myoverview/courses-view-course-item }}
         {{/courses}}
+        <div class="clearfix"></div>
     {{/content}}
 {{/ block_myoverview/paging-content-item }}
index 233c36f..d9fd70e 100644 (file)
         "summary": "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout."
     }
 }}
-<div class="well well-small">
-    <div class="course-info-container" id="course-info-container-{{id}}">
-        <div class="media">
-            <div class="pull-left">
-                <div class="media-object">
-                    {{> block_myoverview/progress-chart}}
-                </div>
+<div class="span6">
+    <div class="well well-small">
+        <a href="{{viewurl}}">
+            <div class="myoverviewimg m-b-1 {{classes}}" style='background-image: url("{{{courseimage}}}");'>
             </div>
-            <div class="media-body">
-                <h4 class="media-heading">
-                    <a href="{{viewurl}}" class="{{^visible}}dimmed{{/visible}}">{{{fullnamedisplay}}}</a>
-                </h4>
+        </a>
+        <div class="course-info-container" id="course-info-container-{{id}}">
+            <div class="media">
+                <div class="pull-left">
+                    <div class="media-object">
+                        {{> block_myoverview/progress-chart}}
+                    </div>
+                </div>
+                <div class="media-body">
+                    <h4 class="media-heading">
+                        <a href="{{viewurl}}" class="{{^visible}}dimmed{{/visible}}">{{{fullnamedisplay}}}</a>
+                    </h4>
+                </div>
             </div>
+            <p class="text-muted">
+                {{#shortentext}} 140, {{{summary}}}{{/shortentext}}
+            </p>
         </div>
-        <p class="text-muted">
-            {{#shortentext}} 140, {{{summary}}