Merge branch 'install_master' of https://git.in.moodle.com/amosbot/moodle-install
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Fri, 19 Oct 2018 15:11:13 +0000 (17:11 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Fri, 19 Oct 2018 15:11:13 +0000 (17:11 +0200)
88 files changed:
admin/tool/xmldb/actions/edit_table/edit_table.class.php
admin/tool/xmldb/actions/edit_xml_file/edit_xml_file.class.php
admin/tool/xmldb/amd/build/move.min.js [new file with mode: 0644]
admin/tool/xmldb/amd/src/move.js [new file with mode: 0644]
admin/tool/xmldb/classes/external.php [new file with mode: 0644]
admin/tool/xmldb/db/services.php [new file with mode: 0644]
admin/tool/xmldb/styles_boost.css [new file with mode: 0644]
admin/tool/xmldb/version.php
admin/user/user_bulk_cohortadd.php
admin/user/user_bulk_delete.php
backup/moodle2/backup_stepslib.php
backup/moodle2/restore_stepslib.php
blocks/classes/external.php
blocks/tests/externallib_test.php
blocks/upgrade.txt
config-dist.php
course/moodleform_mod.php
enrol/externallib.php
enrol/tests/externallib_test.php
enrol/upgrade.txt
favourites/classes/local/entity/favourite.php [new file with mode: 0644]
favourites/classes/local/repository/favourite_repository.php [new file with mode: 0644]
favourites/classes/local/repository/favourite_repository_interface.php [new file with mode: 0644]
favourites/classes/local/service/user_favourite_service.php [new file with mode: 0644]
favourites/classes/privacy/provider.php [new file with mode: 0644]
favourites/classes/service_factory.php [new file with mode: 0644]
favourites/tests/privacy_test.php [new file with mode: 0644]
favourites/tests/repository_test.php [new file with mode: 0644]
favourites/tests/service_test.php [new file with mode: 0644]
grade/report/history/classes/output/tablelog.php
grade/report/user/lib.php
lang/en/admin.php
lang/en/bulkusers.php
lang/en/deprecated.txt
lang/en/favourites.php [new file with mode: 0644]
lang/en/message.php
lib/amd/build/templates.min.js
lib/amd/src/templates.js
lib/classes/component.php
lib/classes/event/message_deleted.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/filelib.php
lib/grade/constants.php
lib/grade/grade_grade.php
lib/grade/grade_item.php
lib/grade/grade_object.php
lib/gradelib.php
lib/moodlelib.php
lib/tests/component_test.php
lib/upgrade.txt
message/amd/build/message_preferences.min.js
message/amd/src/message_preferences.js
message/classes/api.php
message/externallib.php
message/lib.php
message/output/popup/tests/behat/message_popover_unread.feature
message/renderer.php
message/templates/message_preferences.mustache
message/tests/api_test.php
message/tests/behat/delete_all_messages.feature
message/tests/behat/delete_messages.feature
message/tests/behat/reply_message.feature
message/tests/behat/search_messages.feature
message/tests/behat/view_messages.feature
message/tests/events_test.php
message/tests/externallib_test.php
message/tests/privacy_provider_test.php
message/upgrade.txt
mod/assign/feedback/comments/backup/moodle2/backup_assignfeedback_comments_subplugin.class.php
mod/assign/feedback/comments/backup/moodle2/restore_assignfeedback_comments_subplugin.class.php
mod/assign/feedback/comments/classes/privacy/provider.php
mod/assign/feedback/comments/lang/en/assignfeedback_comments.php
mod/assign/feedback/comments/lib.php [new file with mode: 0644]
mod/assign/feedback/comments/locallib.php
mod/assign/feedback/comments/tests/privacy_test.php
mod/assign/feedbackplugin.php
mod/assign/locallib.php
mod/assign/tests/locallib_test.php
mod/assign/upgrade.txt
mod/forum/classes/privacy/provider.php
mod/forum/tests/privacy_provider_test.php
phpunit.xml.dist
user/profile/definelib.php
user/tests/behat/bulk_editenrolment.feature
user/tests/behat/delete_users.feature
version.php

index 32f6fea..00ed43a 100644 (file)
@@ -74,6 +74,7 @@ class edit_table extends XMLDBAction {
      * errormsg and output as necessary
      */
     function invoke() {
+        global $OUTPUT, $PAGE;
         parent::invoke();
 
         $result = true;
@@ -197,6 +198,11 @@ class edit_table extends XMLDBAction {
             $o .= '<table id="listfields" border="0" cellpadding="5" cellspacing="1" class="boxaligncenter flexible">';
             $row = 0;
             foreach ($fields as $field) {
+                // Drag element up/down.
+                $move = (count($fields) > 1) ? html_writer::span($OUTPUT->render_from_template('core/drag_handle',
+                    ['movetitle' => get_string('movecontent', 'moodle', $field->getName())]), '',
+                    ['data-action' => 'move_updown_field', 'data-dir' => str_replace($CFG->dirroot, '', $dirpath),
+                        'data-table' => $table->getName(), 'data-field' => $field->getName()]) : '';
                 // The field name (link to edit - if the field has no uses)
                 if (!$structure->getFieldUses($table->getName(), $field->getName())) {
                     $f = '<a href="index.php?action=edit_field&amp;field=' .$field->getName() . '&amp;table=' . $table->getName() . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">' . $field->getName() . '</a>';
@@ -212,20 +218,6 @@ class edit_table extends XMLDBAction {
                     $b .= '[' . $this->str['edit'] . ']';
                 }
                 $b .= '</td><td class="button cell">';
-                // The up button
-                if ($field->getPrevious()) {
-                    $b .= '<a href="index.php?action=move_updown_field&amp;direction=up&amp;sesskey=' . sesskey() . '&amp;field=' . $field->getName() . '&amp;table=' . $table->getName() . '&amp;postaction=edit_table' . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' . $this->str['up'] . ']</a>';
-                } else {
-                    $b .= '[' . $this->str['up'] . ']';
-                }
-                $b .= '</td><td class="button cell">';
-                // The down button
-                if ($field->getNext()) {
-                    $b .= '<a href="index.php?action=move_updown_field&amp;direction=down&amp;sesskey=' . sesskey() . '&amp;field=' . $field->getName() . '&amp;table=' . $table->getName() . '&amp;postaction=edit_table' . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' . $this->str['down'] . ']</a>';
-                } else {
-                    $b .= '[' . $this->str['down'] . ']';
-                }
-                $b .= '</td><td class="button cell">';
                 // The delete button (if we have more than one and it isn't used
                 if (count($fields) > 1 &&
                 !$structure->getFieldUses($table->getName(), $field->getName())) {
@@ -243,10 +235,12 @@ class edit_table extends XMLDBAction {
                 // The readable info
                 $r = '</td><td class="readableinfo cell">' . $field->readableInfo() . '</td>';
                 // Print table row
-                $o .= '<tr class="r' . $row . '"><td class="cell firstcol">' . $f . $b . $r . '</tr>';
+                $o .= '<tr class="r' . $row . '" data-name="' . s($field->getName()) . '"><td class="cell firstcol">' . $move .
+                    $f . $b . $r . '</tr>';
                 $row = ($row + 1) % 2;
             }
             $o .= '</table>';
+            $PAGE->requires->js_call_amd('tool_xmldb/move', 'init', ['listfields', 'move_updown_field']);
         }
         // Add the keys list
         $keys = $table->getKeys();
@@ -255,6 +249,11 @@ class edit_table extends XMLDBAction {
             $o .= '<table id="listkeys" border="0"  cellpadding="5" cellspacing="1" class="boxaligncenter flexible">';
             $row = 0;
             foreach ($keys as $key) {
+                // Drag element up/down.
+                $move = (count($keys) > 1) ? html_writer::span($OUTPUT->render_from_template('core/drag_handle',
+                    ['movetitle' => get_string('movecontent', 'moodle', $key->getName())]), '',
+                    ['data-action' => 'move_updown_key', 'data-dir' => str_replace($CFG->dirroot, '', $dirpath),
+                        'data-table' => $table->getName(), 'data-key' => $key->getName()]) : '';
                 // The key name (link to edit - if the key has no uses)
                 if (!$structure->getKeyUses($table->getName(), $key->getName())) {
                     $k = '<a href="index.php?action=edit_key&amp;key=' .$key->getName() . '&amp;table=' . $table->getName() . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">' . $key->getName() . '</a>';
@@ -270,20 +269,6 @@ class edit_table extends XMLDBAction {
                     $b .= '[' . $this->str['edit'] . ']';
                 }
                 $b .= '</td><td class="button cell">';
-                // The up button
-                if ($key->getPrevious()) {
-                    $b .= '<a href="index.php?action=move_updown_key&amp;direction=up&amp;sesskey=' . sesskey() . '&amp;key=' . $key->getName() . '&amp;table=' . $table->getName() . '&amp;postaction=edit_table' . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' . $this->str['up'] . ']</a>';
-                } else {
-                    $b .= '[' . $this->str['up'] . ']';
-                }
-                $b .= '</td><td class="button cell">';
-                // The down button
-                if ($key->getNext()) {
-                    $b .= '<a href="index.php?action=move_updown_key&amp;direction=down&amp;sesskey=' . sesskey() . '&amp;key=' . $key->getName() . '&amp;table=' . $table->getName() . '&amp;postaction=edit_table' . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' . $this->str['down'] . ']</a>';
-                } else {
-                    $b .= '[' . $this->str['down'] . ']';
-                }
-                $b .= '</td><td class="button cell">';
                 // The delete button (if the key hasn't uses)
                 if (!$structure->getKeyUses($table->getName(), $key->getName())) {
                     $b .= '<a href="index.php?action=delete_key&amp;sesskey=' . sesskey() . '&amp;key=' . $key->getName() . '&amp;table=' . $table->getName() . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' . $this->str['delete'] . ']</a>';
@@ -296,10 +281,12 @@ class edit_table extends XMLDBAction {
                 // The readable info
                 $r = '</td><td class="readableinfo cell">' . $key->readableInfo() . '</td>';
                 // Print table row
-                $o .= '<tr class="r' . $row . '"><td class="cell firstcol">' . $k . $b . $r .'</tr>';
+                $o .= '<tr class="r' . $row . '" data-name="' . s($key->getName()) . '"><td class="cell firstcol">' .
+                    $move . $k . $b . $r .'</tr>';
                 $row = ($row + 1) % 2;
             }
             $o .= '</table>';
+            $PAGE->requires->js_call_amd('tool_xmldb/move', 'init', ['listkeys', 'move_updown_key']);
         }
        // Add the indexes list
         $indexes = $table->getIndexes();
@@ -308,6 +295,11 @@ class edit_table extends XMLDBAction {
             $o .= '<table id="listindexes" border="0" cellpadding="5" cellspacing="1" class="boxaligncenter flexible">';
             $row = 0;
             foreach ($indexes as $index) {
+                // Drag element up/down.
+                $move = (count($indexes) > 1) ? html_writer::span($OUTPUT->render_from_template('core/drag_handle',
+                    ['movetitle' => get_string('movecontent', 'moodle', $index->getName())]), '',
+                    ['data-action' => 'move_updown_index', 'data-dir' => str_replace($CFG->dirroot, '', $dirpath),
+                        'data-table' => $table->getName(), 'data-index' => $index->getName()]) : '';
                 // The index name (link to edit)
                 $i = '<a href="index.php?action=edit_index&amp;index=' .$index->getName() . '&amp;table=' . $table->getName() . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">' . $index->getName() . '</a>';
                 // Calculate buttons
@@ -315,20 +307,6 @@ class edit_table extends XMLDBAction {
                 // The edit button
                 $b .= '<a href="index.php?action=edit_index&amp;index=' .$index->getName() . '&amp;table=' . $table->getName() . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' . $this->str['edit'] . ']</a>';
                 $b .= '</td><td class="button cell">';
-                // The up button
-                if ($index->getPrevious()) {
-                    $b .= '<a href="index.php?action=move_updown_index&amp;direction=up&amp;sesskey=' . sesskey() . '&amp;index=' . $index->getName() . '&amp;table=' . $table->getName() . '&amp;postaction=edit_table' . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' . $this->str['up'] . ']</a>';
-                } else {
-                    $b .= '[' . $this->str['up'] . ']';
-                }
-                $b .= '</td><td class="button cell">';
-                // The down button
-                if ($index->getNext()) {
-                    $b .= '<a href="index.php?action=move_updown_index&amp;direction=down&amp;sesskey=' . sesskey() . '&amp;index=' . $index->getName() . '&amp;table=' . $table->getName() . '&amp;postaction=edit_table' . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' . $this->str['down'] . ']</a>';
-                } else {
-                    $b .= '[' . $this->str['down'] . ']';
-                }
-                $b .= '</td><td class="button cell">';
                 // The delete button
                     $b .= '<a href="index.php?action=delete_index&amp;sesskey=' . sesskey() . '&amp;index=' . $index->getName() . '&amp;table=' . $table->getName() . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' . $this->str['delete'] . ']</a>';
                 $b .= '</td><td class="button cell">';
@@ -337,10 +315,12 @@ class edit_table extends XMLDBAction {
                 // The readable info
                 $r = '</td><td class="readableinfo cell">' . $index->readableInfo() . '</td>';
                 // Print table row
-                $o .= '<tr class="r' . $row . '"><td class="cell firstcol">' . $i . $b . $r .'</tr>';
+                $o .= '<tr class="r' . $row . '" data-name="' . s($index->getName()) . '"><td class="cell firstcol">' .
+                    $move . $i . $b . $r .'</tr>';
                 $row = ($row + 1) % 2;
             }
             $o .= '</table>';
+            $PAGE->requires->js_call_amd('tool_xmldb/move', 'init', ['listindexes', 'move_updown_index']);
         }
 
         $this->output = $o;
index f053c08..45df3aa 100644 (file)
@@ -70,6 +70,7 @@ class edit_xml_file extends XMLDBAction {
      * errormsg and output as necessary
      */
     function invoke() {
+        global $OUTPUT, $PAGE;
         parent::invoke();
 
         $result = true;
@@ -178,6 +179,11 @@ class edit_xml_file extends XMLDBAction {
                     $o .= '<table id="listtables" border="0" cellpadding="5" cellspacing="1" class="boxaligncenter flexible">';
                     $row = 0;
                     foreach ($tables as $table) {
+                        // Drag element for sortorder.
+                        $move = html_writer::span($OUTPUT->render_from_template('core/drag_handle',
+                            ['movetitle' => get_string('movecontent', 'moodle', $table->getName())]), '',
+                            ['data-action' => 'move_updown_table', 'data-dir' => str_replace($CFG->dirroot, '', $dirpath),
+                                'data-table' => $table->getName()]);
                         // The table name (link to edit table)
                         $t = '<a href="index.php?action=edit_table&amp;table=' . $table->getName() . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">' . $table->getName() . '</a>';
                         // Calculate buttons
@@ -185,20 +191,6 @@ class edit_xml_file extends XMLDBAction {
                         // The edit button
                         $b .= '<a href="index.php?action=edit_table&amp;table=' . $table->getName() . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' . $this->str['edit'] . ']</a>';
                         $b .= '</td><td class="button cell">';
-                        // The up button
-                        if ($table->getPrevious()) {
-                            $b .= '<a href="index.php?action=move_updown_table&amp;direction=up&amp;sesskey=' . sesskey() . '&amp;table=' . $table->getName() . '&amp;postaction=edit_xml_file' . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' . $this->str['up'] . ']</a>';
-                        } else {
-                            $b .= '[' . $this->str['up'] . ']';
-                        }
-                        $b .= '</td><td class="button cell">';
-                        // The down button
-                        if ($table->getNext()) {
-                            $b .= '<a href="index.php?action=move_updown_table&amp;direction=down&amp;sesskey=' . sesskey() . '&amp;table=' . $table->getName() . '&amp;postaction=edit_xml_file' . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' . $this->str['down'] . ']</a>';
-                        } else {
-                            $b .= '[' . $this->str['down'] . ']';
-                        }
-                        $b .= '</td><td class="button cell">';
                         // The delete button (if we have more than one and it isn't used)
                         if (count($tables) > 1 &&
                             !$structure->getTableUses($table->getName())) {
@@ -216,13 +208,16 @@ class edit_xml_file extends XMLDBAction {
                          }
                         $b .= '</td>';
                         // Print table row
-                        $o .= '<tr class="r' . $row . '"><td class="cell firstcol">' . $t . $b . '</tr>';
+                        $o .= '<tr class="r' . $row . '" data-name="' . s($table->getName()) . '"><td class="cell firstcol">' .
+                            (count($tables) > 1 ? $move : '') .
+                            $t . $b . '</tr>';
                         $row = ($row + 1) % 2;
                     }
                     $o .= '</table>';
                 }
-            // Add the back to main
-            $this->output = $o;
+                // Add the back to main.
+                $this->output = $o;
+                $PAGE->requires->js_call_amd('tool_xmldb/move', 'init', ['listtables', 'move_updown_table']);
             }
         }
 
diff --git a/admin/tool/xmldb/amd/build/move.min.js b/admin/tool/xmldb/amd/build/move.min.js
new file mode 100644 (file)
index 0000000..341413f
Binary files /dev/null and b/admin/tool/xmldb/amd/build/move.min.js differ
diff --git a/admin/tool/xmldb/amd/src/move.js b/admin/tool/xmldb/amd/src/move.js
new file mode 100644 (file)
index 0000000..34aca09
--- /dev/null
@@ -0,0 +1,54 @@
+// 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/>.
+
+define(['jquery', 'core/sortable_list', 'core/ajax', 'core/notification'], function($, SortableList, Ajax, Notification) {
+    return {
+        init: function(tableid, moveaction) {
+            // Initialise sortable for the given list.
+            var sort = new SortableList('#' + tableid + ' tbody');
+            sort.getElementName = function(element) {
+                return $.Deferred().resolve(element.attr('data-name'));
+            };
+            var origIndex;
+            $('#' + tableid + ' tbody tr').on(SortableList.EVENTS.DRAGSTART, function(_, info) {
+                // Remember position of the element in the beginning of dragging.
+                origIndex = info.sourceList.children().index(info.element);
+                // Resize the "proxy" element to be the same width as the main element.
+                setTimeout(function() {
+                    $('.sortable-list-is-dragged').width(info.element.width());
+                }, 501);
+            }).on(SortableList.EVENTS.DROP, function(_, info) {
+                // When a list element was moved send AJAX request to the server.
+                var newIndex = info.targetList.children().index(info.element);
+                var t = info.element.find('[data-action=' + moveaction + ']');
+                if (info.positionChanged && t.length) {
+                    var request = {
+                        methodname: 'tool_xmldb_invoke_move_action',
+                        args: {
+                            action: moveaction,
+                            dir: t.attr('data-dir'),
+                            table: t.attr('data-table'),
+                            field: t.attr('data-field'),
+                            key: t.attr('data-key'),
+                            index: t.attr('data-index'),
+                            position: newIndex - origIndex
+                        }
+                    };
+                    Ajax.call([request])[0].fail(Notification.exception);
+                }
+            });
+        }
+    };
+});
diff --git a/admin/tool/xmldb/classes/external.php b/admin/tool/xmldb/classes/external.php
new file mode 100644 (file)
index 0000000..8e56a45
--- /dev/null
@@ -0,0 +1,127 @@
+<?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/>.
+
+/**
+ * Web services
+ *
+ * @package     tool_xmldb
+ * @copyright   2018 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/externallib.php');
+
+/**
+ * tool_xmldb external function
+ *
+ * @package    tool_xmldb
+ * @copyright  2018 Moodle
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_xmldb_external extends external_api {
+
+    /**
+     * Parameters for the 'tool_xmldb_invoke_move_action' WS
+     * @return external_function_parameters
+     */
+    public static function invoke_move_action_parameters() {
+        return new external_function_parameters([
+            'action' => new external_value(PARAM_ALPHAEXT, 'Action'),
+            'dir' => new external_value(PARAM_PATH, 'Plugin that is being edited'),
+            'table' => new external_value(PARAM_NOTAGS, 'Table name'),
+            'field' => new external_value(PARAM_NOTAGS, 'Field name', VALUE_DEFAULT, ''),
+            'key' => new external_value(PARAM_NOTAGS, 'Key name', VALUE_DEFAULT, ''),
+            'index' => new external_value(PARAM_NOTAGS, 'Index name', VALUE_DEFAULT, ''),
+            'position' => new external_value(PARAM_INT, 'How many positions to move by (negative - up, positive - down)'),
+        ]);
+    }
+
+    /**
+     * WS 'tool_xmldb_invoke_move_action' that invokes a move action
+     *
+     * @param string $action
+     * @param string $dir
+     * @param string $table
+     * @param string $field
+     * @param string $key
+     * @param string $index
+     * @param int $position
+     * @throws coding_exception
+     */
+    public static function invoke_move_action($action, $dir, $table, $field, $key, $index, $position) {
+        global $CFG, $XMLDB, $SESSION;
+        require_once($CFG->libdir.'/ddllib.php');
+        require_once("$CFG->dirroot/$CFG->admin/tool/xmldb/actions/XMLDBAction.class.php");
+        require_once("$CFG->dirroot/$CFG->admin/tool/xmldb/actions/XMLDBCheckAction.class.php");
+        $params = self::validate_parameters(self::invoke_move_action_parameters(), [
+            'action' => $action,
+            'dir' => $dir,
+            'table' => $table,
+            'field' => $field,
+            'key' => $key,
+            'index' => $index,
+            'position' => $position
+        ]);
+
+        self::validate_context(context_system::instance());
+        require_capability('moodle/site:config', context_system::instance());
+
+        if (!in_array($action, ['move_updown_table', 'move_updown_field', 'move_updown_key', 'move_updown_index'])) {
+            throw new coding_exception('Unsupported action');
+        }
+
+        $action = $params['action'];
+        $actionsroot = "$CFG->dirroot/$CFG->admin/tool/xmldb/actions";
+        $actionclass = $action . '.class.php';
+        $actionpath = "$actionsroot/$action/$actionclass";
+
+        if (file_exists($actionpath) && is_readable($actionpath)) {
+            require_once($actionpath);
+        }
+        if (!class_exists($action)) {
+            throw new coding_exception('Action class not found');
+        }
+
+        if (!isset($SESSION->xmldb)) {
+            $XMLDB = new stdClass;
+        } else {
+            $XMLDB = unserialize($SESSION->xmldb);
+        }
+
+        $_POST['dir'] = $params['dir'];
+        $_POST['table'] = $params['table'];
+        $_POST['field'] = $params['field'];
+        $_POST['key'] = $params['key'];
+        $_POST['index'] = $params['index'];
+        $_POST['direction'] = ($params['position'] > 0) ? 'down' : 'up';
+        for ($i = 0; $i < abs($params['position']); $i++) {
+            $a = new $action();
+            $a->invoke();
+        }
+        $SESSION->xmldb = serialize($XMLDB);
+    }
+
+    /**
+     * Return structure for the 'tool_xmldb_invoke_move_action' WS
+     * @return null
+     */
+    public static function invoke_move_action_returns() {
+        return null;
+    }
+
+}
\ No newline at end of file
diff --git a/admin/tool/xmldb/db/services.php b/admin/tool/xmldb/db/services.php
new file mode 100644 (file)
index 0000000..412923f
--- /dev/null
@@ -0,0 +1,35 @@
+<?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/>.
+
+/**
+ * Tool xmldb external functions and service definitions.
+ *
+ * @package    tool_xmldb
+ * @copyright  2018 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die;
+
+$functions = [
+    'tool_xmldb_invoke_move_action' => [
+        'classname' => tool_xmldb_external::class,
+        'methodname' => 'invoke_move_action',
+        'description' => 'moves element up/down',
+        'type' => 'write',
+        'ajax' => true,
+    ]
+];
diff --git a/admin/tool/xmldb/styles_boost.css b/admin/tool/xmldb/styles_boost.css
new file mode 100644 (file)
index 0000000..ae530cb
--- /dev/null
@@ -0,0 +1,3 @@
+.path-admin-tool-xmldb a[name="lastused"] {
+    padding-top: 50px;
+}
index 486ba9e..98c4b2e 100644 (file)
@@ -24,7 +24,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2018051400; // The current plugin version (Date: YYYYMMDDXX)
+$plugin->version   = 2018051401; // The current plugin version (Date: YYYYMMDDXX)
 $plugin->requires  = 2018050800; // Requires this Moodle version
 $plugin->component = 'tool_xmldb'; // Full name of the plugin (used for diagnostics)
 
index ff84479..6ce1d97 100644 (file)
@@ -66,8 +66,8 @@ if (count($cohorts) < 2) {
 $countries = get_string_manager()->get_list_of_countries(true);
 $namefields = get_all_user_name_fields(true);
 foreach ($users as $key => $id) {
-    $user = $DB->get_record('user', array('id'=>$id, 'deleted'=>0), 'id, ' . $namefields . ', username,
-            email, country, lastaccess, city');
+    $user = $DB->get_record('user', array('id' => $id), 'id, ' . $namefields . ', username,
+            email, country, lastaccess, city, deleted');
     $user->fullname = fullname($user, true);
     $user->country = @$countries[$user->country];
     unset($user->firstname);
@@ -84,7 +84,7 @@ if (empty($users) or $mform->is_cancelled()) {
 } else if ($data = $mform->get_data()) {
     // process request
     foreach ($users as $user) {
-        if (!$DB->record_exists('cohort_members', array('cohortid'=>$data->cohort, 'userid'=>$user->id))) {
+        if (!$user->deleted && !$DB->record_exists('cohort_members', array('cohortid' => $data->cohort, 'userid' => $user->id))) {
             cohort_add_member($data->cohort, $user->id);
         }
     }
@@ -125,13 +125,25 @@ foreach ($columns as $column) {
 }
 
 foreach ($users as $user) {
-    $table->data[] = array (
-        '<a href="'.$CFG->wwwroot.'/user/view.php?id='.$user->id.'&amp;course='.SITEID.'">'.$user->fullname.'</a>',
-        $user->email,
-        $user->city,
-        $user->country,
-        $user->lastaccess ? format_time(time() - $user->lastaccess) : $strnever
-    );
+    if ($user->deleted) {
+        $table->data[] = array (
+            $user->fullname,
+            '',
+            '',
+            '',
+            get_string('deleteduser', 'bulkusers')
+        );
+    } else {
+        $table->data[] = array(
+            '<a href="' . $CFG->wwwroot . '/user/view.php?id=' . $user->id . '&amp;course=' . SITEID . '">' .
+            $user->fullname .
+            '</a>',
+            $user->email,
+            $user->city,
+            $user->country,
+            $user->lastaccess ? format_time(time() - $user->lastaccess) : $strnever
+        );
+    }
 }
 
 echo $OUTPUT->header();
index 1b8b8ea..2f836eb 100644 (file)
@@ -24,7 +24,7 @@ echo $OUTPUT->header();
 if ($confirm and confirm_sesskey()) {
     $notifications = '';
     list($in, $params) = $DB->get_in_or_equal($SESSION->bulk_users);
-    $rs = $DB->get_recordset_select('user', "id $in", $params);
+    $rs = $DB->get_recordset_select('user', "deleted = 0 and id $in", $params);
     foreach ($rs as $user) {
         if (!is_siteadmin($user) and $USER->id != $user->id and delete_user($user)) {
             unset($SESSION->bulk_users[$user->id]);
index b90490a..ef99fa6 100644 (file)
@@ -2418,6 +2418,9 @@ class backup_activity_grades_structure_step extends backup_structure_step {
     }
 
     protected function define_structure() {
+        global $CFG;
+
+        require_once($CFG->libdir . '/grade/constants.php');
 
         // To know if we are including userinfo
         $userinfo = $this->get_setting_value('userinfo');
@@ -2474,6 +2477,7 @@ class backup_activity_grades_structure_step extends backup_structure_step {
         // This only happens if we are including user info
         if ($userinfo) {
             $grade->set_source_table('grade_grades', array('itemid' => backup::VAR_PARENTID));
+            $grade->annotate_files(GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA, 'id');
         }
 
         $letter->set_source_table('grade_letters', array('contextid' => backup::VAR_CONTEXTID));
@@ -2510,6 +2514,9 @@ class backup_activity_grade_history_structure_step extends backup_structure_step
     }
 
     protected function define_structure() {
+        global $CFG;
+
+        require_once($CFG->libdir . '/grade/constants.php');
 
         // Settings to use.
         $userinfo = $this->get_setting_value('userinfo');
@@ -2537,6 +2544,7 @@ class backup_activity_grade_history_structure_step extends backup_structure_step
                                      JOIN {backup_ids_temp} bi ON ggh.itemid = bi.itemid
                                     WHERE bi.backupid = ?
                                       AND bi.itemname = 'grade_item'", array(backup::VAR_BACKUPID));
+            $grade->annotate_files(GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA, 'id');
         }
 
         // Annotations.
index 250aeb2..1e78ee3 100644 (file)
@@ -3705,6 +3705,10 @@ class restore_activity_grades_structure_step extends restore_structure_step {
     }
 
     protected function process_grade_grade($data) {
+        global $CFG;
+
+        require_once($CFG->libdir . '/grade/constants.php');
+
         $data = (object)($data);
         $olduserid = $data->userid;
         $oldid = $data->id;
@@ -3719,7 +3723,16 @@ class restore_activity_grades_structure_step extends restore_structure_step {
 
             $grade = new grade_grade($data, false);
             $grade->insert('restore');
-            $this->set_mapping('grade_grades', $oldid, $grade->id);
+
+            $this->set_mapping('grade_grades', $oldid, $grade->id, true);
+
+            $this->add_related_files(
+                GRADE_FILE_COMPONENT,
+                GRADE_FEEDBACK_FILEAREA,
+                'grade_grades',
+                null,
+                $oldid
+            );
         } else {
             debugging("Mapped user id not found for user id '{$olduserid}', grade item id '{$data->itemid}'");
         }
@@ -3794,9 +3807,12 @@ class restore_activity_grade_history_structure_step extends restore_structure_st
     }
 
     protected function process_grade_grade($data) {
-        global $DB;
+        global $CFG, $DB;
+
+        require_once($CFG->libdir . '/grade/constants.php');
 
         $data = (object) $data;
+        $oldhistoryid = $data->id;
         $olduserid = $data->userid;
         unset($data->id);
 
@@ -3807,13 +3823,23 @@ class restore_activity_grade_history_structure_step extends restore_structure_st
             $data->oldid = $this->get_mappingid('grade_grades', $data->oldid);
             $data->usermodified = $this->get_mappingid('user', $data->usermodified, null);
             $data->rawscaleid = $this->get_mappingid('scale', $data->rawscaleid);
-            $DB->insert_record('grade_grades_history', $data);
+
+            $newhistoryid = $DB->insert_record('grade_grades_history', $data);
+
+            $this->set_mapping('grade_grades_history', $oldhistoryid, $newhistoryid, true);
+
+            $this->add_related_files(
+                GRADE_FILE_COMPONENT,
+                GRADE_HISTORY_FEEDBACK_FILEAREA,
+                'grade_grades_history',
+                null,
+                $oldhistoryid
+            );
         } else {
             $message = "Mapped user id not found for user id '{$olduserid}', grade item id '{$data->itemid}'";
             $this->log($message, backup::LOG_DEBUG);
         }
     }
-
 }
 
 /**
index 95de3b8..a72d5ef 100644 (file)
@@ -39,6 +39,68 @@ require_once("$CFG->libdir/externallib.php");
  */
 class core_block_external extends external_api {
 
+
+    /**
+     * Returns a block structure.
+     *
+     * @return external_single_structure a block single structure.
+     * @since  Moodle 3.6
+     */
+    private static function get_block_structure() {
+        return new external_single_structure(
+            array(
+                'instanceid'    => new external_value(PARAM_INT, 'Block instance id.'),
+                'name'          => new external_value(PARAM_PLUGIN, 'Block name.'),
+                'region'        => new external_value(PARAM_ALPHANUMEXT, 'Block region.'),
+                'positionid'    => new external_value(PARAM_INT, 'Position id.'),
+                'collapsible'   => new external_value(PARAM_BOOL, 'Whether the block is collapsible.'),
+                'dockable'      => new external_value(PARAM_BOOL, 'Whether the block is dockable.'),
+                'weight'        => new external_value(PARAM_INT, 'Used to order blocks within a region.', VALUE_OPTIONAL),
+                'visible'       => new external_value(PARAM_BOOL, 'Whether the block is visible.', VALUE_OPTIONAL),
+            ), 'Block information.'
+        );
+    }
+
+    /**
+     * Convenience function for getting all the blocks of the current $PAGE.
+     *
+     * @param bool $includeinvisible Whether to include not visible blocks or not
+     * @return array Block information
+     * @since  Moodle 3.6
+     */
+    private static function get_all_current_page_blocks($includeinvisible = false) {
+        global $PAGE, $OUTPUT;
+
+        // Load the block instances for all the regions.
+        $PAGE->blocks->load_blocks($includeinvisible);
+        $PAGE->blocks->create_all_block_instances();
+
+        $allblocks = array();
+        $blocks = $PAGE->blocks->get_content_for_all_regions($OUTPUT);
+        foreach ($blocks as $region => $regionblocks) {
+            $regioninstances = $PAGE->blocks->get_blocks_for_region($region);
+            // Index block instances to retrieve required info.
+            $blockinstances = array();
+            foreach ($regioninstances as $ri) {
+                $blockinstances[$ri->instance->id] = $ri->instance;
+            }
+
+            foreach ($regionblocks as $bc) {
+                $allblocks[] = [
+                    'instanceid' => $bc->blockinstanceid,
+                    'name' => $blockinstances[$bc->blockinstanceid]->blockname,
+                    'region' => $region,
+                    'positionid' => $bc->blockpositionid,
+                    'collapsible' => (bool) $bc->collapsible,
+                    'dockable' => (bool) $bc->dockable,
+                    'weight' => $blockinstances[$bc->blockinstanceid]->weight,
+                    'visible' => $blockinstances[$bc->blockinstanceid]->visible,
+                ];
+            }
+        }
+        return $allblocks;
+    }
+
     /**
      * Returns description of get_course_blocks parameters.
      *
@@ -62,7 +124,7 @@ class core_block_external extends external_api {
      * @since Moodle 3.3
      */
     public static function get_course_blocks($courseid) {
-        global $OUTPUT, $PAGE;
+        global $PAGE;
 
         $warnings = array();
         $params = self::validate_parameters(self::get_course_blocks_parameters(), ['courseid' => $courseid]);
@@ -82,27 +144,10 @@ class core_block_external extends external_api {
             $PAGE->set_pagetype('course-view-' . $course->format);
         }
 
-        // Load the block instances for all the regions.
-        $PAGE->blocks->load_blocks();
-        $PAGE->blocks->create_all_block_instances();
-
-        $finalblocks = array();
-        $blocks = $PAGE->blocks->get_content_for_all_regions($OUTPUT);
-        foreach ($blocks as $region => $regionblocks) {
-            foreach ($regionblocks as $bc) {
-                $finalblocks[] = [
-                    'instanceid' => $bc->blockinstanceid,
-                    'name' => $bc->attributes['data-block'],
-                    'region' => $region,
-                    'positionid' => $bc->blockpositionid,
-                    'collapsible' => (bool) $bc->collapsible,
-                    'dockable' => (bool) $bc->dockable,
-                ];
-            }
-        }
+        $allblocks = self::get_all_current_page_blocks();
 
         return array(
-            'blocks' => $finalblocks,
+            'blocks' => $allblocks,
             'warnings' => $warnings
         );
     }
@@ -117,21 +162,91 @@ class core_block_external extends external_api {
 
         return new external_single_structure(
             array(
-                'blocks' => new external_multiple_structure(
-                    new external_single_structure(
-                        array(
-                            'instanceid'    => new external_value(PARAM_INT, 'Block instance id.'),
-                            'name'          => new external_value(PARAM_PLUGIN, 'Block name.'),
-                            'region'        => new external_value(PARAM_ALPHANUMEXT, 'Block region.'),
-                            'positionid'    => new external_value(PARAM_INT, 'Position id.'),
-                            'collapsible'   => new external_value(PARAM_BOOL, 'Whether the block is collapsible.'),
-                            'dockable'      => new external_value(PARAM_BOOL, 'hether the block is  dockable.'),
-                        ), 'Block information.'
-                    ), 'List of blocks in the course.'
-                ),
+                'blocks' => new external_multiple_structure(self::get_block_structure(), 'List of blocks in the course.'),
                 'warnings'  => new external_warnings(),
             )
         );
     }
 
+    /**
+     * Returns description of get_dashboard_blocks parameters.
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.6
+     */
+    public static function get_dashboard_blocks_parameters() {
+        return new external_function_parameters(
+            array(
+                'userid'  => new external_value(PARAM_INT, 'User id (optional), default is current user.', VALUE_DEFAULT, 0)
+            )
+        );
+    }
+
+    /**
+     * Returns blocks information for the given user dashboard.
+     *
+     * @param int $userid The user id to retrive the blocks from, optional, default is to current user.
+     * @return array Blocks list and possible warnings
+     * @throws moodle_exception
+     * @since Moodle 3.6
+     */
+    public static function get_dashboard_blocks($userid = 0) {
+        global $CFG, $USER, $PAGE;
+
+        require_once($CFG->dirroot . '/my/lib.php');
+
+        $warnings = array();
+        $params = self::validate_parameters(self::get_dashboard_blocks_parameters(), ['userid' => $userid]);
+
+        $userid = $params['userid'];
+        if (empty($userid)) {
+            $userid = $USER->id;
+        }
+
+        if ($USER->id != $userid) {
+            // We must check if the current user can view other users dashboard.
+            require_capability('moodle/site:config', context_system::instance());
+            $user = core_user::get_user($userid, '*', MUST_EXIST);
+            core_user::require_active_user($user);
+        }
+
+        $context = context_user::instance($userid);;
+        self::validate_context($context);
+
+        // Get the My Moodle page info.  Should always return something unless the database is broken.
+        if (!$currentpage = my_get_page($userid, MY_PAGE_PRIVATE)) {
+            throw new moodle_exception('mymoodlesetup');
+        }
+
+        $PAGE->set_context($context);
+        $PAGE->set_pagelayout('mydashboard');
+        $PAGE->set_pagetype('my-index');
+        $PAGE->blocks->add_region('content');   // Need to add this special regition to retrieve the central blocks.
+        $PAGE->set_subpage($currentpage->id);
+
+        // Load the block instances in the current $PAGE for all the regions.
+        $returninvisible = has_capability('moodle/my:manageblocks', $context) ? true : false;
+        $allblocks = self::get_all_current_page_blocks($returninvisible);
+
+        return array(
+            'blocks' => $allblocks,
+            'warnings' => $warnings
+        );
+    }
+
+    /**
+     * Returns description of get_dashboard_blocks result values.
+     *
+     * @return external_single_structure
+     * @since Moodle 3.6
+     */
+    public static function get_dashboard_blocks_returns() {
+
+        return new external_single_structure(
+            array(
+                'blocks' => new external_multiple_structure(self::get_block_structure(), 'List of blocks in the dashboard.'),
+                'warnings'  => new external_warnings(),
+            )
+        );
+    }
 }
index fdf3bea..566ce53 100644 (file)
@@ -29,6 +29,7 @@ defined('MOODLE_INTERNAL') || die();
 global $CFG;
 
 require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+require_once($CFG->dirroot . '/my/lib.php');
 
 /**
  * External block functions unit tests
@@ -138,4 +139,140 @@ class core_block_externallib_testcase extends externallib_advanced_testcase {
 
     }
 
+    /**
+     * Test user get default dashboard blocks.
+     */
+    public function test_get_dashboard_blocks_default_dashboard() {
+        global $PAGE, $DB;
+        $this->resetAfterTest(true);
+
+        $user = $this->getDataGenerator()->create_user();
+        $PAGE->set_url('/my/index.php');    // Need this because some internal API calls require the $PAGE url to be set.
+
+        // Get the expected default blocks.
+        $alldefaultblocksordered = $DB->get_records_menu('block_instances',
+            array('pagetypepattern' => 'my-index'), 'defaultregion, defaultweight ASC', 'id, blockname');
+
+        $this->setUser($user);
+
+        // Check for the default blocks.
+        $result = core_block_external::get_dashboard_blocks($user->id);
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $result = external_api::clean_returnvalue(core_block_external::get_dashboard_blocks_returns(), $result);
+        // Expect all blogs except learning plans one (no learning plans to show).
+        $this->assertCount(count($alldefaultblocksordered) - 1, $result['blocks']);
+        $returnedblocks = array();
+        foreach ($result['blocks'] as $block) {
+            // Check all the returned blocks are in the expected blocks array.
+            $this->assertContains($block['name'], $alldefaultblocksordered);
+            $returnedblocks[] = $block['name'];
+        }
+        // Remove lp block.
+        array_shift($alldefaultblocksordered);
+        // Check that we received the blocks in the expected order.
+        $this->assertEquals(array_values($alldefaultblocksordered), $returnedblocks);
+    }
+
+    /**
+     * Test user get default dashboard blocks including a sticky block.
+     */
+    public function test_get_dashboard_blocks_default_dashboard_including_sticky_block() {
+        global $PAGE, $DB;
+        $this->resetAfterTest(true);
+
+        $user = $this->getDataGenerator()->create_user();
+        $PAGE->set_url('/my/index.php');    // Need this because some internal API calls require the $PAGE url to be set.
+
+        // Get the expected default blocks.
+        $alldefaultblocks = $DB->get_records_menu('block_instances', array('pagetypepattern' => 'my-index'), '', 'id, blockname');
+
+        // Now, add a sticky block.
+        $page = new moodle_page();
+        $page->set_context(context_system::instance());
+        $page->set_pagetype('my-index');
+        $page->set_url(new moodle_url('/'));
+        $page->blocks->add_region('side-pre');
+        $page->blocks->load_blocks();
+        $page->blocks->add_block('myprofile', 'side-pre', 0, true, '*');
+
+        $this->setUser($user);
+
+        // Check for the default blocks plus the sticky.
+        $result = core_block_external::get_dashboard_blocks($user->id);
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $result = external_api::clean_returnvalue(core_block_external::get_dashboard_blocks_returns(), $result);
+        // Expect all blogs plus sticky one except learning plans one (no learning plans to show).
+        $this->assertCount(count($alldefaultblocks), $result['blocks']);
+        $found = false;
+        foreach ($result['blocks'] as $block) {
+            if ($block['name'] == 'myprofile') {
+                $this->assertEquals('side-pre', $block['region']);
+                $found = true;
+                continue;
+            }
+            // Check that the block is in the expected blocks array.
+            $this->assertContains($block['name'], $alldefaultblocks);
+        }
+        $this->assertTrue($found);
+    }
+
+    /**
+     * Test admin get user's custom dashboard blocks.
+     */
+    public function test_get_dashboard_blocks_custom_user_dashboard() {
+        global $PAGE, $DB;
+        $this->resetAfterTest(true);
+
+        $user = $this->getDataGenerator()->create_user();
+        $PAGE->set_url('/my/index.php');    // Need this because some internal API calls require the $PAGE url to be set.
+
+        // Get the expected default blocks.
+        $alldefaultblocks = $DB->get_records_menu('block_instances', array('pagetypepattern' => 'my-index'), '', 'id, blockname');
+
+        // Add a custom block.
+        $page = new moodle_page();
+        $page->set_context(context_user::instance($user->id));
+        $page->set_pagelayout('mydashboard');
+        $page->set_pagetype('my-index');
+        $page->blocks->add_region('content');
+        $currentpage = my_get_page($user->id, MY_PAGE_PRIVATE);
+        $page->set_subpage($currentpage->id);
+        $page->blocks->load_blocks();
+        $page->blocks->add_block('myprofile', 'content', 0, false);
+
+        $this->setAdminUser();
+
+        // Check for the new block as admin for a user.
+        $result = core_block_external::get_dashboard_blocks($user->id);
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $result = external_api::clean_returnvalue(core_block_external::get_dashboard_blocks_returns(), $result);
+        // Expect all default blogs plys the one we added except learning plans one (no learning plans to show).
+        $this->assertCount(count($alldefaultblocks), $result['blocks']);
+        $found = false;
+        foreach ($result['blocks'] as $block) {
+            if ($block['name'] == 'myprofile') {
+                $this->assertEquals('content', $block['region']);
+                $found = true;
+                continue;
+            }
+            // Check that the block is in the expected blocks array.
+            $this->assertContains($block['name'], $alldefaultblocks);
+        }
+        $this->assertTrue($found);
+    }
+
+    /**
+     * Test user tries to get other user blocks not having permission.
+     */
+    public function test_get_dashboard_blocks_other_user_missing_permissions() {
+        $this->resetAfterTest(true);
+
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+
+        $this->setUser($user1);
+
+        $this->expectException('moodle_exception');
+        core_block_external::get_dashboard_blocks($user2->id);
+    }
 }
index 1234de4..c42d1ec 100644 (file)
@@ -4,6 +4,7 @@ information provided here is intended especially for developers.
 === 3.6 ===
 
 * The timeline view from block_myoverview has been split out into block_timeline.
+* External function core_blocks::get_course_blocks now returns the block visible status and weight for ordering.
 
 === 3.4 ===
 
index f2437cb..9b9e371 100644 (file)
@@ -589,6 +589,11 @@ $CFG->admin = 'admin';
 //
 //      $CFG->pdfexportfont = 'freesans';
 //
+// Use the following flag to enable messagingallusers and set the default preference
+// value for existing users to allow them to be contacted by other site users.
+//
+//      $CFG->keepmessagingallusersenabled = true;
+//
 //=========================================================================
 // 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!!
 //=========================================================================
index b38af38..a3235ce 100644 (file)
@@ -290,6 +290,10 @@ abstract class moodleform_mod extends moodleform {
                 if ($mform->elementExists('groupingid')) {
                     $mform->removeElement('groupingid');
                 }
+                // Nor does the group restrictions button.
+                if ($mform->elementExists('restrictgroupbutton')) {
+                    $mform->removeElement('restrictgroupbutton');
+                }
             }
         }
 
index 55b074c..6b06aa4 100644 (file)
@@ -295,15 +295,18 @@ class core_enrol_external extends external_api {
         global $CFG, $USER, $DB;
 
         require_once($CFG->dirroot . '/course/lib.php');
+        require_once($CFG->libdir . '/completionlib.php');
 
         // Do basic automatic PARAM checks on incoming data, using params description
         // If any problems are found then exceptions are thrown with helpful error messages
         $params = self::validate_parameters(self::get_users_courses_parameters(), array('userid'=>$userid));
 
-        $courses = enrol_get_users_courses($params['userid'], true, 'id, shortname, fullname, idnumber, visible,
-                   summary, summaryformat, format, showgrades, lang, enablecompletion, category, startdate, enddate');
+        $courses = enrol_get_users_courses($params['userid'], true, '*');
         $result = array();
 
+        // Get user data including last access to courses.
+        $user = get_complete_user_data('id', $userid);
+
         foreach ($courses as $course) {
             $context = context_course::instance($course->id, IGNORE_MISSING);
             try {
@@ -313,7 +316,8 @@ class core_enrol_external extends external_api {
                 continue;
             }
 
-            if ($userid != $USER->id and !course_can_view_participants($context)) {
+            $sameuser = $USER->id == $userid;
+            if (!$sameuser and !course_can_view_participants($context)) {
                 // we need capability to view participants
                 continue;
             }
@@ -322,20 +326,58 @@ class core_enrol_external extends external_api {
             $enrolledsql = "SELECT COUNT('x') FROM ($enrolledsqlselect) enrolleduserids";
             $enrolledusercount = $DB->count_records_sql($enrolledsql, $enrolledparams);
 
+            $displayname = external_format_string(get_course_display_name_for_list($course), $context->id);
             list($course->summary, $course->summaryformat) =
                 external_format_text($course->summary, $course->summaryformat, $context->id, 'course', 'summary', null);
             $course->fullname = external_format_string($course->fullname, $context->id);
             $course->shortname = external_format_string($course->shortname, $context->id);
 
             $progress = null;
-            if ($course->enablecompletion) {
-                $progress = \core_completion\progress::get_course_progress_percentage($course);
+            $completed = null;
+
+            // Return only private information if the user should be able to see it.
+            if ($sameuser || completion_can_view_data($userid, $course)) {
+                if ($course->enablecompletion) {
+                    $completion = new completion_info($course);
+                    $completed = $completion->is_course_complete($userid);
+                    $progress = \core_completion\progress::get_course_progress_percentage($course, $userid);
+                }
+            }
+
+            $lastaccess = null;
+            // Check if last access is a hidden field.
+            $hiddenfields = array_flip(explode(',', $CFG->hiddenuserfields));
+            $canviewlastaccess = $sameuser || !isset($hiddenfields['lastaccess']);
+            if (!$canviewlastaccess) {
+                $canviewlastaccess = has_capability('moodle/course:viewhiddenuserfields', $context);
+            }
+
+            if ($canviewlastaccess && isset($user->lastcourseaccess[$course->id])) {
+                $lastaccess = $user->lastcourseaccess[$course->id];
+            }
+
+            // Retrieve course overview used files.
+            $courselist = new core_course_list_element($course);
+            $overviewfiles = array();
+            foreach ($courselist->get_course_overviewfiles() as $file) {
+                $fileurl = moodle_url::make_webservice_pluginfile_url($file->get_contextid(), $file->get_component(),
+                                                                        $file->get_filearea(), null, $file->get_filepath(),
+                                                                        $file->get_filename())->out(false);
+                $overviewfiles[] = array(
+                    'filename' => $file->get_filename(),
+                    'fileurl' => $fileurl,
+                    'filesize' => $file->get_filesize(),
+                    'filepath' => $file->get_filepath(),
+                    'mimetype' => $file->get_mimetype(),
+                    'timemodified' => $file->get_timemodified(),
+                );
             }
 
             $result[] = array(
                 'id' => $course->id,
                 'shortname' => $course->shortname,
                 'fullname' => $course->fullname,
+                'displayname' => $displayname,
                 'idnumber' => $course->idnumber,
                 'visible' => $course->visible,
                 'enrolledusercount' => $enrolledusercount,
@@ -347,8 +389,12 @@ class core_enrol_external extends external_api {
                 'enablecompletion' => $course->enablecompletion,
                 'category' => $course->category,
                 'progress' => $progress,
+                'completed' => $completed,
                 'startdate' => $course->startdate,
                 'enddate' => $course->enddate,
+                'marker' => $course->marker,
+                'lastaccess' => $lastaccess,
+                'overviewfiles' => $overviewfiles,
             );
         }
 
@@ -367,6 +413,7 @@ class core_enrol_external extends external_api {
                     'id'        => new external_value(PARAM_INT, 'id of course'),
                     'shortname' => new external_value(PARAM_RAW, 'short name of course'),
                     'fullname'  => new external_value(PARAM_RAW, 'long name of course'),
+                    'displayname' => new external_value(PARAM_TEXT, 'course display name for lists.', VALUE_OPTIONAL),
                     'enrolledusercount' => new external_value(PARAM_INT, 'Number of enrolled users in this course'),
                     'idnumber'  => new external_value(PARAM_RAW, 'id number of course'),
                     'visible'   => new external_value(PARAM_INT, '1 means visible, 0 means hidden course'),
@@ -379,8 +426,12 @@ class core_enrol_external extends external_api {
                                                                 VALUE_OPTIONAL),
                     'category' => new external_value(PARAM_INT, 'course category id', VALUE_OPTIONAL),
                     'progress' => new external_value(PARAM_FLOAT, 'Progress percentage', VALUE_OPTIONAL),
+                    'completed' => new external_value(PARAM_BOOL, 'Whether the course is completed.', VALUE_OPTIONAL),
                     'startdate' => new external_value(PARAM_INT, 'Timestamp when the course start', VALUE_OPTIONAL),
                     'enddate' => new external_value(PARAM_INT, 'Timestamp when the course end', VALUE_OPTIONAL),
+                    'marker' => new external_value(PARAM_INT, 'Course section marker.', VALUE_OPTIONAL),
+                    'lastaccess' => new external_value(PARAM_INT, 'Last access to the course (timestamp).', VALUE_OPTIONAL),
+                    'overviewfiles' => new external_files('Overview files attached to this course.', VALUE_OPTIONAL),
                 )
             )
         );
index 22c3c5c..90029e2 100644 (file)
@@ -359,9 +359,10 @@ class core_enrol_externallib_testcase extends externallib_advanced_testcase {
      * Test get_users_courses
      */
     public function test_get_users_courses() {
-        global $USER;
+        global $CFG, $DB;
 
         $this->resetAfterTest(true);
+        $CFG->enablecompletion = 1;
 
         $timenow = time();
         $coursedata1 = array(
@@ -373,7 +374,8 @@ class core_enrol_externallib_testcase extends externallib_advanced_testcase {
             'enablecompletion' => true,
             'showgrades'       => true,
             'startdate'        => $timenow,
-            'enddate'          => $timenow + WEEKSECS
+            'enddate'          => $timenow + WEEKSECS,
+            'marker'           => 1
         );
 
         $coursedata2 = array(
@@ -383,21 +385,32 @@ class core_enrol_externallib_testcase extends externallib_advanced_testcase {
         $course1 = self::getDataGenerator()->create_course($coursedata1);
         $course2 = self::getDataGenerator()->create_course($coursedata2);
         $courses = array($course1, $course2);
+        $contexts = array ($course1->id => context_course::instance($course1->id),
+            $course2->id => context_course::instance($course2->id));
 
-        // Enrol $USER in the courses.
-        // We use the manual plugin.
-        $roleid = null;
-        $contexts = array();
-        foreach ($courses as $course) {
-            $contexts[$course->id] = context_course::instance($course->id);
-            $roleid = $this->assignUserCapability('moodle/course:viewparticipants',
-                    $contexts[$course->id]->id, $roleid);
+        $student = $this->getDataGenerator()->create_user();
+        $otherstudent = $this->getDataGenerator()->create_user();
+        $studentroleid = $DB->get_field('role', 'id', array('shortname' => 'student'));
+        $this->getDataGenerator()->enrol_user($student->id, $course1->id, $studentroleid);
+        $this->getDataGenerator()->enrol_user($otherstudent->id, $course1->id, $studentroleid);
+        $this->getDataGenerator()->enrol_user($student->id, $course2->id, $studentroleid);
 
-            $this->getDataGenerator()->enrol_user($USER->id, $course->id, $roleid, 'manual');
-        }
+        // Force last access.
+        $timenow = time();
+        $lastaccess = array(
+            'userid' => $student->id,
+            'courseid' => $course1->id,
+            'timeaccess' => $timenow
+        );
+        $DB->insert_record('user_lastaccess', $lastaccess);
+
+        // Force completion.
+        $ccompletion = new completion_completion(array('course' => $course1->id, 'userid' => $student->id));
+        $ccompletion->mark_complete();
 
+        $this->setUser($student);
         // Call the external function.
-        $enrolledincourses = core_enrol_external::get_users_courses($USER->id);
+        $enrolledincourses = core_enrol_external::get_users_courses($student->id);
 
         // We need to execute the return values cleaning process to simulate the web service server.
         $enrolledincourses = external_api::clean_returnvalue(core_enrol_external::get_users_courses_returns(), $enrolledincourses);
@@ -418,11 +431,54 @@ class core_enrol_externallib_testcase extends externallib_advanced_testcase {
                 foreach ($coursedata1 as $fieldname => $value) {
                     $this->assertEquals($courseenrol[$fieldname], $course1->$fieldname);
                 }
+                // Text extra fields.
+                $this->assertEquals($course1->fullname, $courseenrol['displayname']);
+                $this->assertEquals([], $courseenrol['overviewfiles']);
+                $this->assertEquals($timenow, $courseenrol['lastaccess']);
+                $this->assertEquals(100.0, $courseenrol['progress']);
+                $this->assertEquals(true, $courseenrol['completed']);
             } else {
                 // Check language pack. Should be empty since an incorrect one was used when creating the course.
                 $this->assertEmpty($courseenrol['lang']);
+                $this->assertEquals($course2->fullname, $courseenrol['displayname']);
+                $this->assertEquals([], $courseenrol['overviewfiles']);
+                $this->assertEquals(0, $courseenrol['lastaccess']);
+                $this->assertEquals(0, $courseenrol['progress']);
+                $this->assertEquals(false, $courseenrol['completed']);
             }
         }
+
+        // Now check that admin users can see all the info.
+        $this->setAdminUser();
+
+        $enrolledincourses = core_enrol_external::get_users_courses($student->id);
+        $enrolledincourses = external_api::clean_returnvalue(core_enrol_external::get_users_courses_returns(), $enrolledincourses);
+        $this->assertEquals(2, count($enrolledincourses));
+        foreach ($enrolledincourses as $courseenrol) {
+            if ($courseenrol['id'] == $course1->id) {
+                $this->assertEquals($timenow, $courseenrol['lastaccess']);
+                $this->assertEquals(100.0, $courseenrol['progress']);
+            } else {
+                $this->assertEquals(0, $courseenrol['progress']);
+            }
+        }
+
+        // Check other users can't see private info.
+        $this->setUser($otherstudent);
+
+        $enrolledincourses = core_enrol_external::get_users_courses($student->id);
+        $enrolledincourses = external_api::clean_returnvalue(core_enrol_external::get_users_courses_returns(), $enrolledincourses);
+        $this->assertEquals(1, count($enrolledincourses));
+
+        $this->assertEquals($timenow, $enrolledincourses[0]['lastaccess']); // I can see this, not hidden.
+        $this->assertEquals(null, $enrolledincourses[0]['progress']);   // I can't see this, private.
+
+        // Change some global profile visibility fields.
+        $CFG->hiddenuserfields = 'lastaccess';
+        $enrolledincourses = core_enrol_external::get_users_courses($student->id);
+        $enrolledincourses = external_api::clean_returnvalue(core_enrol_external::get_users_courses_returns(), $enrolledincourses);
+
+        $this->assertEquals(0, $enrolledincourses[0]['lastaccess']); // I can't see this, hidden by global setting.
     }
 
     /**
index 09ff3ba..69d47fe 100644 (file)
@@ -1,6 +1,16 @@
 This files describes API changes in /enrol/* - plugins,
 information provided here is intended especially for developers.
 
+=== 3.6 ===
+
+* External function core_enrol_external::get_users_courses now return more information to avoid multiple queries to build the
+  user dashboard:
+  - displayname: Course display name for lists.
+  - marker: Course section active marker.
+  - completed: Whether the given user completed the course or not.
+  - lastaccess: Last time the user accessed the course.
+  - overviewfiles: Course overview files.
+
 === 3.5 ===
 
 * Default sorting in enrol_get_my_courses(), enrol_get_all_users_courses() and enrol_get_users_courses() now respects
diff --git a/favourites/classes/local/entity/favourite.php b/favourites/classes/local/entity/favourite.php
new file mode 100644 (file)
index 0000000..a10a8f2
--- /dev/null
@@ -0,0 +1,77 @@
+<?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/>.
+
+/**
+ * Contains the favourite class, each instance being a representation of a DB row for the 'favourite' table.
+ *
+ * @package   core_favourites
+ * @copyright 2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_favourites\local\entity;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Contains the favourite class, each instance being a representation of a DB row for the 'favourite' table.
+ *
+ * @copyright 2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class favourite {
+    /** @var int $id the id of the favourite.*/
+    public $id;
+
+    /** @var string $component the frankenstyle name of the component containing the favourited item. E.g. 'core_course'.*/
+    public $component;
+
+    /** @var string $itemtype the type of the item being marked as a favourite. E.g. 'course', 'conversation', etc.*/
+    public $itemtype;
+
+    /** @var int $itemid the id of the item that is being marked as a favourite. e.g course->id, conversation->id, etc.*/
+    public $itemid;
+
+    /** @var int $contextid the id of the context in which this favourite was created.*/
+    public $contextid;
+
+    /** @var int $userid the id of user who owns this favourite.*/
+    public $userid;
+
+    /** @var int $ordering the ordering of the favourite within it's favourite area.*/
+    public $ordering;
+
+    /** @var int $timecreated the time at which the favourite was created.*/
+    public $timecreated;
+
+    /** @var int $timemodified the time at which the last modification of the favourite took place.*/
+    public $timemodified;
+
+    /**
+     * Favourite constructor.
+     * @param string $component the frankenstyle name of the component containing the favourited item. E.g. 'core_course'.
+     * @param string $itemtype the type of the item being marked as a favourite. E.g. 'course', 'conversation', etc.
+     * @param int $itemid the id of the item that is being marked as a favourite. e.g course->id, conversation->id, etc.
+     * @param int $contextid the id of the context in which this favourite was created.
+     * @param int $userid the id of user who owns this favourite.
+     */
+    public function __construct(string $component, string $itemtype, int $itemid, int $contextid, int $userid) {
+        $this->component = $component;
+        $this->itemtype = $itemtype;
+        $this->itemid = $itemid;
+        $this->contextid = $contextid;
+        $this->userid = $userid;
+    }
+}
diff --git a/favourites/classes/local/repository/favourite_repository.php b/favourites/classes/local/repository/favourite_repository.php
new file mode 100644 (file)
index 0000000..d0236b7
--- /dev/null
@@ -0,0 +1,308 @@
+<?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/>.
+/**
+ * Contains the favourite_repository class, responsible for CRUD operations for favourites.
+ *
+ * @package   core_favourites
+ * @copyright 2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_favourites\local\repository;
+use \core_favourites\local\entity\favourite;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class favourite_repository.
+ *
+ * This class handles persistence of favourites. Favourites from all areas are supported by this repository.
+ *
+ * @copyright 2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class favourite_repository implements favourite_repository_interface {
+
+    /**
+     * @var string the name of the table which favourites are stored in.
+     */
+    protected $favouritetable = 'favourite';
+
+    /**
+     * Get a favourite object, based on a full record.
+     * @param \stdClass $record the record we wish to hydrate.
+     * @return favourite the favourite record.
+     */
+    protected function get_favourite_from_record(\stdClass $record) : favourite {
+        $favourite = new favourite(
+            $record->component,
+            $record->itemtype,
+            $record->itemid,
+            $record->contextid,
+            $record->userid
+        );
+        $favourite->id = $record->id;
+        $favourite->ordering = $record->ordering ?? null;
+        $favourite->timecreated = $record->timecreated ?? null;
+        $favourite->timemodified = $record->timemodified ?? null;
+
+        return $favourite;
+    }
+
+    /**
+     * Get a list of favourite objects, based on a list of records.
+     * @param array $records the record we wish to hydrate.
+     * @return array the list of favourites.
+     */
+    protected function get_list_of_favourites_from_records(array $records) {
+        $list = [];
+        foreach ($records as $index => $record) {
+            $list[$index] = $this->get_favourite_from_record($record);
+        }
+        return $list;
+    }
+
+    /**
+     * Basic validation, confirming we have the minimum field set needed to save a record to the store.
+     *
+     * @param favourite $favourite the favourite record to validate.
+     * @throws \moodle_exception if the supplied favourite has missing or unsupported fields.
+     */
+    protected function validate(favourite $favourite) {
+
+        $favourite = (array)$favourite;
+
+        // The allowed fields, and whether or not each is required to create a record.
+        // The timecreated, timemodified and id fields are generated during create/update.
+        $allowedfields = [
+            'userid' => true,
+            'component' => true,
+            'itemtype' => true,
+            'itemid' => true,
+            'contextid' => true,
+            'ordering' => false,
+            'timecreated' => false,
+            'timemodified' => false,
+            'id' => false
+        ];
+
+        $requiredfields = array_filter($allowedfields, function($field) {
+            return $field;
+        });
+
+        if ($missingfields = array_keys(array_diff_key($requiredfields, $favourite))) {
+            throw new \moodle_exception("Missing object property(s) '" . join(', ', $missingfields) . "'.");
+        }
+
+        // If the record contains fields we don't allow, throw an exception.
+        if ($unsupportedfields = array_keys(array_diff_key($favourite, $allowedfields))) {
+            throw new \moodle_exception("Unexpected object property(s) '" . join(', ', $unsupportedfields) . "'.");
+        }
+    }
+
+    /**
+     * Add a favourite to the repository.
+     *
+     * @param favourite $favourite the favourite to add.
+     * @return favourite the favourite which has been stored.
+     * @throws \dml_exception if any database errors are encountered.
+     * @throws \moodle_exception if the favourite has missing or invalid properties.
+     */
+    public function add(favourite $favourite) : favourite {
+        global $DB;
+        $this->validate($favourite);
+        $favourite = (array)$favourite;
+        $time = time();
+        $favourite['timecreated'] = $time;
+        $favourite['timemodified'] = $time;
+        $id = $DB->insert_record($this->favouritetable, $favourite);
+        return $this->find($id);
+    }
+
+    /**
+     * Add a collection of favourites to the repository.
+     *
+     * @param array $items the list of favourites to add.
+     * @return array the list of favourites which have been stored.
+     * @throws \dml_exception if any database errors are encountered.
+     * @throws \moodle_exception if any of the favourites have missing or invalid properties.
+     */
+    public function add_all(array $items) : array {
+        global $DB;
+        $time = time();
+        foreach ($items as $item) {
+            $this->validate($item);
+            $favourite = (array)$item;
+            $favourite['timecreated'] = $time;
+            $favourite['timemodified'] = $time;
+            $ids[] = $DB->insert_record($this->favouritetable, $favourite);
+        }
+        list($insql, $params) = $DB->get_in_or_equal($ids);
+        $records = $DB->get_records_select($this->favouritetable, "id $insql", $params);
+        return $this->get_list_of_favourites_from_records($records);
+    }
+
+    /**
+     * Find a favourite by id.
+     *
+     * @param int $id the id of the favourite.
+     * @return favourite the favourite.
+     * @throws \dml_exception if any database errors are encountered.
+     */
+    public function find(int $id) : favourite {
+        global $DB;
+        $record = $DB->get_record($this->favouritetable, ['id' => $id], '*', MUST_EXIST);
+        return $this->get_favourite_from_record($record);
+    }
+
+    /**
+     * Return all items in this repository, as an array, indexed by id.
+     *
+     * @param int $limitfrom optional pagination control for returning a subset of records, starting at this point.
+     * @param int $limitnum optional pagination control for returning a subset comprising this many records.
+     * @return array the list of all favourites stored within this repository.
+     * @throws \dml_exception if any database errors are encountered.
+     */
+    public function find_all(int $limitfrom = 0, int $limitnum = 0) : array {
+        global $DB;
+        $records = $DB->get_records($this->favouritetable, null, '', '*', $limitfrom, $limitnum);
+        return $this->get_list_of_favourites_from_records($records);
+    }
+
+    /**
+     * Return all items matching the supplied criteria (a [key => value,..] list).
+     *
+     * @param array $criteria the list of key/value criteria pairs.
+     * @param int $limitfrom optional pagination control for returning a subset of records, starting at this point.
+     * @param int $limitnum optional pagination control for returning a subset comprising this many records.
+     * @return array the list of favourites matching the criteria.
+     * @throws \dml_exception if any database errors are encountered.
+     */
+    public function find_by(array $criteria, int $limitfrom = 0, int $limitnum = 0) : array {
+        global $DB;
+        $records = $DB->get_records($this->favouritetable, $criteria, '', '*', $limitfrom, $limitnum);
+        return $this->get_list_of_favourites_from_records($records);
+    }
+
+    /**
+     * Find a specific favourite, based on the properties known to identify it.
+     *
+     * Used if we don't know its id.
+     *
+     * @param int $userid the id of the user to which the favourite belongs.
+     * @param string $component the frankenstyle component name.
+     * @param string $itemtype the type of the favourited item.
+     * @param int $itemid the id of the item which was favourited (not the favourite's id).
+     * @param int $contextid the contextid of the item which was favourited.
+     * @return favourite the favourite.
+     * @throws \dml_exception if any database errors are encountered or if the record could not be found.
+     */
+    public function find_favourite(int $userid, string $component, string $itemtype, int $itemid, int $contextid) : favourite {
+        global $DB;
+        // Favourites model: We know that only one favourite can exist based on these properties.
+        $record = $DB->get_record($this->favouritetable, [
+            'userid' => $userid,
+            'component' => $component,
+            'itemtype' => $itemtype,
+            'itemid' => $itemid,
+            'contextid' => $contextid
+        ], '*', MUST_EXIST);
+        return $this->get_favourite_from_record($record);
+    }
+
+    /**
+     * Check whether a favourite exists in this repository, based on its id.
+     *
+     * @param int $id the id to search for.
+     * @return bool true if the favourite exists, false otherwise.
+     * @throws \dml_exception if any database errors are encountered.
+     */
+    public function exists(int $id) : bool {
+        global $DB;
+        return $DB->record_exists($this->favouritetable, ['id' => $id]);
+    }
+
+    /**
+     * Check whether an item exists in this repository, based on the specified criteria.
+     *
+     * @param array $criteria the list of key/value criteria pairs.
+     * @return bool true if the favourite exists, false otherwise.
+     * @throws \dml_exception if any database errors are encountered.
+     */
+    public function exists_by(array $criteria) : bool {
+        global $DB;
+        return $DB->record_exists($this->favouritetable, $criteria);
+    }
+
+    /**
+     * Update a favourite.
+     *
+     * @param favourite $favourite the favourite to update.
+     * @return favourite the updated favourite.
+     * @throws \dml_exception if any database errors are encountered.
+     */
+    public function update(favourite $favourite) : favourite {
+        global $DB;
+        $time = time();
+        $favourite->timemodified = $time;
+        $DB->update_record($this->favouritetable, $favourite);
+        return $this->find($favourite->id);
+    }
+
+    /**
+     * Delete a favourite, by id.
+     *
+     * @param int $id the id of the favourite to delete.
+     * @throws \dml_exception if any database errors are encountered.
+     */
+    public function delete(int $id) {
+        global $DB;
+        $DB->delete_records($this->favouritetable, ['id' => $id]);
+    }
+
+    /**
+     * Delete all favourites matching the specified criteria.
+     *
+     * @param array $criteria the list of key/value criteria pairs.
+     * @throws \dml_exception if any database errors are encountered.
+     */
+    public function delete_by(array $criteria) {
+        global $DB;
+        $DB->delete_records($this->favouritetable, $criteria);
+    }
+
+    /**
+     * Return the total number of favourites in this repository.
+     *
+     * @return int the total number of items.
+     * @throws \dml_exception if any database errors are encountered.
+     */
+    public function count() : int {
+        global $DB;
+        return $DB->count_records($this->favouritetable);
+    }
+
+    /**
+     * Return the number of user favourites matching the specified criteria.
+     *
+     * @param array $criteria the list of key/value criteria pairs.
+     * @return int the number of favourites matching the criteria.
+     * @throws \dml_exception if any database errors are encountered.
+     */
+    public function count_by(array $criteria) : int {
+        global $DB;
+        return $DB->count_records($this->favouritetable, $criteria);
+    }
+}
diff --git a/favourites/classes/local/repository/favourite_repository_interface.php b/favourites/classes/local/repository/favourite_repository_interface.php
new file mode 100644 (file)
index 0000000..e0d36c1
--- /dev/null
@@ -0,0 +1,141 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Contains the favourite_repository interface.
+ *
+ * @package   core_favourites
+ * @copyright 2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_favourites\local\repository;
+use \core_favourites\local\entity\favourite;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The favourite_repository interface, defining the basic CRUD operations for favourite type items within core_favourites.
+ */
+interface favourite_repository_interface {
+    /**
+     * Add one item to this repository.
+     *
+     * @param favourite $item the item to add.
+     * @return favourite the item which was added.
+     */
+    public function add(favourite $item) : favourite;
+
+    /**
+     * Add all the items in the list to this repository.
+     *
+     * @param array $items the list of items to add.
+     * @return array the list of items added to this repository.
+     */
+    public function add_all(array $items) : array;
+
+    /**
+     * Find an item in this repository based on its id.
+     *
+     * @param int $id the id of the item.
+     * @return favourite the item.
+     */
+    public function find(int $id) : favourite;
+
+    /**
+     * Find all items in this repository.
+     *
+     * @param int $limitfrom optional pagination control for returning a subset of records, starting at this point.
+     * @param int $limitnum optional pagination control for returning a subset comprising this many records.
+     * @return array list of all items in this repository.
+     */
+    public function find_all(int $limitfrom = 0, int $limitnum = 0) : array;
+
+    /**
+     * Find all items with attributes matching certain values.
+     *
+     * @param array $criteria the array of attribute/value pairs.
+     * @param int $limitfrom optional pagination control for returning a subset of records, starting at this point.
+     * @param int $limitnum optional pagination control for returning a subset comprising this many records.
+     * @return array the list of items matching the criteria.
+     */
+    public function find_by(array $criteria, int $limitfrom = 0, int $limitnum = 0) : array;
+
+    /**
+     * Check whether an item exists in this repository, based on its id.
+     *
+     * @param int $id the id to search for.
+     * @return bool true if the item could be found, false otherwise.
+     */
+    public function exists(int $id) : bool;
+
+    /**
+     * Check whether an item exists in this repository, based on the specified criteria.
+     *
+     * @param array $criteria the list of key/value criteria pairs.
+     * @return bool true if the favourite exists, false otherwise.
+     */
+    public function exists_by(array $criteria) : bool;
+
+    /**
+     * Return the total number of items in this repository.
+     *
+     * @return int the total number of items.
+     */
+    public function count() : int;
+
+    /**
+     * Return the number of favourites matching the specified criteria.
+     *
+     * @param array $criteria the list of key/value criteria pairs.
+     * @return int the number of favourites matching the criteria.
+     */
+    public function count_by(array $criteria) : int;
+
+    /**
+     * Update an item within this repository.
+     *
+     * @param favourite $item the item to update.
+     * @return favourite the updated item.
+     */
+    public function update(favourite $item) : favourite;
+
+    /**
+     * Delete an item by id.
+     *
+     * @param int $id the id of the item to delete.
+     * @return void
+     */
+    public function delete(int $id);
+
+    /**
+     * Delete all favourites matching the specified criteria.
+     *
+     * @param array $criteria the list of key/value criteria pairs.
+     * @return void.
+     */
+    public function delete_by(array $criteria);
+
+    /**
+     * Find a single favourite, based on it's unique identifiers.
+     *
+     * @param int $userid the id of the user to which the favourite belongs.
+     * @param string $component the frankenstyle component name.
+     * @param string $itemtype the type of the favourited item.
+     * @param int $itemid the id of the item which was favourited (not the favourite's id).
+     * @param int $contextid the contextid of the item which was favourited.
+     * @return favourite the favourite.
+     */
+    public function find_favourite(int $userid, string $component, string $itemtype, int $itemid, int $contextid) : favourite;
+}
diff --git a/favourites/classes/local/service/user_favourite_service.php b/favourites/classes/local/service/user_favourite_service.php
new file mode 100644 (file)
index 0000000..d3b6f6d
--- /dev/null
@@ -0,0 +1,138 @@
+<?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/>.
+
+/**
+ * Contains the user_favourite_service class, part of the service layer for the favourites subsystem.
+ *
+ * @package   core_favourites
+ * @copyright 2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_favourites\local\service;
+use \core_favourites\local\entity\favourite;
+use \core_favourites\local\repository\favourite_repository_interface;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class service, providing an single API for interacting with the favourites subsystem for a SINGLE USER.
+ *
+ * This class is responsible for exposing key operations (add, remove, find) and enforces any business logic necessary to validate
+ * authorization/data integrity for these operations.
+ *
+ * All object persistence is delegated to the favourite_repository_interface object.
+ *
+ * @copyright 2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class user_favourite_service {
+
+    /** @var favourite_repository_interface $repo the favourite repository object. */
+    protected $repo;
+
+    /** @var int $userid the id of the user to which this favourites service is scoped. */
+    protected $userid;
+
+    /**
+     * The user_favourite_service constructor.
+     *
+     * @param \context_user $usercontext The context of the user to which this service operations are scoped.
+     * @param \core_favourites\local\repository\favourite_repository_interface $repository a favourites repository.
+     */
+    public function __construct(\context_user $usercontext, favourite_repository_interface $repository) {
+        $this->repo = $repository;
+        $this->userid = $usercontext->instanceid;
+    }
+
+    /**
+     * Favourite an item defined by itemid/context, in the area defined by component/itemtype.
+     *
+     * @param string $component the frankenstyle component name.
+     * @param string $itemtype the type of the item being favourited.
+     * @param int $itemid the id of the item which is to be favourited.
+     * @param \context $context the context in which the item is to be favourited.
+     * @param int|null $ordering optional ordering integer used for sorting the favourites in an area.
+     * @return favourite the favourite, once created.
+     * @throws \moodle_exception if the component name is invalid, or if the repository encounters any errors.
+     */
+    public function create_favourite(string $component, string $itemtype, int $itemid, \context $context,
+            int $ordering = null) : favourite {
+        // Access: Any component can ask to favourite something, we can't verify access to that 'something' here though.
+
+        // Validate the component name.
+        if (!in_array($component, \core_component::get_component_names())) {
+            throw new \moodle_exception("Invalid component name '$component'");
+        }
+
+        $favourite = new favourite($component, $itemtype, $itemid, $context->id, $this->userid);
+        $favourite->ordering = $ordering > 0 ? $ordering : null;
+        return $this->repo->add($favourite);
+    }
+
+    /**
+     * Find a list of favourites, by type, where type is the component/itemtype pair.
+     *
+     * E.g. "Find all favourite courses" might result in:
+     * $favcourses = find_favourites_by_type('core_course', 'course');
+     *
+     * @param string $component the frankenstyle component name.
+     * @param string $itemtype the type of the favourited item.
+     * @param int $limitfrom optional pagination control for returning a subset of records, starting at this point.
+     * @param int $limitnum optional pagination control for returning a subset comprising this many records.
+     * @return array the list of favourites found.
+     * @throws \moodle_exception if the component name is invalid, or if the repository encounters any errors.
+     */
+    public function find_favourites_by_type(string $component, string $itemtype, int $limitfrom = 0, int $limitnum = 0) : array {
+        if (!in_array($component, \core_component::get_component_names())) {
+            throw new \moodle_exception("Invalid component name '$component'");
+        }
+        return $this->repo->find_by(
+            [
+                'userid' => $this->userid,
+                'component' => $component,
+                'itemtype' => $itemtype
+            ],
+            $limitfrom,
+            $limitnum
+        );
+    }
+
+    /**
+     * Delete a favourite item from an area and from within a context.
+     *
+     * E.g. delete a favourite course from the area 'core_course', 'course' with itemid 3 and from within the CONTEXT_USER context.
+     *
+     * @param string $component the frankenstyle component name.
+     * @param string $itemtype the type of the favourited item.
+     * @param int $itemid the id of the item which was favourited (not the favourite's id).
+     * @param \context $context the context of the item which was favourited.
+     * @throws \moodle_exception if the user does not control the favourite, or it doesn't exist.
+     */
+    public function delete_favourite(string $component, string $itemtype, int $itemid, \context $context) {
+        if (!in_array($component, \core_component::get_component_names())) {
+            throw new \moodle_exception("Invalid component name '$component'");
+        }
+
+        // Business logic: check the user owns the favourite.
+        try {
+            $favourite = $this->repo->find_favourite($this->userid, $component, $itemtype, $itemid, $context->id);
+        } catch (\moodle_exception $e) {
+            throw new \moodle_exception("Favourite does not exist for the user. Cannot delete.");
+        }
+
+        $this->repo->delete($favourite->id);
+    }
+}
diff --git a/favourites/classes/privacy/provider.php b/favourites/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..0d94739
--- /dev/null
@@ -0,0 +1,143 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Privacy class for requesting user data for the favourites subsystem.
+ *
+ * @package    core_favourites
+ * @copyright  2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_favourites\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_privacy\local\metadata\collection;
+use \core_privacy\local\request\context;
+use \core_privacy\local\request\approved_contextlist;
+
+/**
+ * Privacy class for requesting user data.
+ *
+ * @copyright  2018 Jake Dallimore <jrhdallimore@gmail.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\subsystem\plugin_provider {
+
+    /**
+     * Returns metadata 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 {
+        return $collection->add_database_table('favourite', [
+            'userid' => 'privacy:metadata:favourite:userid',
+            'component' => 'privacy:metadata:favourite:component',
+            'itemtype' => 'privacy:metadata:favourite:itemtype',
+            'itemid' => 'privacy:metadata:favourite:itemid',
+            'ordering' => 'privacy:metadata:favourite:ordering',
+            'timecreated' => 'privacy:metadata:favourite:timecreated',
+            'timemodified' => 'privacy:metadata:favourite:timemodified',
+        ], 'privacy:metadata:favourite');
+    }
+
+    /**
+     * Provide a list of contexts which have favourites for the user, in the respective area (component/itemtype combination).
+     *
+     * This method is to be called by consumers of the favourites subsystem (plugins), in their get_contexts_for_userid() method,
+     * to add the contexts for items which may have been favourited, but would normally not be reported as having user data by the
+     * plugin responsible for them.
+     *
+     * Consider an example: Favourite courses.
+     * Favourite courses will be handled by the core_course subsystem and courses can be favourited at site context.
+     *
+     * Now normally, the course provider method get_contexts_for_userid() would report the context of any courses the user is in.
+     * Then, we'd export data for those contexts. This won't include courses the user has favourited, but is not a member of.
+     *
+     * To report the full list, the course provider needs to be made aware of the contexts of any courses the user may have marked
+     * as favourites. Course will need to ask th favourites subsystem for this - a call to add_contexts_for_userid($userid).
+     *
+     * Once called, if a course has been marked as a favourite, at site context, then we'd return the site context. During export,
+     * the consumer (course), just looks at all contexts and decides whether to export favourite courses for each one.
+     *
+     * @param \core_privacy\local\request\contextlist $contextlist
+     * @param int $userid The id of the user in scope.
+     * @param string $component the frankenstyle component name.
+     * @param string $itemtype the type of the favourited items.
+     */
+    public static function add_contexts_for_userid(\core_privacy\local\request\contextlist $contextlist, int $userid,
+                                                   string $component, string $itemtype = null) {
+        $sql = "SELECT contextid
+                  FROM {favourite} f
+                 WHERE userid = :userid
+                   AND component = :component";
+        if (!is_null($itemtype)) {
+            $sql .= " AND itemtype = :itemtype";
+        }
+        $params = ['userid' => $userid, 'component' => $component, 'itemtype' => $itemtype];
+        $contextlist->add_from_sql($sql, $params);
+    }
+
+    /**
+     * Delete all favourites for all users in the specified contexts, and component area.
+     *
+     * @param \context $context The context to which deletion is scoped.
+     * @param string $component The favourite's component name.
+     * @param string $itemtype The favourite's itemtype.
+     * @throws \dml_exception if any errors are encountered during deletion.
+     */
+    public static function delete_favourites_for_all_users(\context $context, string $component, string $itemtype) {
+        global $DB;
+
+        $params = [
+            'component' => $component,
+            'itemtype' => $itemtype,
+            'contextid' => $context->id
+        ];
+
+        $select = "component = :component AND itemtype =:itemtype AND contextid = :contextid";
+        $DB->delete_records_select('favourite', $select, $params);
+    }
+
+    /**
+     * Delete all favourites for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
+     * @param string $component
+     * @param string $itemtype
+     * @throws \coding_exception
+     * @throws \dml_exception
+     */
+    public static function delete_favourites_for_user(approved_contextlist $contextlist, string $component, string $itemtype) {
+        global $DB;
+
+        $userid = $contextlist->get_user()->id;
+
+        list($insql, $inparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
+
+        $params = [
+            'userid' => $userid,
+            'component' => $component,
+            'itemtype' => $itemtype,
+        ];
+        $params += $inparams;
+
+        $select = "userid = :userid AND component = :component AND itemtype =:itemtype AND contextid $insql";
+        $DB->delete_records_select('favourite', $select, $params);
+    }
+}
diff --git a/favourites/classes/service_factory.php b/favourites/classes/service_factory.php
new file mode 100644 (file)
index 0000000..f158003
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Contains the service_factory, a locator for services for the favourites subsystem.
+ *
+ * Services encapsulate the business logic, and any data manipulation code, and are what clients should interact with.
+ *
+ * @package   core_favourites
+ * @copyright 2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_favourites;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class service_factory, providing functions for location of service objects for the favourites subsystem.
+ *
+ * This class is responsible for providing service objects to clients only.
+ *
+ * @copyright 2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class service_factory {
+
+    /**
+     * Returns a basic service object providing operations for user favourites.
+     *
+     * @param \context_user $context the context of the user to which the service should be scoped.
+     * @return \core_favourites\local\service\user_favourite_service the service object.
+     */
+    public static function get_service_for_user_context(\context_user $context) : local\service\user_favourite_service {
+        return new local\service\user_favourite_service($context, new local\repository\favourite_repository());
+    }
+}
+
diff --git a/favourites/tests/privacy_test.php b/favourites/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..409282b
--- /dev/null
@@ -0,0 +1,137 @@
+<?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_favourites.
+ *
+ * @package    core_favourites
+ * @category   test
+ * @copyright  2018 Jake Dallimore <jrhdallimore@gmail.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_favourites\privacy\provider;
+
+/**
+ * Unit tests for favourites/classes/privacy/provider
+ *
+ * @copyright  2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class privacy_test extends provider_testcase {
+
+    public function setUp() {
+        $this->resetAfterTest(true);
+    }
+
+    /**
+     * Helper to set up some sample users and courses.
+     */
+    protected function set_up_courses_and_users() {
+        $user1 = self::getDataGenerator()->create_user();
+        $user1context = \context_user::instance($user1->id);
+        $user2 = self::getDataGenerator()->create_user();
+        $user2context = \context_user::instance($user2->id);
+        $course1 = self::getDataGenerator()->create_course();
+        $course2 = self::getDataGenerator()->create_course();
+        $course1context = context_course::instance($course1->id);
+        $course2context = context_course::instance($course2->id);
+        return [$user1, $user2, $user1context, $user2context, $course1context, $course2context];
+    }
+
+    /**
+     * Test confirming that contexts of favourited items can be added to the contextlist.
+     */
+    public function test_add_contexts_for_userid() {
+        list($user1, $user2, $user1context, $user2context, $course1context, $course2context) = $this->set_up_courses_and_users();
+
+        // Favourite 2 courses for user1 and 1 course for user2, all at the site context.
+        $ufservice1 = \core_favourites\service_factory::get_service_for_user_context($user1context);
+        $ufservice2 = \core_favourites\service_factory::get_service_for_user_context($user2context);
+        $systemcontext = context_system::instance();
+        $ufservice1->create_favourite('core_course', 'course', $course1context->instanceid, $systemcontext);
+        $ufservice1->create_favourite('core_course', 'course', $course2context->instanceid, $systemcontext);
+        $ufservice2->create_favourite('core_course', 'course', $course2context->instanceid, $systemcontext);
+        $this->assertCount(2, $ufservice1->find_favourites_by_type('core_course', 'course'));
+        $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'course'));
+
+        // Now, just for variety, let's assume you can favourite a course at user context, and do so for user1.
+        $ufservice1->create_favourite('core_course', 'course', $course1context->instanceid, $user1context);
+
+        // Now, ask the favourites privacy api to export contexts for favourites of the type we just created, for user1.
+        $contextlist = new \core_privacy\local\request\contextlist();
+        \core_favourites\privacy\provider::add_contexts_for_userid($contextlist, $user1->id, 'core_course', 'course');
+
+        // Verify we have two contexts in the list for user1.
+        $this->assertCount(2, $contextlist->get_contextids());
+
+        // And verify we only have the system context returned for user2.
+        $contextlist = new \core_privacy\local\request\contextlist();
+        \core_favourites\privacy\provider::add_contexts_for_userid($contextlist, $user2->id, 'core_course', 'course');
+        $this->assertCount(1, $contextlist->get_contextids());
+    }
+
+    /**
+     * Test deletion of user favourites based on an approved_contextlist and component area.
+     */
+    public function test_delete_favourites_for_user() {
+        list($user1, $user2, $user1context, $user2context, $course1context, $course2context) = $this->set_up_courses_and_users();
+
+        // Favourite 2 courses for user1 and 1 course for user2, all at the user context.
+        $ufservice1 = \core_favourites\service_factory::get_service_for_user_context($user1context);
+        $ufservice2 = \core_favourites\service_factory::get_service_for_user_context($user2context);
+        $ufservice1->create_favourite('core_course', 'course', $course1context->instanceid, $user1context);
+        $ufservice1->create_favourite('core_course', 'course', $course2context->instanceid, $user1context);
+        $ufservice2->create_favourite('core_course', 'course', $course2context->instanceid, $user2context);
+        $this->assertCount(2, $ufservice1->find_favourites_by_type('core_course', 'course'));
+        $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'course'));
+
+        // Now, delete the favourites for user1 only.
+        $approvedcontextlist = new \core_privacy\local\request\approved_contextlist($user1, 'core_course', [$user1context->id]);
+        provider::delete_favourites_for_user($approvedcontextlist, 'core_course', 'course');
+
+        // Verify that we have no favourite courses for user1 but that the records are in tact for user2.
+        $this->assertCount(0, $ufservice1->find_favourites_by_type('core_course', 'course'));
+        $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'course'));
+    }
+
+    public function test_delete_favourites_for_all_users() {
+        list($user1, $user2, $user1context, $user2context, $course1context, $course2context) = $this->set_up_courses_and_users();
+
+        // Favourite 2 course modules for user1 and 1 course module for user2 all in course 1 context.
+        $ufservice1 = \core_favourites\service_factory::get_service_for_user_context($user1context);
+        $ufservice2 = \core_favourites\service_factory::get_service_for_user_context($user2context);
+        $ufservice1->create_favourite('core_course', 'modules', 1, $course1context);
+        $ufservice1->create_favourite('core_course', 'modules', 2, $course1context);
+        $ufservice2->create_favourite('core_course', 'modules', 3, $course1context);
+
+        // Now, favourite a different course module for user2 in course 2.
+        $ufservice2->create_favourite('core_course', 'modules', 5, $course2context);
+
+        $this->assertCount(2, $ufservice1->find_favourites_by_type('core_course', 'modules'));
+        $this->assertCount(2, $ufservice2->find_favourites_by_type('core_course', 'modules'));
+
+        // Now, delete all course module favourites in the 'course1' context only.
+        provider::delete_favourites_for_all_users($course1context, 'core_course', 'modules');
+
+        // Verify that only a single favourite for user1 in course 1 remains.
+        $this->assertCount(0, $ufservice1->find_favourites_by_type('core_course', 'modules'));
+        $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'modules'));
+    }
+}
diff --git a/favourites/tests/repository_test.php b/favourites/tests/repository_test.php
new file mode 100644 (file)
index 0000000..a99f30f
--- /dev/null
@@ -0,0 +1,626 @@
+<?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/>.
+
+/**
+ * Testing the repository objects within core_favourites.
+ *
+ * @package    core_favourites
+ * @category   test
+ * @copyright  2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_favourites\local\repository\favourite_repository;
+use \core_favourites\local\entity\favourite;
+
+/**
+ * Test class covering the favourite_repository.
+ *
+ * @copyright  2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class favourite_repository_testcase extends advanced_testcase {
+
+    public function setUp() {
+        $this->resetAfterTest();
+    }
+
+    // Basic setup stuff to be reused in most tests.
+    protected function setup_users_and_courses() {
+        $user1 = self::getDataGenerator()->create_user();
+        $user1context = \context_user::instance($user1->id);
+        $user2 = self::getDataGenerator()->create_user();
+        $user2context = \context_user::instance($user2->id);
+        $course1 = self::getDataGenerator()->create_course();
+        $course2 = self::getDataGenerator()->create_course();
+        $course1context = context_course::instance($course1->id);
+        $course2context = context_course::instance($course2->id);
+        return [$user1context, $user2context, $course1context, $course2context];
+    }
+
+    /**
+     * Verify the basic create operation can create records, and is validated.
+     */
+    public function test_add() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Create a favourites repository and favourite a course.
+        $favouritesrepo = new favourite_repository($user1context);
+
+        $favcourse = new favourite(
+            'core_course',
+            'course',
+            $course1context->instanceid,
+            $course1context->id,
+            $user1context->instanceid
+        );
+        $timenow = time(); // Reference only, to check that the created item has a time equal to or greater than this.
+        $favourite = $favouritesrepo->add($favcourse);
+
+        // Verify we get the record back.
+        $this->assertInstanceOf(favourite::class, $favourite);
+        $this->assertObjectHasAttribute('id', $favourite);
+        $this->assertEquals('core_course', $favourite->component);
+        $this->assertEquals('course', $favourite->itemtype);
+
+        // Verify the returned object has additional properties, created as part of the add.
+        $this->assertObjectHasAttribute('ordering', $favourite);
+        $this->assertObjectHasAttribute('timecreated', $favourite);
+        $this->assertGreaterThanOrEqual($timenow, $favourite->timecreated);
+
+        // Try to save the same record again and confirm the store throws an exception.
+        $this->expectException('dml_write_exception');
+        $favouritesrepo->add($favcourse);
+    }
+
+    /**
+     * Tests that malformed favourites cannot be saved.
+     */
+    public function test_add_malformed_favourite() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Create a favourites repository and favourite a course.
+        $favouritesrepo = new favourite_repository($user1context);
+
+        $favcourse = new favourite(
+            'core_course',
+            'course',
+            $course1context->instanceid,
+            $course1context->id,
+            $user1context->instanceid
+        );
+        $favcourse->something = 'something';
+
+        $this->expectException('moodle_exception');
+        $favouritesrepo->add($favcourse);
+    }
+
+    /**
+     * Tests that incomplete favourites cannot be saved.
+     */
+    public function test_add_incomplete_favourite() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Create a favourites repository and try to favourite a course.
+        $favouritesrepo = new favourite_repository($user1context);
+
+        $favcourse = new favourite(
+            'core_course',
+            'course',
+            $course1context->instanceid,
+            $course1context->id,
+            $user1context->instanceid
+        );
+        unset($favcourse->userid);
+
+        $this->expectException('moodle_exception');
+        $favouritesrepo->add($favcourse);
+    }
+
+    public function test_add_all_basic() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Create a favourites repository and favourite several courses.
+        $favouritesrepo = new favourite_repository($user1context);
+        $favcourses = [];
+
+        $favcourses[] = new favourite(
+            'core_course',
+            'course',
+            $course1context->instanceid,
+            $course1context->id,
+            $user1context->instanceid
+        );
+        $favcourses[] = new favourite(
+            'core_course',
+            'course',
+            $course2context->instanceid,
+            $course2context->id,
+            $user1context->instanceid
+        );
+
+        $timenow = time(); // Reference only, to check that the created item has a time equal to or greater than this.
+        $favourites = $favouritesrepo->add_all($favcourses);
+
+        $this->assertInternalType('array', $favourites);
+        $this->assertCount(2, $favourites);
+        foreach ($favourites as $favourite) {
+            // Verify we get the favourite back.
+            $this->assertInstanceOf(favourite::class, $favourite);
+            $this->assertEquals('core_course', $favourite->component);
+            $this->assertEquals('course', $favourite->itemtype);
+
+            // Verify the returned object has additional properties, created as part of the add.
+            $this->assertObjectHasAttribute('ordering', $favourite);
+            $this->assertObjectHasAttribute('timecreated', $favourite);
+            $this->assertGreaterThanOrEqual($timenow, $favourite->timecreated);
+        }
+
+        // Try to save the same record again and confirm the store throws an exception.
+        $this->expectException('dml_write_exception');
+        $favouritesrepo->add_all($favcourses);
+    }
+
+    /**
+     * Tests reading from the repository by instance id.
+     */
+    public function test_find() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Create a favourites repository and favourite a course.
+        $favouritesrepo = new favourite_repository($user1context);
+        $favourite = new favourite(
+            'core_course',
+            'course',
+            $course1context->instanceid,
+            $course1context->id,
+            $user1context->instanceid
+        );
+        $favourite = $favouritesrepo->add($favourite);
+
+        // Now, from the repo, get the single favourite we just created, by id.
+        $userfavourite = $favouritesrepo->find($favourite->id);
+        $this->assertInstanceOf(favourite::class, $userfavourite);
+        $this->assertObjectHasAttribute('timecreated', $userfavourite);
+
+        // Try to get a favourite we know doesn't exist.
+        // We expect an exception in this case.
+        $this->expectException(dml_exception::class);
+        $favouritesrepo->find(0);
+    }
+
+    /**
+     * Test verifying that find_all() returns all favourites, or an empty array.
+     */
+    public function test_find_all() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        $favouritesrepo = new favourite_repository($user1context);
+
+        // Verify that for an empty repository, find_all returns an empty array.
+        $this->assertEquals([], $favouritesrepo->find_all());
+
+        // Save a favourite for 2 courses, in different areas.
+        $favourite = new favourite(
+            'core_course',
+            'course',
+            $course1context->instanceid,
+            $course1context->id,
+            $user1context->instanceid
+        );
+        $favourite2 = new favourite(
+            'core_course',
+            'course',
+            $course2context->instanceid,
+            $course2context->id,
+            $user1context->instanceid
+        );
+        $favouritesrepo->add($favourite);
+        $favouritesrepo->add($favourite2);
+
+        // Verify that find_all returns both of our favourites.
+        $favourites = $favouritesrepo->find_all();
+        $this->assertCount(2, $favourites);
+        foreach ($favourites as $fav) {
+            $this->assertInstanceOf(favourite::class, $fav);
+            $this->assertObjectHasAttribute('id', $fav);
+            $this->assertObjectHasAttribute('timecreated', $fav);
+        }
+    }
+
+    /**
+     * Testing the pagination of the find_all method.
+     */
+    public function test_find_all_pagination() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        $favouritesrepo = new favourite_repository($user1context);
+
+        // Verify that for an empty repository, find_all with any combination of page options returns an empty array.
+        $this->assertEquals([], $favouritesrepo->find_all(0, 0));
+        $this->assertEquals([], $favouritesrepo->find_all(0, 10));
+        $this->assertEquals([], $favouritesrepo->find_all(1, 0));
+        $this->assertEquals([], $favouritesrepo->find_all(1, 10));
+
+        // Save 10 arbitrary favourites to the repo.
+        foreach (range(1, 10) as $i) {
+            $favourite = new favourite(
+                'core_course',
+                'course',
+                $i,
+                $course1context->id,
+                $user1context->instanceid
+            );
+            $favouritesrepo->add($favourite);
+        }
+
+        // Verify we have 10 favourites.
+        $this->assertEquals(10, $favouritesrepo->count());
+
+        // Verify we can fetch the first page of 5 records.
+        $favourites = $favouritesrepo->find_all(0, 5);
+        $this->assertCount(5, $favourites);
+
+        // Verify we can fetch the second page.
+        $favourites = $favouritesrepo->find_all(5, 5);
+        $this->assertCount(5, $favourites);
+
+        // Verify the third page request ends with an empty array.
+        $favourites = $favouritesrepo->find_all(10, 5);
+        $this->assertCount(0, $favourites);
+    }
+
+    /**
+     * Test retrieval of a user's favourites for a given criteria, in this case, area.
+     */
+    public function test_find_by() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Create a favourites repository and favourite a course.
+        $favouritesrepo = new favourite_repository($user1context);
+        $favourite = new favourite(
+            'core_course',
+            'course',
+            $course1context->instanceid,
+            $course1context->id,
+            $user1context->instanceid
+        );
+        $favouritesrepo->add($favourite);
+
+        // From the repo, get the list of favourites for the 'core_course/course' area.
+        $userfavourites = $favouritesrepo->find_by(['component' => 'core_course', 'itemtype' => 'course']);
+        $this->assertInternalType('array', $userfavourites);
+        $this->assertCount(1, $userfavourites);
+
+        // Try to get a list of favourites for a non-existent area.
+        $userfavourites = $favouritesrepo->find_by(['component' => 'core_cannibalism', 'itemtype' => 'course']);
+        $this->assertInternalType('array', $userfavourites);
+        $this->assertCount(0, $userfavourites);
+    }
+
+    /**
+     * Testing the pagination of the find_by method.
+     */
+    public function test_find_by_pagination() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        $favouritesrepo = new favourite_repository($user1context);
+
+        // Verify that for an empty repository, find_all with any combination of page options returns an empty array.
+        $this->assertEquals([], $favouritesrepo->find_by([], 0, 0));
+        $this->assertEquals([], $favouritesrepo->find_by([], 0, 10));
+        $this->assertEquals([], $favouritesrepo->find_by([], 1, 0));
+        $this->assertEquals([], $favouritesrepo->find_by([], 1, 10));
+
+        // Save 10 arbitrary favourites to the repo.
+        foreach (range(1, 10) as $i) {
+            $favourite = new favourite(
+                'core_course',
+                'course',
+                $i,
+                $course1context->id,
+                $user1context->instanceid
+            );
+            $favouritesrepo->add($favourite);
+        }
+
+        // Verify we have 10 favourites.
+        $this->assertEquals(10, $favouritesrepo->count());
+
+        // Verify a request for a page, when no criteria match, results in an empty array.
+        $favourites = $favouritesrepo->find_by(['component' => 'core_message'], 0, 5);
+        $this->assertCount(0, $favourites);
+
+        // Verify we can fetch a the first page of 5 records.
+        $favourites = $favouritesrepo->find_by(['component' => 'core_course'], 0, 5);
+        $this->assertCount(5, $favourites);
+
+        // Verify we can fetch the second page.
+        $favourites = $favouritesrepo->find_by(['component' => 'core_course'], 5, 5);
+        $this->assertCount(5, $favourites);
+
+        // Verify the third page request ends with an empty array.
+        $favourites = $favouritesrepo->find_by(['component' => 'core_course'], 10, 5);
+        $this->assertCount(0, $favourites);
+    }
+
+    /**
+     * Test the count_by() method.
+     */
+    public function test_count_by() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Create a favourites repository and add 2 favourites in different areas.
+        $favouritesrepo = new favourite_repository($user1context);
+        $favourite = new favourite(
+            'core_course',
+            'course',
+            $course1context->instanceid,
+            $course1context->id,
+            $user1context->instanceid
+        );
+        $favourite2 = new favourite(
+            'core_course',
+            'anothertype',
+            $course2context->instanceid,
+            $course2context->id,
+            $user1context->instanceid
+        );
+        $favouritesrepo->add($favourite);
+        $favouritesrepo->add($favourite2);
+
+        // Verify counts can be restricted by criteria.
+        $this->assertEquals(1, $favouritesrepo->count_by(['userid' => $user1context->instanceid, 'component' => 'core_course',
+                'itemtype' => 'course']));
+        $this->assertEquals(1, $favouritesrepo->count_by(['userid' => $user1context->instanceid, 'component' => 'core_course',
+            'itemtype' => 'anothertype']));
+        $this->assertEquals(0, $favouritesrepo->count_by(['userid' => $user1context->instanceid, 'component' => 'core_course',
+            'itemtype' => 'nonexistenttype']));
+    }
+
+    /**
+     * Test the exists() function.
+     */
+    public function test_exists() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Create a favourites repository and favourite a course.
+        $favouritesrepo = new favourite_repository($user1context);
+        $favourite = new favourite(
+            'core_course',
+            'course',
+            $course1context->instanceid,
+            $course1context->id,
+            $user1context->instanceid
+        );
+        $createdfavourite = $favouritesrepo->add($favourite);
+
+        // Verify the existence of the favourite in the repo.
+        $this->assertTrue($favouritesrepo->exists($createdfavourite->id));
+
+        // Verify exists returns false for non-existent favourite.
+        $this->assertFalse($favouritesrepo->exists(0));
+    }
+
+    /**
+     * Test the exists_by() method.
+     */
+    public function test_exists_by() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Create a favourites repository and favourite two courses, in different areas.
+        $favouritesrepo = new favourite_repository($user1context);
+        $favourite = new favourite(
+            'core_course',
+            'course',
+            $course1context->instanceid,
+            $course1context->id,
+            $user1context->instanceid
+        );
+        $favourite2 = new favourite(
+            'core_course',
+            'anothertype',
+            $course2context->instanceid,
+            $course2context->id,
+            $user1context->instanceid
+        );
+        $favourite1 = $favouritesrepo->add($favourite);
+        $favourite2 = $favouritesrepo->add($favourite2);
+
+        // Verify the existence of the favourites.
+        $this->assertTrue($favouritesrepo->exists_by(
+            [
+                'userid' => $user1context->instanceid,
+                'component' => 'core_course',
+                'itemtype' => 'course',
+                'itemid' => $favourite1->itemid,
+                'contextid' => $favourite1->contextid
+            ]
+        ));
+        $this->assertTrue($favouritesrepo->exists_by(
+            [
+                'userid' => $user1context->instanceid,
+                'component' => 'core_course',
+                'itemtype' => 'anothertype',
+                'itemid' => $favourite2->itemid,
+                'contextid' => $favourite2->contextid
+            ]
+        ));
+
+        // Verify that we can't find a favourite from one area, in another.
+        $this->assertFalse($favouritesrepo->exists_by(
+            [
+                'userid' => $user1context->instanceid,
+                'component' => 'core_course',
+                'itemtype' => 'anothertype',
+                'itemid' => $favourite1->itemid,
+                'contextid' => $favourite1->contextid
+            ]
+        ));
+    }
+
+    /**
+     * Test the update() method, by simulating a user changing the ordering of a favourite.
+     */
+    public function test_update() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Create a favourites repository and favourite a course.
+        $favouritesrepo = new favourite_repository($user1context);
+        $favourite = new favourite(
+            'core_course',
+            'course',
+            $course1context->instanceid,
+            $course1context->id,
+            $user1context->instanceid
+        );
+        $favourite1 = $favouritesrepo->add($favourite);
+        $this->assertNull($favourite1->ordering);
+
+        // Verify we can update the ordering for 2 favourites.
+        $favourite1->ordering = 1;
+        $favourite1 = $favouritesrepo->update($favourite1);
+        $this->assertInstanceOf(favourite::class, $favourite1);
+        $this->assertAttributeEquals('1', 'ordering', $favourite1);
+    }
+
+    public function test_delete() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Create a favourites repository and favourite a course.
+        $favouritesrepo = new favourite_repository($user1context);
+        $favourite = new favourite(
+            'core_course',
+            'course',
+            $course1context->instanceid,
+            $course1context->id,
+            $user1context->instanceid
+        );
+        $favourite = $favouritesrepo->add($favourite);
+
+        // Verify the existence of the favourite in the repo.
+        $this->assertTrue($favouritesrepo->exists($favourite->id));
+
+        // Now, delete the favourite and confirm it's not retrievable.
+        $favouritesrepo->delete($favourite->id);
+        $this->assertFalse($favouritesrepo->exists($favourite->id));
+    }
+
+    /**
+     * Test the delete_by() method.
+     */
+    public function test_delete_by() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Create a favourites repository and favourite two courses, in different areas.
+        $favouritesrepo = new favourite_repository($user1context);
+        $favourite = new favourite(
+            'core_course',
+            'course',
+            $course1context->instanceid,
+            $course1context->id,
+            $user1context->instanceid
+        );
+        $favourite2 = new favourite(
+            'core_course',
+            'anothertype',
+            $course1context->instanceid,
+            $course1context->id,
+            $user1context->instanceid
+        );
+        $favourite1 = $favouritesrepo->add($favourite);
+        $favourite2 = $favouritesrepo->add($favourite2);
+
+        // Verify we have 2 items in the repo.
+        $this->assertEquals(2, $favouritesrepo->count());
+
+        // Try to delete by a non-existent area, and confirm it doesn't remove anything.
+        $favouritesrepo->delete_by(
+            [
+                'userid' => $user1context->instanceid,
+                'component' => 'core_course',
+                'itemtype' => 'donaldduck'
+            ]
+        );
+        $this->assertEquals(2, $favouritesrepo->count());
+
+        // Try to delete by a non-existent area, and confirm it doesn't remove anything.
+        $favouritesrepo->delete_by(
+            [
+                'userid' => $user1context->instanceid,
+                'component' => 'core_course',
+                'itemtype' => 'cat'
+            ]
+        );
+        $this->assertEquals(2, $favouritesrepo->count());
+
+        // Delete by area, and confirm we have one record left, from the 'core_course/anothertype' area.
+        $favouritesrepo->delete_by(
+            [
+                'userid' => $user1context->instanceid,
+                'component' => 'core_course',
+                'itemtype' => 'course'
+            ]
+        );
+        $this->assertEquals(1, $favouritesrepo->count());
+        $this->assertFalse($favouritesrepo->exists($favourite1->id));
+        $this->assertTrue($favouritesrepo->exists($favourite2->id));
+    }
+
+    /**
+     * Test the find_favourite() method for an existing favourite.
+     */
+    public function test_find_favourite_basic() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Create a favourites repository and favourite two courses, in different areas.
+        $favouritesrepo = new favourite_repository($user1context);
+        $favourite = new favourite(
+            'core_course',
+            'course',
+            $course1context->instanceid,
+            $course1context->id,
+            $user1context->instanceid
+        );
+        $favourite2 = new favourite(
+            'core_course',
+            'anothertype',
+            $course1context->instanceid,
+            $course1context->id,
+            $user1context->instanceid
+        );
+        $favourite1 = $favouritesrepo->add($favourite);
+        $favourite2 = $favouritesrepo->add($favourite2);
+
+        $fav = $favouritesrepo->find_favourite($user1context->instanceid, 'core_course', 'course', $course1context->instanceid,
+            $course1context->id);
+        $this->assertInstanceOf(\core_favourites\local\entity\favourite::class, $fav);
+    }
+
+    /**
+     * Test confirming the repository throws an exception in find_favourite if the favourite can't be found.
+     */
+    public function test_find_favourite_nonexistent_favourite() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Confirm we get an exception.
+        $favouritesrepo = new favourite_repository($user1context);
+        $this->expectException(\dml_exception::class);
+        $favouritesrepo->find_favourite($user1context->instanceid, 'core_course', 'course', 0, $course1context->id);
+    }
+}
diff --git a/favourites/tests/service_test.php b/favourites/tests/service_test.php
new file mode 100644 (file)
index 0000000..6600c2d
--- /dev/null
@@ -0,0 +1,308 @@
+<?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/>.
+
+/**
+ * Testing the service layer within core_favourites.
+ *
+ * @package    core_favourites
+ * @category   test
+ * @copyright  2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+use \core_favourites\local\entity\favourite;
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Test class covering the user_favourite_service within the service layer of favourites.
+ *
+ * @copyright  2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class user_favourite_service_testcase extends advanced_testcase {
+
+    public function setUp() {
+        $this->resetAfterTest();
+    }
+
+    // Basic setup stuff to be reused in most tests.
+    protected function setup_users_and_courses() {
+        $user1 = self::getDataGenerator()->create_user();
+        $user1context = \context_user::instance($user1->id);
+        $user2 = self::getDataGenerator()->create_user();
+        $user2context = \context_user::instance($user2->id);
+        $course1 = self::getDataGenerator()->create_course();
+        $course2 = self::getDataGenerator()->create_course();
+        $course1context = context_course::instance($course1->id);
+        $course2context = context_course::instance($course2->id);
+        return [$user1context, $user2context, $course1context, $course2context];
+    }
+
+    /**
+     * Generates an in-memory repository for testing, using an array store for CRUD stuff.
+     *
+     * @param array $mockstore
+     * @return \PHPUnit\Framework\MockObject\MockObject
+     */
+    protected function get_mock_repository(array $mockstore) {
+        // This mock will just store data in an array.
+        $mockrepo = $this->getMockBuilder(\core_favourites\local\repository\favourite_repository_interface::class)
+            ->setMethods([])
+            ->getMock();
+        $mockrepo->expects($this->any())
+            ->method('add')
+            ->will($this->returnCallback(function(favourite $favourite) use (&$mockstore) {
+                // Mock implementation of repository->add(), where an array is used instead of the DB.
+                // Duplicates are confirmed via the unique key, and exceptions thrown just like a real repo.
+                $key = $favourite->userid . $favourite->component . $favourite->itemtype . $favourite->itemid
+                    . $favourite->contextid;
+
+                // Check the objects for the unique key.
+                foreach ($mockstore as $item) {
+                    if ($item->uniquekey == $key) {
+                        throw new \moodle_exception('Favourite already exists');
+                    }
+                }
+                $index = count($mockstore);     // Integer index.
+                $favourite->uniquekey = $key;   // Simulate the unique key constraint.
+                $favourite->id = $index;
+                $mockstore[$index] = $favourite;
+                return $mockstore[$index];
+            })
+        );
+        $mockrepo->expects($this->any())
+            ->method('find_by')
+            ->will($this->returnCallback(function(array $criteria, int $limitfrom = 0, int $limitnum = 0) use (&$mockstore) {
+                // Check the mockstore for all objects with properties matching the key => val pairs in $criteria.
+                foreach ($mockstore as $index => $mockrow) {
+                    $mockrowarr = (array)$mockrow;
+                    if (array_diff($criteria, $mockrowarr) == []) {
+                        $returns[$index] = $mockrow;
+                    }
+                }
+                // Return a subset of the records, according to the paging options, if set.
+                if ($limitnum != 0) {
+                    return array_slice($returns, $limitfrom, $limitnum);
+                }
+                // Otherwise, just return the full set.
+                return $returns;
+            })
+        );
+        $mockrepo->expects($this->any())
+            ->method('find_favourite')
+            ->will($this->returnCallback(function(int $userid, string $comp, string $type, int $id, int $ctxid) use (&$mockstore) {
+                // Check the mockstore for all objects with properties matching the key => val pairs in $criteria.
+                $crit = ['userid' => $userid, 'component' => $comp, 'itemtype' => $type, 'itemid' => $id, 'contextid' => $ctxid];
+                foreach ($mockstore as $fakerow) {
+                    $fakerowarr = (array)$fakerow;
+                    if (array_diff($crit, $fakerowarr) == []) {
+                        return $fakerow;
+                    }
+                }
+                throw new \moodle_exception("Item not found");
+            })
+        );
+        $mockrepo->expects($this->any())
+            ->method('find')
+            ->will($this->returnCallback(function(int $id) use (&$mockstore) {
+                return $mockstore[$id];
+            })
+        );
+        $mockrepo->expects($this->any())
+            ->method('exists')
+            ->will($this->returnCallback(function(int $id) use (&$mockstore) {
+                return array_key_exists($id, $mockstore);
+            })
+        );
+        $mockrepo->expects($this->any())
+            ->method('delete')
+            ->will($this->returnCallback(function(int $id) use (&$mockstore) {
+                foreach ($mockstore as $mockrow) {
+                    if ($mockrow->id == $id) {
+                        unset($mockstore[$id]);
+                    }
+                }
+            })
+        );
+        return $mockrepo;
+    }
+
+    /**
+     * Test getting a user_favourite_service from the static locator.
+     */
+    public function test_get_service_for_user_context() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+        $userservice = \core_favourites\service_factory::get_service_for_user_context($user1context);
+        $this->assertInstanceOf(\core_favourites\local\service\user_favourite_service::class, $userservice);
+    }
+
+    /**
+     * Test confirming an item can be favourited only once.
+     */
+    public function test_create_favourite_basic() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Get a user_favourite_service for a user.
+        $repo = $this->get_mock_repository([]); // Mock repository, using the array as a mock DB.
+        $user1service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
+
+        // Favourite a course.
+        $favourite1 = $user1service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
+        $this->assertObjectHasAttribute('id', $favourite1);
+
+        // Try to favourite the same course again.
+        $this->expectException('moodle_exception');
+        $user1service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
+    }
+
+    /**
+     * Test confirming that an exception is thrown if trying to favourite an item for a non-existent component.
+     */
+    public function test_create_favourite_nonexistent_component() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Get a user_favourite_service for the user.
+        $repo = $this->get_mock_repository([]); // Mock repository, using the array as a mock DB.
+        $user1service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
+
+        // Try to favourite something in a non-existent component.
+        $this->expectException('moodle_exception');
+        $user1service->create_favourite('core_cccourse', 'my_area', $course1context->instanceid, $course1context);
+    }
+
+    /**
+     * Test fetching favourites for single user, by area.
+     */
+    public function test_find_favourites_by_type_single_user() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Get a user_favourite_service for the user.
+        $repo = $this->get_mock_repository([]); // Mock repository, using the array as a mock DB.
+        $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
+
+        // Favourite 2 courses, in separate areas.
+        $fav1 = $service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
+        $fav2 = $service->create_favourite('core_course', 'anothertype', $course2context->instanceid, $course2context);
+
+        // Verify we can get favourites by area.
+        $favourites = $service->find_favourites_by_type('core_course', 'course');
+        $this->assertInternalType('array', $favourites);
+        $this->assertCount(1, $favourites); // We only get favourites for the 'core_course/course' area.
+        $this->assertAttributeEquals($fav1->id, 'id', $favourites[$fav1->id]);
+
+        $favourites = $service->find_favourites_by_type('core_course', 'anothertype');
+        $this->assertInternalType('array', $favourites);
+        $this->assertCount(1, $favourites); // We only get favourites for the 'core_course/course' area.
+        $this->assertAttributeEquals($fav2->id, 'id', $favourites[$fav2->id]);
+    }
+
+    /**
+     * Make sure the find_favourites_by_type() method only returns favourites for the scoped user.
+     */
+    public function test_find_favourites_by_type_multiple_users() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Get a user_favourite_service for 2 users.
+        $repo = $this->get_mock_repository([]);
+        $user1service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
+        $user2service = new \core_favourites\local\service\user_favourite_service($user2context, $repo);
+
+        // Now, as each user, favourite the same course.
+        $fav1 = $user1service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
+        $fav2 = $user2service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
+
+        // Verify find_favourites_by_type only returns results for the user to which the service is scoped.
+        $user1favourites = $user1service->find_favourites_by_type('core_course', 'course');
+        $this->assertInternalType('array', $user1favourites);
+        $this->assertCount(1, $user1favourites); // We only get favourites for the 'core_course/course' area for $user1.
+        $this->assertAttributeEquals($fav1->id, 'id', $user1favourites[$fav1->id]);
+
+        $user2favourites = $user2service->find_favourites_by_type('core_course', 'course');
+        $this->assertInternalType('array', $user2favourites);
+        $this->assertCount(1, $user2favourites); // We only get favourites for the 'core_course/course' area for $user2.
+        $this->assertAttributeEquals($fav2->id, 'id', $user2favourites[$fav2->id]);
+    }
+
+    /**
+     * Test confirming that an exception is thrown if trying to get favourites for a non-existent component.
+     */
+    public function test_find_favourites_by_type_nonexistent_component() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Get a user_favourite_service for the user.
+        $repo = $this->get_mock_repository([]);
+        $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
+
+        // Verify we get an exception if we try to search for favourites in an invalid component.
+        $this->expectException('moodle_exception');
+        $service->find_favourites_by_type('cccore_notreal', 'something');
+    }
+
+    /**
+     * Test confirming the pagination support for the find_favourites_by_type() method.
+     */
+    public function test_find_favourites_by_type_pagination() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Get a user_favourite_service for the user.
+        $repo = $this->get_mock_repository([]);
+        $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
+
+        // Favourite 10 arbitrary items.
+        foreach (range(1, 10) as $i) {
+            $service->create_favourite('core_course', 'course', $i, $course1context);
+        }
+
+        // Verify we have 10 favourites.
+        $this->assertCount(10, $service->find_favourites_by_type('core_course', 'course'));
+
+        // Verify we get back 5 favourites for page 1.
+        $favourites = $service->find_favourites_by_type('core_course', 'course', 0, 5);
+        $this->assertCount(5, $favourites);
+
+        // Verify we get back 5 favourites for page 2.
+        $favourites = $service->find_favourites_by_type('core_course', 'course', 5, 5);
+        $this->assertCount(5, $favourites);
+
+        // Verify we get back an empty array if querying page 3.
+        $favourites = $service->find_favourites_by_type('core_course', 'course', 10, 5);
+        $this->assertCount(0, $favourites);
+    }
+
+    /**
+     * Test confirming the basic deletion behaviour.
+     */
+    public function test_delete_favourite_basic() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Get a user_favourite_service for the user.
+        $repo = $this->get_mock_repository([]);
+        $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
+
+        // Favourite a course.
+        $fav1 = $service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
+        $this->assertTrue($repo->exists($fav1->id));
+
+        // Delete the favourite.
+        $service->delete_favourite('core_course', 'course', $course1context->instanceid, $course1context);
+
+        // Verify the favourite doesn't exist.
+        $this->assertFalse($repo->exists($fav1->id));
+
+        // Try to delete a favourite which we know doesn't exist.
+        $this->expectException(\moodle_exception::class);
+        $service->delete_favourite('core_course', 'course', $course1context->instanceid, $course1context);
+    }
+}
index b6e7d1d..4d9c9fc 100644 (file)
@@ -314,7 +314,20 @@ class tablelog extends \table_sql implements \renderable {
         if ($this->is_downloading()) {
             return $history->feedback;
         } else {
-            return format_text($history->feedback, $history->feedbackformat, array('context' => $this->context));
+            // We need the activity context, not the course context.
+            $gradeitem = $this->gradeitems[$history->itemid];
+            $context = $gradeitem->get_context();
+
+            $feedback = file_rewrite_pluginfile_urls(
+                $history->feedback,
+                'pluginfile.php',
+                $context->id,
+                GRADE_FILE_COMPONENT,
+                GRADE_HISTORY_FEEDBACK_FILEAREA,
+                $history->id
+            );
+
+            return format_text($feedback, $history->feedbackformat, array('context' => $context));
         }
     }
 
index ea40100..dab9bb5 100644 (file)
@@ -685,16 +685,30 @@ class grade_report_user extends grade_report {
                     $gradeitemdata['feedback'] = '';
                     $gradeitemdata['feedbackformat'] = $grade_grade->feedbackformat;
 
+                    if ($grade_grade->feedback) {
+                        $grade_grade->feedback = file_rewrite_pluginfile_urls(
+                            $grade_grade->feedback,
+                            'pluginfile.php',
+                            $grade_grade->get_context()->id,
+                            GRADE_FILE_COMPONENT,
+                            GRADE_FEEDBACK_FILEAREA,
+                            $grade_grade->id
+                        );
+                    }
+
                     if ($grade_grade->overridden > 0 AND ($type == 'categoryitem' OR $type == 'courseitem')) {
                     $data['feedback']['class'] = $classfeedback.' feedbacktext';
-                        $data['feedback']['content'] = get_string('overridden', 'grades').': ' . format_text($grade_grade->feedback, $grade_grade->feedbackformat);
+                        $data['feedback']['content'] = get_string('overridden', 'grades').': ' .
+                            format_text($grade_grade->feedback, $grade_grade->feedbackformat,
+                                ['context' => $grade_grade->get_context()]);
                         $gradeitemdata['feedback'] = $grade_grade->feedback;
                     } else if (empty($grade_grade->feedback) or (!$this->canviewhidden and $grade_grade->is_hidden())) {
                         $data['feedback']['class'] = $classfeedback.' feedbacktext';
                         $data['feedback']['content'] = '&nbsp;';
                     } else {
                         $data['feedback']['class'] = $classfeedback.' feedbacktext';
-                        $data['feedback']['content'] = format_text($grade_grade->feedback, $grade_grade->feedbackformat);
+                        $data['feedback']['content'] = format_text($grade_grade->feedback, $grade_grade->feedbackformat,
+                            ['context' => $grade_grade->get_context()]);
                         $gradeitemdata['feedback'] = $grade_grade->feedback;
                     }
                     $data['feedback']['headers'] = "$header_cat $header_row feedback";
index ad1b1a9..dd31c0c 100644 (file)
@@ -919,6 +919,7 @@ $string['profilerequired'] = 'Is this field required?';
 $string['profileroles'] = 'Profile visible roles';
 $string['profilesforenrolledusersonly'] = 'Profiles for enrolled users only';
 $string['profileshortname'] = 'Short name (must be unique)';
+$string['profileshortnameinvalid'] = 'This short name can only contain alphanumeric characters (letters and numbers) or underscore (_).';
 $string['profileshortnamenotunique'] = 'This short name is already in use';
 $string['profilesignup'] = 'Display on signup page?';
 $string['profilespecificsettings'] = 'Specific settings';
index deb03d1..02e1dea 100644 (file)
@@ -30,6 +30,7 @@ $string['allselectedusers'] = 'All selected ({$a->count}/{$a->total})';
 $string['allusers'] = 'All users ({$a})';
 $string['available'] = 'Available';
 $string['confirmmessage'] = 'Do you really want to send the message above to all these users?<br />{$a}';
+$string['deleteduser'] = 'Deleted user';
 $string['nofilteredusers'] = 'No users found (0/{$a})';
 $string['noselectedusers'] = 'No users selected';
 $string['removeall'] = 'Remove all';
index 873ad79..c1939ef 100644 (file)
@@ -137,3 +137,5 @@ previewhtml,core
 messagedselecteduserfailed,core
 eventmessagecontactblocked,core_message
 eventmessagecontactunblocked,core_message
+userisblockingyou,core_message
+userisblockingyounoncontact,core_message
\ No newline at end of file
diff --git a/lang/en/favourites.php b/lang/en/favourites.php
new file mode 100644 (file)
index 0000000..ea13dd2
--- /dev/null
@@ -0,0 +1,30 @@
+<?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/>.
+/**
+ * Strings for component 'favourites', language 'en', branch 'master'
+ *
+ * @package   core_favourites
+ * @copyright 2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+$string['privacy:metadata:favourite'] = 'Stores references to items which have been marked as favourites.';
+$string['privacy:metadata:favourite:component'] = 'The component to which the favourite belongs to. E.g. core_user.';
+$string['privacy:metadata:favourite:itemid'] = 'The identifier for the item being marked as a favourite.';
+$string['privacy:metadata:favourite:itemtype'] = 'The type of the favourite item. E.g. course.';
+$string['privacy:metadata:favourite:ordering'] = 'A number used to order the favourites of the same type.';
+$string['privacy:metadata:favourite:timecreated'] = 'The time at which the item was marked as a favourite.';
+$string['privacy:metadata:favourite:timemodified'] = 'The time at which favourite was last modified.';
+$string['privacy:metadata:favourite:userid'] = 'The user who created the favourite.';
index 41fb26e..562723b 100644 (file)
@@ -31,6 +31,10 @@ $string['blockcontact'] = 'Block contact';
 $string['blockedusers'] = 'Blocked users';
 $string['blocknoncontacts'] = 'Prevent non-contacts from messaging me';
 $string['canceledit'] = 'Cancel editing messages';
+$string['contactableprivacy'] = 'Accept messages from:';
+$string['contactableprivacy_onlycontacts'] = 'My contacts only';
+$string['contactableprivacy_coursemember'] = 'My contacts and anyone in my courses';
+$string['contactableprivacy_site'] = 'Anyone on the site';
 $string['contactblocked'] = 'Contact blocked';
 $string['contactrequests'] = 'Contact requests';
 $string['contacts'] = 'Contacts';
@@ -181,8 +185,7 @@ $string['unblockcontact'] = 'Unblock contact';
 $string['unknownuser'] = 'Unknown user';
 $string['unreadnotification'] = 'Unread notification: {$a}';
 $string['unreadnewmessage'] = 'New message from {$a}';
-$string['userisblockingyou'] = 'This user has blocked you from sending messages to them';
-$string['userisblockingyounoncontact'] = '{$a} only accepts messages from their contacts.';
+$string['usercantbemessaged'] = 'You can\'t message {$a} due to their message preferences. Try adding them as a contact.';
 $string['viewfullnotification'] = 'View full notification';
 $string['viewinganotherusersmessagearea'] = 'You are viewing another user\'s message area.';
 $string['viewmessageswith'] = 'View messages with {$a}';
@@ -195,3 +198,5 @@ $string['you'] = 'You:';
 $string['eventmessagecontactblocked'] = 'Message contact blocked';
 $string['eventmessagecontactunblocked'] = 'Message contact unblocked';
 $string['messagingdisabled'] = 'Messaging is disabled on this site, emails will be sent instead';
+$string['userisblockingyou'] = 'This user has blocked you from sending messages to them';
+$string['userisblockingyounoncontact'] = '{$a} only accepts messages from their contacts.';
\ No newline at end of file
index 0036edc..f85887e 100644 (file)
Binary files a/lib/amd/build/templates.min.js and b/lib/amd/build/templates.min.js differ
index 27adb6a..dd49428 100644 (file)
@@ -50,6 +50,9 @@ define(['core/mustache',
     /** @var {Promise[]} templatePromises - Cache of already loaded template promises */
     var templatePromises = {};
 
+    /** @var {Promise[]} cachePartialPromises - Cache of already loaded template partial promises */
+    var cachePartialPromises = {};
+
     /** @var {Object} iconSystem - Object extending core/iconsystem */
     var iconSystem = {};
 
@@ -648,24 +651,45 @@ define(['core/mustache',
      * @return {Promise} JQuery promise object resolved when all partials are in the cache.
      */
     Renderer.prototype.cachePartials = function(templateName) {
-        return this.getTemplate(templateName).then(function(templateSource) {
-            var i;
+        var searchKey = this.currentThemeName + '/' + templateName;
+
+        if (searchKey in cachePartialPromises) {
+            return cachePartialPromises[searchKey];
+        }
+
+        // This promise will not be resolved until all child partials are also resolved and ready.
+        // We create it here to allow us to check for recursive inclusion of templates.
+        cachePartialPromises[searchKey] = $.Deferred();
+
+        this.getTemplate(templateName)
+        .then(function(templateSource) {
             var partials = this.scanForPartials(templateSource);
-            var fetchThemAll = [];
-
-            for (i = 0; i < partials.length; i++) {
-                var searchKey = this.currentThemeName + '/' + partials[i];
-                if (searchKey in templatePromises) {
-                    fetchThemAll.push(templatePromises[searchKey]);
-                } else {
-                    fetchThemAll.push(this.cachePartials(partials[i]));
+            var uniquePartials = partials.filter(function(partialName) {
+                // Check for recursion.
+
+                if (typeof cachePartialPromises[this.currentThemeName + '/' + partialName] !== 'undefined') {
+                    // Ignore templates which include their parent.
+                    return false;
                 }
-            }
 
-            return $.when.apply($, fetchThemAll).then(function() {
-                return templateSource;
+                // Ignore templates that include themselves.
+                return partialName != templateName;
+            }.bind(this));
+
+            // Fetch any partial which has not already been fetched.
+            var fetchThemAll = uniquePartials.map(function(partialName) {
+                return this.cachePartials(partialName);
+            }.bind(this));
+
+            // Resolve the templateName promise when all of the children are resolved.
+            return $.when.apply($, fetchThemAll)
+            .then(function() {
+                return cachePartialPromises[searchKey].resolve(templateSource);
             });
-        }.bind(this));
+        }.bind(this))
+        .catch(cachePartialPromises[searchKey].reject);
+
+        return cachePartialPromises[searchKey];
     };
 
     /**
index f7debb8..11ce5a6 100644 (file)
@@ -442,6 +442,7 @@ $cache = '.var_export($cache, true).';
             'edufields'   => null,
             'enrol'       => $CFG->dirroot.'/enrol',
             'error'       => null,
+            'favourites'  => $CFG->dirroot . '/favourites',
             'filepicker'  => null,
             'fileconverter' => $CFG->dirroot.'/files/converter',
             'files'       => $CFG->dirroot.'/files',
@@ -1277,4 +1278,31 @@ $cache = '.var_export($cache, true).';
         }
         return $components;
     }
+
+    /**
+     * Returns a list of frankenstyle component names.
+     *
+     * E.g.
+     *  [
+     *      'core_course',
+     *      'core_message',
+     *      'mod_assign',
+     *      ...
+     *  ]
+     * @return array the list of frankenstyle component names.
+     */
+    public static function get_component_names() : array {
+        $componentnames = [];
+        // Get all plugins.
+        foreach (self::get_plugin_types() as $plugintype => $typedir) {
+            foreach (self::get_plugin_list($plugintype) as $pluginname => $plugindir) {
+                $componentnames[] = $plugintype . '_' . $pluginname;
+            }
+        }
+        // Get all subsystems.
+        foreach (self::get_core_subsystems() as $subsystemname => $subsystempath) {
+            $componentnames[] = 'core_' . $subsystemname;
+        }
+        return $componentnames;
+    }
 }
index b991853..ab38448 100644 (file)
@@ -33,8 +33,6 @@ defined('MOODLE_INTERNAL') || die();
  *      Extra information about event.
  *
  *      - int messageid: the id of the message.
- *      - int useridfrom: the id of the user who received the message.
- *      - int useridto: the id of the user who sent the message.
  * }
  *
  * @package    core
@@ -47,32 +45,22 @@ class message_deleted extends base {
     /**
      * Create event using ids.
      *
-     * @param int $userfromid the user who the message was from.
-     * @param int $usertoid the user who the message was sent to.
-     * @param int $userdeleted the user who deleted it.
+     * @param int $userid the user who the we are deleting the message for.
+     * @param int $userdeleting the user who deleted it (it's possible that an admin may delete a message on someones behalf)
      * @param int $messageid the id of the message that was deleted.
-     * @param int $muaid The id in the message_user_actions table
+     * @param int $muaid The id in the message_user_actions table.
      * @return message_deleted
      */
-    public static function create_from_ids($userfromid, $usertoid, $userdeleted, $messageid, $muaid) {
-        // Check who was deleting the message.
-        if ($userdeleted == $userfromid) {
-            $relateduserid = $usertoid;
-        } else {
-            $relateduserid = $userfromid;
-        }
-
+    public static function create_from_ids(int $userid, int $userdeleting, int $messageid, int $muaid) : message_deleted {
         // We set the userid to the user who deleted the message, nothing to do
         // with whether or not they sent or received the message.
         $event = self::create(array(
             'objectid' => $muaid,
-            'userid' => $userdeleted,
+            'userid' => $userdeleting,
             'context' => \context_system::instance(),
-            'relateduserid' => $relateduserid,
+            'relateduserid' => $userid,
             'other' => array(
                 'messageid' => $messageid,
-                'useridfrom' => $userfromid,
-                'useridto' => $usertoid
             )
         ));
 
@@ -103,14 +91,28 @@ class message_deleted extends base {
      * @return string
      */
     public function get_description() {
-        // Check if the person who deleted the message received or sent it.
-        if ($this->userid == $this->other['useridto']) {
-            $str = 'from';
-        } else {
-            $str = 'to';
+        // This is for BC when the event used to take this value into account before group conversations.
+        // We still want the same message to display for older events.
+        if (isset($this->other['useridto'])) {
+            // Check if the person who deleted the message received or sent it.
+            if ($this->userid == $this->other['useridto']) {
+                $str = 'from';
+            } else {
+                $str = 'to';
+            }
+
+            return "The user with id '$this->userid' deleted a message sent $str the user with id '$this->relateduserid'.";
+        }
+
+        $messageid = $this->other['messageid'];
+
+        // Check if the user deleting the message was not the actual user we are deleting for.
+        $str = "The user with id '$this->userid' deleted a message with id '$messageid'";
+        if ($this->userid != $this->relateduserid) {
+            $str .= " for the user with id '$this->relateduserid'";
         }
 
-        return "The user with id '$this->userid' deleted a message sent $str the user with id '$this->relateduserid'.";
+        return $str;
     }
 
     /**
@@ -129,14 +131,6 @@ class message_deleted extends base {
         if (!isset($this->other['messageid'])) {
             throw new \coding_exception('The \'messageid\' value must be set in other.');
         }
-
-        if (!isset($this->other['useridfrom'])) {
-            throw new \coding_exception('The \'useridfrom\' value must be set in other.');
-        }
-
-        if (!isset($this->other['useridto'])) {
-            throw new \coding_exception('The \'useridto\' value must be set in other.');
-        }
     }
 
     public static function get_objectid_mapping() {
@@ -145,9 +139,9 @@ class message_deleted extends base {
 
     public static function get_other_mapping() {
         // Messages are not backed up, so no need to map them on restore.
-        $othermapped = array();
-        $othermapped['useridfrom'] = array('db' => 'user', 'restore' => base::NOT_MAPPED);
-        $othermapped['useridto'] = array('db' => 'user', 'restore' => base::NOT_MAPPED);
+        $othermapped = [];
+        $othermapped['messageid'] = ['db' => 'messages', 'restore' => base::NOT_MAPPED];
+
         return $othermapped;
     }
 }
index 455b29c..60aa82e 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20180618" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20181018" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
         <INDEX NAME="indexprioritytimerequested" UNIQUE="false" FIELDS="indexpriority, timerequested"/>
       </INDEXES>
     </TABLE>
+    <TABLE NAME="favourite" COMMENT="Stores the relationship between an arbitrary item (itemtype, itemid), and a context area (component, contextid) for a specific user. Used by the favourites subsystem.">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="component" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false" COMMENT="Defines the Moodle component in which the favourite was created."/>
+        <FIELD NAME="itemtype" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false" COMMENT="The type of the item which is being favourited. Usually a table name, but doesn't have to be. E.g. 'messages' or 'message_conversations'."/>
+        <FIELD NAME="itemid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="The identifier of the item which is being favourited."/>
+        <FIELD NAME="contextid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="The context id of the item being favourited"/>
+        <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="The id of the user to whom the favourite belongs"/>
+        <FIELD NAME="ordering" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="Optional ordering of the favourite within its context area. For example, this allows things like sorting favourite message conversations."/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Creation time"/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Last modification time"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="contextid" TYPE="foreign" FIELDS="contextid" REFTABLE="context" REFFIELDS="id"/>
+        <KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id" COMMENT="Reference to the user owning the favourite."/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="uniqueuserfavouriteitem" UNIQUE="true" FIELDS="component, itemtype, itemid, contextid, userid"/>
+      </INDEXES>
+    </TABLE>
   </TABLES>
-</XMLDB>
+</XMLDB>
\ No newline at end of file
index 10bd788..5cec94e 100644 (file)
@@ -917,7 +917,18 @@ $functions = array(
         'classname' => 'core_message_external',
         'methodname' => 'delete_conversation',
         'classpath' => 'message/externallib.php',
-        'description' => 'Deletes a conversation.',
+        'description' => '** DEPRECATED ** Please do not call this function any more.
+                          Deletes a conversation.',
+        'type' => 'write',
+        'capabilities' => 'moodle/site:deleteownmessage',
+        'ajax' => true,
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
+    'core_message_delete_conversations_by_id' => array(
+        'classname' => 'core_message_external',
+        'methodname' => 'delete_conversations_by_id',
+        'classpath' => 'message/externallib.php',
+        'description' => 'Deletes a list of conversations.',
         'type' => 'write',
         'capabilities' => 'moodle/site:deleteownmessage',
         'ajax' => true,
@@ -2214,6 +2225,15 @@ $functions = array(
         'services'      => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
 
+    'core_block_get_dashboard_blocks' => array(
+        'classname'     => 'core_block_external',
+        'methodname'    => 'get_dashboard_blocks',
+        'description'   => 'Returns blocks information for the given user dashboard.',
+        'type'          => 'read',
+        'capabilities'  => '',
+        'services'      => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
+
     // Filters functions.
     'core_filters_get_available_in_context' => array(
         'classname'   => 'core_filters\external',
index a6087d3..6ec46a8 100644 (file)
@@ -2337,22 +2337,6 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2018091200.00);
     }
 
-    if ($oldversion < 2018091400.01) {
-        if (!isset($CFG->messagingallusers)) {
-            // For existing instances, $CFG->messagingallusers would be same value $CFG->messaging has.
-            if (isset($CFG->messaging)) {
-                set_config('messagingallusers', $CFG->messaging);
-            } else {
-                // When $CFG->messaging is not set, default value for $CFG->messaging should be true,
-                // so $CFG->messagingallusers value should be true as well.
-                set_config('messagingallusers', 1);
-            }
-        }
-
-        // Main savepoint reached.
-        upgrade_main_savepoint(true, 2018091400.01);
-    }
-
     if ($oldversion < 2018091700.01) {
         // Remove unused setting.
         unset_config('messaginghidereadnotifications');
@@ -2524,5 +2508,62 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2018092800.03);
     }
 
+    if ($oldversion < 2018101700.01) {
+        if (empty($CFG->keepmessagingallusersenabled)) {
+            // When it is not set, $CFG->messagingallusers should be disabled by default.
+            // When $CFG->messagingallusers = false, the default user preference is MESSAGE_PRIVACY_COURSEMEMBER
+            // (contacted by users sharing a course).
+            set_config('messagingallusers', false);
+        } else {
+            // When $CFG->keepmessagingallusersenabled is set to true, $CFG->messagingallusers is set to true.
+            set_config('messagingallusers', true);
+
+            // When $CFG->messagingallusers = true, the default user preference is MESSAGE_PRIVACY_SITE
+            // (contacted by all users site). So we need to set existing values from 0 (MESSAGE_PRIVACY_COURSEMEMBER)
+            // to 2 (MESSAGE_PRIVACY_SITE).
+            $DB->set_field(
+                'user_preferences',
+                'value',
+                \core_message\api::MESSAGE_PRIVACY_SITE,
+                array('name' => 'message_blocknoncontacts', 'value' => 0)
+            );
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2018101700.01);
+    }
+
+    if ($oldversion < 2018101800.00) {
+        // Define table 'favourite' to be created.
+        $table = new xmldb_table('favourite');
+
+        // Adding fields to table favourite.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('component', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('itemtype', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('itemid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('contextid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('ordering', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+
+        // Adding keys to table favourite.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+        $table->add_key('contextid', XMLDB_KEY_FOREIGN, ['contextid'], 'context', ['id']);
+        $table->add_key('userid', XMLDB_KEY_FOREIGN, ['userid'], 'user', ['id']);
+
+        // Adding indexes to table favourite.
+        $table->add_index('uniqueuserfavouriteitem', XMLDB_INDEX_UNIQUE, ['component', 'itemtype', 'itemid', 'contextid', 'userid']);
+
+        // Conditionally launch create table for favourite.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2018101800.00);
+    }
+
     return true;
 }
index ded335b..b096a5b 100644 (file)
@@ -4198,6 +4198,9 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null, $offlin
 
     // ========================================================================================================================
     } else if ($component === 'grade') {
+
+        require_once($CFG->libdir . '/grade/constants.php');
+
         if (($filearea === 'outcome' or $filearea === 'scale') and $context->contextlevel == CONTEXT_SYSTEM) {
             // Global gradebook files
             if ($CFG->forcelogin) {
@@ -4213,15 +4216,35 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null, $offlin
             \core\session\manager::write_close(); // Unlock session during file serving.
             send_stored_file($file, 60*60, 0, $forcedownload, $sendfileoptions);
 
-        } else if ($filearea === 'feedback' and $context->contextlevel == CONTEXT_COURSE) {
-            //TODO: nobody implemented this yet in grade edit form!!
-            send_file_not_found();
+        } else if ($filearea == GRADE_FEEDBACK_FILEAREA || $filearea == GRADE_HISTORY_FEEDBACK_FILEAREA) {
+            if ($context->contextlevel != CONTEXT_MODULE) {
+                send_file_not_found;
+            }
 
-            if ($CFG->forcelogin || $course->id != SITEID) {
-                require_login($course);
+            require_login($course, false);
+
+            $gradeid = (int) array_shift($args);
+            $filename = array_pop($args);
+            if ($filearea == GRADE_HISTORY_FEEDBACK_FILEAREA) {
+                $grade = $DB->get_record('grade_grades_history', ['id' => $gradeid]);
+            } else {
+                $grade = $DB->get_record('grade_grades', ['id' => $gradeid]);
             }
 
-            $fullpath = "/$context->id/$component/$filearea/".implode('/', $args);
+            if (!$grade) {
+                send_file_not_found();
+            }
+
+            $iscurrentuser = $USER->id == $grade->userid;
+
+            if (!$iscurrentuser) {
+                $coursecontext = context_course::instance($course->id);
+                if (!has_capability('moodle/grade:viewall', $coursecontext)) {
+                    send_file_not_found();
+                }
+            }
+
+            $fullpath = "/$context->id/$component/$filearea/$gradeid/$filename";
 
             if (!$file = $fs->get_file_by_hash(sha1($fullpath)) or $file->is_directory()) {
                 send_file_not_found();
index 662965e..039ed3b 100644 (file)
@@ -263,3 +263,18 @@ define('GRADE_MIN_MAX_FROM_GRADE_ITEM', 1);
  * GRADE_MIN_MAX_FROM_GRADE_GRADE - Get the grade min/max from the grade grade.
  */
 define('GRADE_MIN_MAX_FROM_GRADE_GRADE', 2);
+
+/**
+ * The component to store grade files.
+ */
+define('GRADE_FILE_COMPONENT', 'grade');
+
+/**
+ * The file area to store the associated grade_grades feedback files.
+ */
+define('GRADE_FEEDBACK_FILEAREA', 'feedback');
+
+/**
+ * The file area to store the associated grade_grades_history feedback files.
+ */
+define('GRADE_HISTORY_FEEDBACK_FILEAREA', 'historyfeedback');
index f2b30af..aef9f51 100644 (file)
@@ -172,6 +172,22 @@ class grade_grade extends grade_object {
      */
     public $aggregationweight = null;
 
+    /**
+     * Feedback files to copy.
+     *
+     * Example -
+     *
+     * [
+     *     'contextid' => 1,
+     *     'component' => 'mod_xyz',
+     *     'filearea' => 'mod_xyz_feedback',
+     *     'itemid' => 2
+     * ];
+     *
+     * @var array
+     */
+    public $feedbackfiles = [];
+
     /**
      * Returns array of grades for given grade_item+users
      *
@@ -1017,13 +1033,60 @@ class grade_grade extends grade_object {
      * @return bool success
      */
     public function update($source=null) {
-        $this->rawgrade    = grade_floatval($this->rawgrade);
-        $this->finalgrade  = grade_floatval($this->finalgrade);
+        $this->rawgrade = grade_floatval($this->rawgrade);
+        $this->finalgrade = grade_floatval($this->finalgrade);
         $this->rawgrademin = grade_floatval($this->rawgrademin);
         $this->rawgrademax = grade_floatval($this->rawgrademax);
         return parent::update($source);
     }
 
+
+    /**
+     * Handles adding feedback files in the gradebook.
+     *
+     * @param int|null $historyid
+     */
+    protected function add_feedback_files(int $historyid = null) {
+        global $CFG;
+
+        // We only support feedback files for modules atm.
+        if ($this->grade_item && $this->grade_item->is_external_item()) {
+            $context = $this->get_context();
+            $this->copy_feedback_files($context, GRADE_FEEDBACK_FILEAREA, $this->id);
+
+            if (empty($CFG->disablegradehistory) && $historyid) {
+                $this->copy_feedback_files($context, GRADE_HISTORY_FEEDBACK_FILEAREA, $historyid);
+            }
+        }
+
+        return $this->id;
+    }
+
+    /**
+     * Handles updating feedback files in the gradebook.
+     *
+     * @param int|null $historyid
+     */
+    protected function update_feedback_files(int $historyid = null){
+        global $CFG;
+
+        // We only support feedback files for modules atm.
+        if ($this->grade_item && $this->grade_item->is_external_item()) {
+            $context = $this->get_context();
+
+            $fs = new file_storage();
+            $fs->delete_area_files($context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA, $this->id);
+
+            $this->copy_feedback_files($context, GRADE_FEEDBACK_FILEAREA, $this->id);
+
+            if (empty($CFG->disablegradehistory) && $historyid) {
+                $this->copy_feedback_files($context, GRADE_HISTORY_FEEDBACK_FILEAREA, $historyid);
+            }
+        }
+
+        return true;
+    }
+
     /**
      * Deletes the grade_grade instance from the database.
      *
@@ -1121,4 +1184,44 @@ class grade_grade extends grade_object {
         return array('status' => $this->get_aggregationstatus(),
                      'weight' => $this->get_aggregationweight());
     }
+
+    /**
+     * Handles copying feedback files to a specified gradebook file area.
+     *
+     * @param context $context
+     * @param string $filearea
+     * @param int $itemid
+     */
+    private function copy_feedback_files(context $context, string $filearea, int $itemid) {
+        if ($this->feedbackfiles) {
+            $filestocopycontextid = $this->feedbackfiles['contextid'];
+            $filestocopycomponent = $this->feedbackfiles['component'];
+            $filestocopyfilearea = $this->feedbackfiles['filearea'];
+            $filestocopyitemid = $this->feedbackfiles['itemid'];
+
+            $fs = new file_storage();
+            if ($filestocopy = $fs->get_area_files($filestocopycontextid, $filestocopycomponent, $filestocopyfilearea,
+                    $filestocopyitemid)) {
+                foreach ($filestocopy as $filetocopy) {
+                    $destination = [
+                        'contextid' => $context->id,
+                        'component' => GRADE_FILE_COMPONENT,
+                        'filearea' => $filearea,
+                        'itemid' => $itemid
+                    ];
+                    $fs->create_file_from_storedfile($destination, $filetocopy);
+                }
+            }
+        }
+    }
+
+    /**
+     * Determine the correct context for this grade_grade.
+     *
+     * @return context
+     */
+    public function get_context() {
+        $this->load_grade_item();
+        return $this->grade_item->get_context();
+    }
 }
index 81d8ef8..d3b12f6 100644 (file)
@@ -1864,9 +1864,19 @@ class grade_item extends grade_object {
      * @param int $dategraded A timestamp of when the student's work was graded
      * @param int $datesubmitted A timestamp of when the student's work was submitted
      * @param grade_grade $grade A grade object, useful for bulk upgrades
+     * @param array $feedbackfiles An array identifying the location of files we want to copy to the gradebook feedback area.
+     *        Example -
+     *        [
+     *            'contextid' => 1,
+     *            'component' => 'mod_xyz',
+     *            'filearea' => 'mod_xyz_feedback',
+     *            'itemid' => 2
+     *        ];
      * @return bool success
      */
-    public function update_raw_grade($userid, $rawgrade=false, $source=NULL, $feedback=false, $feedbackformat=FORMAT_MOODLE, $usermodified=null, $dategraded=null, $datesubmitted=null, $grade=null) {
+    public function update_raw_grade($userid, $rawgrade = false, $source = null, $feedback = false,
+            $feedbackformat = FORMAT_MOODLE, $usermodified = null, $dategraded = null, $datesubmitted=null,
+            $grade = null, array $feedbackfiles = []) {
         global $USER;
 
         $result = true;
@@ -1929,6 +1939,7 @@ class grade_item extends grade_object {
         if ($feedback !== false and !$grade->is_overridden()) {
             $grade->feedback       = $feedback;
             $grade->feedbackformat = $feedbackformat;
+            $grade->feedbackfiles  = $feedbackfiles;
         }
 
         // update final grade if possible
@@ -2469,4 +2480,19 @@ class grade_item extends grade_object {
             \availability_grade\callbacks::grade_item_changed($this->courseid);
         }
     }
+
+    /**
+     * Helper function to get the accurate context for this grade column.
+     *
+     * @return context
+     */
+    public function get_context() {
+        if ($this->itemtype == 'mod') {
+            $cm = get_fast_modinfo($this->courseid)->instances[$this->itemmodule][$this->iteminstance];
+            $context = \context_module::instance($cm->id);
+        } else {
+            $context = \context_course::instance($this->courseid);
+        }
+        return $context;
+    }
 }
index 654228e..33e907e 100644 (file)
@@ -252,6 +252,7 @@ abstract class grade_object {
 
         $DB->update_record($this->table, $data);
 
+        $historyid = null;
         if (empty($CFG->disablegradehistory)) {
             unset($data->timecreated);
             $data->action       = GRADE_HISTORY_UPDATE;
@@ -259,10 +260,13 @@ abstract class grade_object {
             $data->source       = $source;
             $data->timemodified = time();
             $data->loggeduser   = $USER->id;
-            $DB->insert_record($this->table.'_history', $data);
+            $historyid = $DB->insert_record($this->table.'_history', $data);
         }
 
         $this->notify_changed(false);
+
+        $this->update_feedback_files($historyid);
+
         return true;
     }
 
@@ -346,6 +350,7 @@ abstract class grade_object {
 
         $data = $this->get_record_data();
 
+        $historyid = null;
         if (empty($CFG->disablegradehistory)) {
             unset($data->timecreated);
             $data->action       = GRADE_HISTORY_INSERT;
@@ -353,10 +358,13 @@ abstract class grade_object {
             $data->source       = $source;
             $data->timemodified = time();
             $data->loggeduser   = $USER->id;
-            $DB->insert_record($this->table.'_history', $data);
+            $historyid = $DB->insert_record($this->table.'_history', $data);
         }
 
         $this->notify_changed(false);
+
+        $this->add_feedback_files($historyid);
+
         return $this->id;
     }
 
@@ -411,6 +419,22 @@ abstract class grade_object {
     protected function notify_changed($deleted) {
     }
 
+    /**
+     * Handles adding feedback files in the gradebook.
+     *
+     * @param int|null $historyid
+     */
+    protected function add_feedback_files(int $historyid = null) {
+    }
+
+    /**
+     * Handles updating feedback files in the gradebook.
+     *
+     * @param int|null $historyid
+     */
+    protected function update_feedback_files(int $historyid = null) {
+    }
+
     /**
      * Returns the current hidden state of this grade_item
      *
index 9e37c42..a9a6829 100644 (file)
@@ -252,6 +252,7 @@ function grade_update($source, $courseid, $itemtype, $itemmodule, $iteminstance,
         $rawgrade       = false;
         $feedback       = false;
         $feedbackformat = FORMAT_MOODLE;
+        $feedbackfiles = [];
         $usermodified   = $USER->id;
         $datesubmitted  = null;
         $dategraded     = null;
@@ -268,6 +269,10 @@ function grade_update($source, $courseid, $itemtype, $itemmodule, $iteminstance,
             $feedbackformat = $grade['feedbackformat'];
         }
 
+        if (array_key_exists('feedbackfiles', $grade)) {
+            $feedbackfiles = $grade['feedbackfiles'];
+        }
+
         if (array_key_exists('usermodified', $grade)) {
             $usermodified = $grade['usermodified'];
         }
@@ -281,7 +286,8 @@ function grade_update($source, $courseid, $itemtype, $itemmodule, $iteminstance,
         }
 
         // update or insert the grade
-        if (!$grade_item->update_raw_grade($userid, $rawgrade, $source, $feedback, $feedbackformat, $usermodified, $dategraded, $datesubmitted, $grade_grade)) {
+        if (!$grade_item->update_raw_grade($userid, $rawgrade, $source, $feedback, $feedbackformat, $usermodified,
+                $dategraded, $datesubmitted, $grade_grade, $feedbackfiles)) {
             $failed = true;
         }
     }
@@ -537,7 +543,17 @@ function grade_get_grades($courseid, $itemtype, $itemmodule, $iteminstance, $use
                         if (is_null($grade->feedback)) {
                             $grade->str_feedback = '';
                         } else {
-                            $grade->str_feedback = format_text($grade->feedback, $grade->feedbackformat);
+                            $feedback = file_rewrite_pluginfile_urls(
+                                $grade->feedback,
+                                'pluginfile.php',
+                                $grade_grades[$userid]->get_context()->id,
+                                GRADE_FILE_COMPONENT,
+                                GRADE_FEEDBACK_FILEAREA,
+                                $grade_grades[$userid]->id
+                            );
+
+                            $grade->str_feedback = format_text($feedback, $grade->feedbackformat,
+                                ['context' => $grade_grades[$userid]->get_context()]);
                         }
 
                         $item->grades[$userid] = $grade;
index ef21b05..6c760dd 100644 (file)
@@ -4091,7 +4091,7 @@ function truncate_userinfo(array $info) {
  * @throws coding_exception if invalid $user parameter detected
  */
 function delete_user(stdClass $user) {
-    global $CFG, $DB;
+    global $CFG, $DB, $SESSION;
     require_once($CFG->libdir.'/grouplib.php');
     require_once($CFG->libdir.'/gradelib.php');
     require_once($CFG->dirroot.'/message/lib.php');
@@ -4186,6 +4186,11 @@ function delete_user(stdClass $user) {
     // Remove users customised pages.
     $DB->delete_records('my_pages', array('userid' => $user->id, 'private' => 1));
 
+    // Delete user from $SESSION->bulk_users.
+    if (isset($SESSION->bulk_users[$user->id])) {
+        unset($SESSION->bulk_users[$user->id]);
+    }
+
     // Force logout - may fail if file based sessions used, sorry.
     \core\session\manager::kill_user_sessions($user->id);
 
index 3e3bd96..2b79591 100644 (file)
@@ -36,7 +36,7 @@ class core_component_testcase extends advanced_testcase {
      * this is defined here to annoy devs that try to add more without any thinking,
      * always verify that it does not collide with any existing add-on modules and subplugins!!!
      */
-    const SUBSYSTEMCOUNT = 66;
+    const SUBSYSTEMCOUNT = 67;
 
     public function setUp() {
         $psr0namespaces = new ReflectionProperty('core_component', 'psr0namespaces');
@@ -805,4 +805,31 @@ class core_component_testcase extends advanced_testcase {
         $this->assertEquals($componentslist['mod']['mod_forum'], $CFG->dirroot . '/mod/forum');
         $this->assertEquals($componentslist['tool']['tool_usertours'], $CFG->dirroot . '/' . $CFG->admin . '/tool/usertours');
     }
+
+    /**
+     * Test the get_component_names() method.
+     */
+    public function test_get_component_names() {
+        global $CFG;
+        $componentnames = \core_component::get_component_names();
+
+        // We should have an entry for each plugin type.
+        $plugintypes = \core_component::get_plugin_types();
+        $numplugintypes = 0;
+        foreach ($plugintypes as $type => $typedir) {
+            foreach (\core_component::get_plugin_list($type) as $plugin) {
+                $numplugintypes++;
+            }
+        }
+        // And an entry for each core subsystem.
+        $numcomponents = $numplugintypes + count(\core_component::get_core_subsystems());
+
+        $this->assertEquals($numcomponents, count($componentnames));
+
+        // Check a few of the known plugin types to confirm their presence at their respective type index.
+        $this->assertContains('core_comment', $componentnames);
+        $this->assertContains('mod_forum', $componentnames);
+        $this->assertContains('tool_usertours', $componentnames);
+        $this->assertContains('core_favourites', $componentnames);
+    }
 }
index 288ed76..bdb18ab 100644 (file)
@@ -135,6 +135,30 @@ the groupid field.
   - message_contact_unblocked
   The reason for this is because you can now block/unblock users without them necessarily being a contact. These events
   have been replaced with message_user_blocked and message_user_unblocked respectively.
+* The event message_deleted has been changed, it no longer records the value of the 'useridto' due to
+  the introduction of group messaging. Please, if you have any observers or are triggering this event
+  in your code you will have to make some changes!
+* The gradebook now supports the ability to accept files as feedback. This can be achieved by adding
+  'feedbackfiles' to the $grades parameter passed to grade_update().
+    For example -
+        $grades['feedbackfiles'] = [
+            'contextid' => 1,
+            'component' => 'mod_xyz',
+            'filearea' => 'mod_xyz_feedback',
+            'itemid' => 2
+        ];
+  These files will be then copied to the gradebook file area.
+* Allow users to choose who can message them for privacy reasons, with a 'growing circle of contactability':
+  - Added $CFG->messagingallusers, for enabling messaging to all site users. Default value: 0.
+    When $CFG->messagingallusers = false users can choose being contacted by only contacts or contacts and users sharing a course with them.
+    In that case, the default user preference is MESSAGE_PRIVACY_COURSEMEMBER (users sharing a course).
+    When $CFG->messagingallusers = true users have a new option for the privacy messaging preferences: "Anyone on the site". In that case,
+    the default user preference is MESSAGE_PRIVACY_SITE (all site users).
+  - Added $CFG->keepmessagingallusersenabled setting to config.php to force enabling $CFG->messagingallusers during the upgrading process.
+    Default value: 0.
+    When $CFG->keepmessagingallusersenabled is set to true, $CFG->messagingallusers will be also set to true to enable messaging site users.
+    However, when it is empty, $CFG->messagingallusers will be disabled during the upgrading process, so the users will only be able to
+    message contacts and users sharing a course with them.
 
 === 3.5 ===
 
index 436b3bb..54bf917 100644 (file)
Binary files a/message/amd/build/message_preferences.min.js and b/message/amd/build/message_preferences.min.js differ
index c8a4372..c5944b6 100644 (file)
@@ -29,8 +29,7 @@ define(['jquery', 'core/ajax', 'core/notification',
     var SELECTORS = {
         PREFERENCE: '[data-state]',
         PREFERENCES_CONTAINER: '[data-region="preferences-container"]',
-        BLOCK_NON_CONTACTS: '[data-region="block-non-contacts-container"] [data-block-non-contacts]',
-        BLOCK_NON_CONTACTS_CONTAINER: '[data-region="block-non-contacts-container"]',
+        CONTACTABLE_PRIVACY_CONTAINER: '[data-region="privacy-setting-container"]',
     };
 
     /**
@@ -56,16 +55,15 @@ define(['jquery', 'core/ajax', 'core/notification',
     };
 
     /**
-     * Update the block messages from non-contacts user preference in the DOM and
+     * Update the contactable privacy user preference in the DOM and
      * send a request to update on the server.
      *
      * @return {Promise}
-     * @method saveBlockNonContactsStatus
+     * @method saveContactablePrivacySetting
      */
-    MessagePreferences.prototype.saveBlockNonContactsStatus = function() {
-        var checkbox = this.root.find(SELECTORS.BLOCK_NON_CONTACTS);
-        var container = this.root.find(SELECTORS.BLOCK_NON_CONTACTS_CONTAINER);
-        var ischecked = checkbox.prop('checked');
+    MessagePreferences.prototype.saveContactablePrivacySetting = function() {
+        var container = this.root.find(SELECTORS.CONTACTABLE_PRIVACY_CONTAINER);
+        var value = $("input[type='radio']:checked").val();
 
         if (container.hasClass('loading')) {
             return $.Deferred().resolve();
@@ -79,8 +77,8 @@ define(['jquery', 'core/ajax', 'core/notification',
                 userid: this.userId,
                 preferences: [
                     {
-                        type: checkbox.attr('data-preference-key'),
-                        value: ischecked ? 1 : 0,
+                        type: container.attr('data-preference-key'),
+                        value: value,
                     }
                 ]
             }
@@ -103,20 +101,22 @@ define(['jquery', 'core/ajax', 'core/notification',
             CustomEvents.events.activate
         ]);
 
-        this.root.on(CustomEvents.events.activate, SELECTORS.BLOCK_NON_CONTACTS, function() {
-            this.saveBlockNonContactsStatus();
-        }.bind(this));
-
         this.root.on('change', function(e) {
-            if (!this.preferencesDisabled()) {
-                var preferencesContainer = $(e.target).closest(SELECTORS.PREFERENCES_CONTAINER);
-                var preferenceElement = $(e.target).closest(SELECTORS.PREFERENCE);
-                var messagePreference = new MessageNotificationPreference(preferencesContainer, this.userId);
+            // Add listener for privacy setting radio buttons change.
+            if (e.target.name == 'message_blocknoncontacts') {
+                this.saveContactablePrivacySetting();
+            } else {
+                // Add listener for processor preferences.
+                if (!this.preferencesDisabled()) {
+                    var preferencesContainer = $(e.target).closest(SELECTORS.PREFERENCES_CONTAINER);
+                    var preferenceElement = $(e.target).closest(SELECTORS.PREFERENCE);
+                    var messagePreference = new MessageNotificationPreference(preferencesContainer, this.userId);
 
-                preferenceElement.addClass('loading');
-                messagePreference.save().always(function() {
-                    preferenceElement.removeClass('loading');
-                });
+                    preferenceElement.addClass('loading');
+                    messagePreference.save().always(function() {
+                        preferenceElement.removeClass('loading');
+                    });
+                }
             }
         }.bind(this));
     };
index 8bc93a7..d9b507e 100644 (file)
@@ -46,6 +46,21 @@ class api {
      */
     const MESSAGE_ACTION_DELETED = 2;
 
+    /**
+     * The privacy setting for being messaged by anyone within courses user is member of.
+     */
+    const MESSAGE_PRIVACY_COURSEMEMBER = 0;
+
+    /**
+     * The privacy setting for being messaged only by contacts.
+     */
+    const MESSAGE_PRIVACY_ONLYCONTACTS = 1;
+
+    /**
+     * The privacy setting for being messaged by anyone on the site.
+     */
+    const MESSAGE_PRIVACY_SITE = 2;
+
     /**
      * Handles searching for messages in the message area.
      *
@@ -628,17 +643,30 @@ class api {
      *
      * @param int $userid The user id of who we want to delete the messages for (this may be done by the admin
      *  but will still seem as if it was by the user)
+     * @param int $conversationid The id of the conversation
      * @return bool Returns true if a user can delete the conversation, false otherwise.
      */
-    public static function can_delete_conversation($userid) {
+    public static function can_delete_conversation(int $userid, int $conversationid = null) : bool {
         global $USER;
 
+        if (is_null($conversationid)) {
+            debugging('\core_message\api::can_delete_conversation() now expects a \'conversationid\' to be passed.',
+                DEBUG_DEVELOPER);
+            return false;
+        }
+
         $systemcontext = \context_system::instance();
 
-        // Let's check if the user is allowed to delete this conversation.
-        if (has_capability('moodle/site:deleteanymessage', $systemcontext) ||
-            ((has_capability('moodle/site:deleteownmessage', $systemcontext) &&
-                $USER->id == $userid))) {
+        if (has_capability('moodle/site:deleteanymessage', $systemcontext)) {
+            return true;
+        }
+
+        if (!self::is_user_in_conversation($userid, $conversationid)) {
+            return false;
+        }
+
+        if (has_capability('moodle/site:deleteownmessage', $systemcontext) &&
+                $USER->id == $userid) {
             return true;
         }
 
@@ -650,13 +678,15 @@ class api {
      *
      * This function does not verify any permissions.
      *
+     * @deprecated since 3.6
      * @param int $userid The user id of who we want to delete the messages for (this may be done by the admin
      *  but will still seem as if it was by the user)
      * @param int $otheruserid The id of the other user in the conversation
      * @return bool
      */
     public static function delete_conversation($userid, $otheruserid) {
-        global $DB, $USER;
+        debugging('\core_message\api::delete_conversation() is deprecated, please use ' .
+            '\core_message\api::delete_conversation_by_id() instead.', DEBUG_DEVELOPER);
 
         $conversationid = self::get_conversation_between_users([$userid, $otheruserid]);
 
@@ -665,6 +695,23 @@ class api {
             return true;
         }
 
+        self::delete_conversation_by_id($userid, $conversationid);
+
+        return true;
+    }
+
+    /**
+     * Deletes a conversation for a specified user.
+     *
+     * This function does not verify any permissions.
+     *
+     * @param int $userid The user id of who we want to delete the messages for (this may be done by the admin
+     *  but will still seem as if it was by the user)
+     * @param int $conversationid The id of the other user in the conversation
+     */
+    public static function delete_conversation_by_id(int $userid, int $conversationid) {
+        global $DB, $USER;
+
         // Get all messages belonging to this conversation that have not already been deleted by this user.
         $sql = "SELECT m.*
                  FROM {messages} m
@@ -686,16 +733,9 @@ class api {
             $mua->timecreated = time();
             $mua->id = $DB->insert_record('message_user_actions', $mua);
 
-            if ($message->useridfrom == $userid) {
-                $useridto = $otheruserid;
-            } else {
-                $useridto = $userid;
-            }
-            \core\event\message_deleted::create_from_ids($message->useridfrom, $useridto,
-                $USER->id, $message->id, $mua->id)->trigger();
+            \core\event\message_deleted::create_from_ids($userid, $USER->id,
+                $message->id, $mua->id)->trigger();
         }
-
-        return true;
     }
 
     /**
@@ -857,7 +897,7 @@ class api {
         }
 
         // Load general messaging preferences.
-        $preferences->blocknoncontacts = get_user_preferences('message_blocknoncontacts', '', $user->id);
+        $preferences->blocknoncontacts = self::get_user_privacy_messaging_preference($user->id);
         $preferences->mailformat = $user->mailformat;
         $preferences->mailcharset = get_user_preferences('mailcharset', '', $user->id);
 
@@ -927,6 +967,36 @@ class api {
         return true;
     }
 
+    /**
+     * Get the messaging preference for a user.
+     * If the user has not any messaging privacy preference:
+     * - When $CFG->messagingallusers = false the default user preference is MESSAGE_PRIVACY_COURSEMEMBER.
+     * - When $CFG->messagingallusers = true the default user preference is MESSAGE_PRIVACY_SITE.
+     *
+     * @param  int    $userid The user identifier.
+     * @return int    The default messaging preference.
+     */
+    public static function get_user_privacy_messaging_preference(int $userid) : int {
+        global $CFG;
+
+        // When $CFG->messagingallusers is enabled, default value for the messaging preference will be "Anyone on the site";
+        // otherwise, the default value will be "My contacts and anyone in my courses".
+        if (empty($CFG->messagingallusers)) {
+            $defaultprefvalue = self::MESSAGE_PRIVACY_COURSEMEMBER;
+        } else {
+            $defaultprefvalue = self::MESSAGE_PRIVACY_SITE;
+        }
+        $privacypreference = get_user_preferences('message_blocknoncontacts', $defaultprefvalue, $userid);
+
+        // When the $CFG->messagingallusers privacy setting is disabled, MESSAGE_PRIVACY_SITE is
+        // also disabled, so it has to be replaced to MESSAGE_PRIVACY_COURSEMEMBER.
+        if (empty($CFG->messagingallusers) && $privacypreference == self::MESSAGE_PRIVACY_SITE) {
+            $privacypreference = self::MESSAGE_PRIVACY_COURSEMEMBER;
+        }
+
+        return $privacypreference;
+    }
+
     /**
      * Checks if the recipient is allowing messages from users that aren't a
      * contact. If not then it checks to make sure the sender is in the
@@ -937,23 +1007,31 @@ class api {
      * @return bool true if $sender is blocked, false otherwise.
      */
     public static function is_user_non_contact_blocked($recipient, $sender = null) {
-        global $USER;
+        global $USER, $CFG;
 
         if (is_null($sender)) {
             // The message is from the logged in user, unless otherwise specified.
             $sender = $USER;
         }
 
-        $blockednoncontacts = get_user_preferences('message_blocknoncontacts', '', $recipient->id);
-        if (!empty($blockednoncontacts)) {
-            // Confirm the sender is a contact of the recipient.
-            if (self::is_contact($sender->id, $recipient->id)) {
-                // All good, the recipient is a contact of the sender.
-                return false;
-            } else {
-                // Oh no, the recipient is not a contact. Looks like we can't send the message.
-                return true;
-            }
+        $privacypreference = self::get_user_privacy_messaging_preference($recipient->id);
+        switch ($privacypreference) {
+            case self::MESSAGE_PRIVACY_SITE:
+                if (!empty($CFG->messagingallusers)) {
+                    // Users can be messaged without being contacts or members of the same course.
+                    break;
+                }
+                // When the $CFG->messagingallusers privacy setting is disabled, continue with the next
+                // case, because MESSAGE_PRIVACY_SITE is replaced to MESSAGE_PRIVACY_COURSEMEMBER.
+            case self::MESSAGE_PRIVACY_COURSEMEMBER:
+                // Confirm the sender and the recipient are both members of the same course.
+                if (enrol_sharing_course($recipient, $sender)) {
+                    // All good, the recipient and the sender are members of the same course.
+                    return false;
+                }
+            case self::MESSAGE_PRIVACY_ONLYCONTACTS:
+                // True if they aren't contacts (they can't send a message because of the privacy settings), false otherwise.
+                return !self::is_contact($sender->id, $recipient->id);
         }
 
         return false;
@@ -1202,30 +1280,20 @@ class api {
     public static function can_delete_message($userid, $messageid) {
         global $DB, $USER;
 
-        $sql = "SELECT m.id, m.useridfrom, mcm.userid as useridto
-                  FROM {messages} m
-            INNER JOIN {message_conversations} mc
-                    ON m.conversationid = mc.id
-            INNER JOIN {message_conversation_members} mcm
-                    ON mcm.conversationid = mc.id
-                 WHERE mcm.userid != m.useridfrom
-                   AND m.id = ?";
-        $message = $DB->get_record_sql($sql, [$messageid], MUST_EXIST);
-
-        if ($message->useridfrom == $userid) {
-            $userdeleting = 'useridfrom';
-        } else if ($message->useridto == $userid) {
-            $userdeleting = 'useridto';
-        } else {
-            return false;
+        $systemcontext = \context_system::instance();
+
+        $conversationid = $DB->get_field('messages', 'conversationid', ['id' => $messageid], MUST_EXIST);
+
+        if (has_capability('moodle/site:deleteanymessage', $systemcontext)) {
+            return true;
         }
 
-        $systemcontext = \context_system::instance();
+        if (!self::is_user_in_conversation($userid, $conversationid)) {
+            return false;
+        }
 
-        // Let's check if the user is allowed to delete this message.
-        if (has_capability('moodle/site:deleteanymessage', $systemcontext) ||
-            ((has_capability('moodle/site:deleteownmessage', $systemcontext) &&
-                $USER->id == $message->$userdeleting))) {
+        if (has_capability('moodle/site:deleteownmessage', $systemcontext) &&
+                $USER->id == $userid) {
             return true;
         }
 
@@ -1243,17 +1311,11 @@ class api {
      * @return bool
      */
     public static function delete_message($userid, $messageid) {
-        global $DB;
+        global $DB, $USER;
 
-        $sql = "SELECT m.id, m.useridfrom, mcm.userid as useridto
-                  FROM {messages} m
-            INNER JOIN {message_conversations} mc
-                    ON m.conversationid = mc.id
-            INNER JOIN {message_conversation_members} mcm
-                    ON mcm.conversationid = mc.id
-                 WHERE mcm.userid != m.useridfrom
-                   AND m.id = ?";
-        $message = $DB->get_record_sql($sql, [$messageid], MUST_EXIST);
+        if (!$DB->record_exists('messages', ['id' => $messageid])) {
+            return false;
+        }
 
         // Check if the user has already deleted this message.
         if (!$DB->record_exists('message_user_actions', ['userid' => $userid,
@@ -1266,8 +1328,8 @@ class api {
             $mua->id = $DB->insert_record('message_user_actions', $mua);
 
             // Trigger event for deleting a message.
-            \core\event\message_deleted::create_from_ids($message->useridfrom, $message->useridto,
-                $userid, $message->id, $mua->id)->trigger();
+            \core\event\message_deleted::create_from_ids($userid, $USER->id,
+                $messageid, $mua->id)->trigger();
 
             return true;
         }
@@ -1612,4 +1674,19 @@ class api {
                     OR (mcr.userid = ? AND mcr.requesteduserid = ?)";
         return $DB->record_exists_sql($sql, [$userid, $requesteduserid, $requesteduserid, $userid]);
     }
+
+    /**
+     * Checks if a user is already in a conversation.
+     *
+     * @param int $userid The id of the user we want to check if they are in a group
+     * @param int $conversationid The id of the conversation
+     * @return bool Returns true if a contact request exists, false otherwise
+     */
+    public static function is_user_in_conversation(int $userid, int $conversationid) : bool {
+        global $DB;
+
+        return $DB->record_exists('message_conversation_members', ['conversationid' => $conversationid,
+            'userid' => $userid]);
+
+    }
 }
index f678c2f..89016ec 100644 (file)
@@ -92,72 +92,36 @@ class core_message_external extends external_api {
         }
         list($sqluserids, $sqlparams) = $DB->get_in_or_equal($receivers);
         $tousers = $DB->get_records_select("user", "id " . $sqluserids . " AND deleted = 0", $sqlparams);
-        $blocklist   = array();
-        $contactlist = array();
-        $contactsqlparams = array_merge($sqlparams, [$USER->id], [$USER->id], $sqlparams);
-        $rs = $DB->get_recordset_sql("SELECT *
-                                        FROM {message_contacts}
-                                       WHERE (userid $sqluserids AND contactid = ?)
-                                          OR (userid = ? AND contactid $sqluserids)", $contactsqlparams);
-        foreach ($rs as $record) {
-            $useridtouse = $record->userid;
-            if ($record->userid == $USER->id) {
-                $useridtouse = $record->contactid;
-            }
-            $contactlist[$useridtouse] = true;
-        }
-        $rs->close();
-        $blocksqlparams = array_merge($sqlparams, [$USER->id]);
-        $rs = $DB->get_recordset_sql("SELECT *
-                                        FROM {message_users_blocked}
-                                       WHERE userid $sqluserids
-                                         AND blockeduserid = ?", $blocksqlparams);
-        foreach ($rs as $record) {
-            $blocklist[$record->userid] = true;
-        }
-        $rs->close();
-
-        $canreadallmessages = has_capability('moodle/site:readallmessages', $context);
 
         $resultmessages = array();
         foreach ($params['messages'] as $message) {
             $resultmsg = array(); //the infos about the success of the operation
 
-            //we are going to do some checking
-            //code should match /messages/index.php checks
+            // We are going to do some checking.
+            // Code should match /messages/index.php checks.
             $success = true;
 
-            //check the user exists
+            // Check the user exists.
             if (empty($tousers[$message['touserid']])) {
                 $success = false;
                 $errormessage = get_string('touserdoesntexist', 'message', $message['touserid']);
             }
 
-            //check that the touser is not blocking the current user
-            if ($success and !empty($blocklist[$message['touserid']]) and !$canreadallmessages) {
+            // TODO MDL-31118 performance improvement - edit the function so we can pass an array instead userid
+            // Check if the recipient can be messaged by the sender.
+            if ($success && !\core_message\api::can_post_message($tousers[$message['touserid']], $USER)) {
                 $success = false;
-                $errormessage = get_string('userisblockingyou', 'message');
+                $errormessage = get_string('usercantbemessaged', 'message', fullname(\core_user::get_user($message['touserid'])));
             }
 
-            // Check if the user is a contact
-            //TODO MDL-31118 performance improvement - edit the function so we can pass an array instead userid
-            $blocknoncontacts = get_user_preferences('message_blocknoncontacts', NULL, $message['touserid']);
-            // message_blocknoncontacts option is on and current user is not in contact list
-            if ($success && empty($contactlist[$message['touserid']]) && !empty($blocknoncontacts)) {
-                // The user isn't a contact and they have selected to block non contacts so this message won't be sent.
-                $success = false;
-                $errormessage = get_string('userisblockingyounoncontact', 'message',
-                        fullname(core_user::get_user($message['touserid'])));
-            }
-
-            //now we can send the message (at least try)
+            // Now we can send the message (at least try).
             if ($success) {
-                //TODO MDL-31118 performance improvement - edit the function so we can pass an array instead one touser object
+                // TODO MDL-31118 performance improvement - edit the function so we can pass an array instead one touser object.
                 $success = message_post_message($USER, $tousers[$message['touserid']],
                         $message['text'], external_validate_format($message['textformat']));
             }
 
-            //build the resultmsg
+            // Build the resultmsg.
             if (isset($message['clientmsgid'])) {
                 $resultmsg['clientmsgid'] = $message['clientmsgid'];
             }
@@ -571,7 +535,6 @@ class core_message_external extends external_api {
     /**
      * Unblock contacts.
      *
-     * @deprecated since Moodle 3.6
      * @param array $userids array of user IDs.
      * @param int $userid The id of the user we are unblocking the contacts for
      * @return null
@@ -2541,6 +2504,7 @@ class core_message_external extends external_api {
     /**
      * Returns description of method parameters.
      *
+     * @deprecated since 3.6
      * @return external_function_parameters
      * @since 3.2
      */
@@ -2556,6 +2520,7 @@ class core_message_external extends external_api {
     /**
      * Deletes a conversation.
      *
+     * @deprecated since 3.6
      * @param int $userid The user id of who we want to delete the conversation for
      * @param int $otheruserid The user id of the other user in the conversation
      * @return array
@@ -2587,8 +2552,13 @@ class core_message_external extends external_api {
         $user = core_user::get_user($params['userid'], '*', MUST_EXIST);
         core_user::require_active_user($user);
 
-        if (\core_message\api::can_delete_conversation($user->id)) {
-            $status = \core_message\api::delete_conversation($user->id, $otheruserid);
+        if (!$conversationid = \core_message\api::get_conversation_between_users([$userid, $otheruserid])) {
+            return [];
+        }
+
+        if (\core_message\api::can_delete_conversation($user->id, $conversationid)) {
+            \core_message\api::delete_conversation_by_id($user->id, $conversationid);
+            $status = true;
         } else {
             throw new moodle_exception('You do not have permission to delete messages');
         }
@@ -2604,6 +2574,7 @@ class core_message_external extends external_api {
     /**
      * Returns description of method result value.
      *
+     * @deprecated since 3.6
      * @return external_description
      * @since 3.2
      */
@@ -2616,6 +2587,85 @@ class core_message_external extends external_api {
         );
     }
 
+    /**
+     * Marking the method as deprecated.
+     *
+     * @return bool
+     */
+    public static function delete_conversation_is_deprecated() {
+        return true;
+    }
+
+    /**
+     * Returns description of method parameters.
+     *
+     * @return external_function_parameters
+     * @since 3.6
+     */
+    public static function delete_conversations_by_id_parameters() {
+        return new external_function_parameters(
+            array(
+                'userid' => new external_value(PARAM_INT, 'The user id of who we want to delete the conversation for'),
+                'conversationids' => new external_multiple_structure(
+                    new external_value(PARAM_INT, 'The id of the conversation'),
+                    'List of conversation IDs'
+                ),
+            )
+        );
+    }
+
+    /**
+     * Deletes a conversation.
+     *
+     * @param int $userid The user id of who we want to delete the conversation for
+     * @param int[] $conversationids The ids of the conversations
+     * @return array
+     * @throws moodle_exception
+     * @since 3.6
+     */
+    public static function delete_conversations_by_id($userid, array $conversationids) {
+        global $CFG;
+
+        // Check if private messaging between users is allowed.
+        if (empty($CFG->messaging)) {
+            throw new moodle_exception('disabled', 'message');
+        }
+
+        // Validate params.
+        $params = [
+            'userid' => $userid,
+            'conversationids' => $conversationids,
+        ];
+        $params = self::validate_parameters(self::delete_conversations_by_id_parameters(), $params);
+
+        // Validate context.
+        $context = context_system::instance();
+        self::validate_context($context);
+
+        $user = core_user::get_user($params['userid'], '*', MUST_EXIST);
+        core_user::require_active_user($user);
+
+        foreach ($conversationids as $conversationid) {
+            if (\core_message\api::can_delete_conversation($user->id, $conversationid)) {
+                \core_message\api::delete_conversation_by_id($user->id, $conversationid);
+            } else {
+                throw new moodle_exception("You do not have permission to delete the conversation '$conversationid'");
+            }
+        }
+
+        return [];
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description
+     * @since 3.6
+     */
+    public static function delete_conversations_by_id_returns() {
+        return new external_warnings();
+    }
+
     /**
      * Returns description of method parameters
      *
@@ -3012,7 +3062,6 @@ class core_message_external extends external_api {
         );
     }
 
-
     /**
      * Returns description of method parameters
      *
@@ -3068,7 +3117,7 @@ class core_message_external extends external_api {
         $result = array(
             'warnings' => array(),
             'preferences' => $notificationlistoutput->export_for_template($renderer),
-            'blocknoncontacts' => get_user_preferences('message_blocknoncontacts', '', $user->id) ? true : false,
+            'blocknoncontacts' => \core_message\api::get_user_privacy_messaging_preference($user->id),
         );
         return $result;
     }
@@ -3083,7 +3132,7 @@ class core_message_external extends external_api {
         return new external_function_parameters(
             array(
                 'preferences' => self::get_preferences_structure(),
-                'blocknoncontacts' => new external_value(PARAM_BOOL, 'Whether to block or not messages from non contacts'),
+                'blocknoncontacts' => new external_value(PARAM_INT, 'Privacy messaging setting to define who can message you'),
                 'warnings' => new external_warnings(),
             )
         );
index 1fbe61c..a85e65a 100644 (file)
@@ -716,10 +716,26 @@ function core_message_can_edit_message_profile($user) {
  * @return array
  */
 function core_message_user_preferences() {
-
     $preferences = [];
-    $preferences['message_blocknoncontacts'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 0,
-        'choices' => array(0, 1));
+    $preferences['message_blocknoncontacts'] = array(
+        'type' => PARAM_INT,
+        'null' => NULL_NOT_ALLOWED,
+        'default' => 0,
+        'choices' => array(
+            \core_message\api::MESSAGE_PRIVACY_ONLYCONTACTS,
+            \core_message\api::MESSAGE_PRIVACY_COURSEMEMBER,
+            \core_message\api::MESSAGE_PRIVACY_SITE
+        ),
+        'cleancallback' => function ($value) {
+            global $CFG;
+
+            // When site-wide messaging between users is disabled, MESSAGE_PRIVACY_SITE should be converted.
+            if (empty($CFG->messagingallusers) && $value === \core_message\api::MESSAGE_PRIVACY_SITE) {
+                return \core_message\api::MESSAGE_PRIVACY_COURSEMEMBER;
+            }
+            return $value;
+        }
+    );
     $preferences['/^message_provider_([\w\d_]*)_logged(in|off)$/'] = array('isregex' => true, 'type' => PARAM_NOTAGS,
         'null' => NULL_NOT_ALLOWED, 'default' => 'none',
         'permissioncallback' => function ($user, $preferencename) {
index 67289a2..b27e22e 100644 (file)
@@ -6,9 +6,16 @@ Feature: Message popover unread messages
 
   Background:
     Given the following "users" exist:
-      | username | firstname | lastname | email |
+      | username | firstname | lastname | email       |
       | student1 | Student | 1 | student1@example.com |
       | student2 | Student | 2 | student2@example.com |
+    And the following "courses" exist:
+      | fullname | shortname |
+      | Course 1 | C1        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | student1 | C1     | student        |
+      | student2 | C1     | student        |
     And I log in as "student2"
     And I send "Test message" message to "Student 1" user
     And I log out
index 1aac259..17af06d 100644 (file)
@@ -227,6 +227,8 @@ class core_message_renderer extends plugin_renderer_base {
      * @return string The text to render
      */
     public function render_user_message_preferences($user) {
+        global $CFG;
+
         // Filter out enabled, available system_configured and user_configured processors only.
         $readyprocessors = array_filter(get_message_processors(), function($processor) {
             return $processor->enabled &&
@@ -243,7 +245,29 @@ class core_message_renderer extends plugin_renderer_base {
         $notificationlistoutput = new \core_message\output\preferences\message_notification_list($readyprocessors,
             $providers, $preferences, $user);
         $context = $notificationlistoutput->export_for_template($this);
-        $context['blocknoncontacts'] = get_user_preferences('message_blocknoncontacts', '', $user->id) ? true : false;
+
+        // Get the privacy settings options for being messaged.
+        $privacysetting = \core_message\api::get_user_privacy_messaging_preference($user->id);
+        $choices = array();
+        $choices[] = [
+            'value' => \core_message\api::MESSAGE_PRIVACY_ONLYCONTACTS,
+            'text' => get_string('contactableprivacy_onlycontacts', 'message'),
+            'checked' => ($privacysetting == \core_message\api::MESSAGE_PRIVACY_ONLYCONTACTS)
+        ];
+        $choices[] = [
+            'value' => \core_message\api::MESSAGE_PRIVACY_COURSEMEMBER,
+            'text' => get_string('contactableprivacy_coursemember', 'message'),
+            'checked' => ($privacysetting == \core_message\api::MESSAGE_PRIVACY_COURSEMEMBER)
+        ];
+        if (!empty($CFG->messagingallusers)) {
+            // Add the MESSAGE_PRIVACY_SITE option when site-wide messaging between users is enabled.
+            $choices[] = [
+                'value' => \core_message\api::MESSAGE_PRIVACY_SITE,
+                'text' => get_string('contactableprivacy_site', 'message'),
+                'checked' => ($privacysetting == \core_message\api::MESSAGE_PRIVACY_SITE)
+            ];
+        }
+        $context['privacychoices'] = $choices;
 
         return $this->render_from_template('message/message_preferences', $context);
     }
index 43e2ea9..ebe1c40 100644 (file)
@@ -29,6 +29,7 @@
     * userid The logged in user id
     * disableall If the user has disabled notifications
     * components The list of notification components
+    * privacychoices The choice options for the contactable privacy setting
 
     Example context (json):
     {
                     }
                 ]
             }
+        ],
+        "privacychoices": [
+            {
+                "value": 1,
+                "text": "My contacts only",
+                "checked": 0
+            },
+            {
+                "value": 2,
+                "text": "Anyone within courses I am a member of",
+                "checked": 1
+            }
         ]
     }
 }}
 <div class="preferences-page-container" data-region="preferences-page-container">
     <h2>{{#str}} messagepreferences, message {{/str}}</h2>
-    <div class="checkbox-container" data-region="block-non-contacts-container">
-        <input id="block-non-contacts"
-                type="checkbox"
-                data-user-id="{{userid}}"
-                data-block-non-contacts
-                data-preference-key="message_blocknoncontacts"
-                {{#blocknoncontacts}}checked{{/blocknoncontacts}} />
-        <label for="block-non-contacts">{{#str}} blocknoncontacts, message {{/str}}</label>
-        {{> core/loading }}
-    </div>
+    <div class="privacy-setting-container"
+         data-user-id="{{userid}}"
+         data-region="privacy-setting-container"
+         data-preference-key="message_blocknoncontacts">
+        <p>{{#str}} contactableprivacy, message {{/str}}</p>
+       {{#privacychoices}}
+        <input id="action-selection-option-{{value}}"
+               type="radio"
+               name="message_blocknoncontacts"
+               value="{{value}}"
+               {{#checked}}checked="checked"{{/checked}}/>
+        <label for="action-selection-option-{{value}}">{{text}}</label>
+        <br>
+       {{/privacychoices}}
+    </div><br>
     <div class="preferences-container {{#disableall}}disabled{{/disableall}}"
         data-user-id="{{userid}}"
         data-region="preferences-container">
index 0278d67..8be0f3f 100644 (file)
@@ -109,6 +109,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
 
         // Create user to add to the admin's block list.
         $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
 
         $this->assertEquals(0, \core_message\api::count_blocked_users());
 
@@ -1146,17 +1147,26 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $user1 = self::getDataGenerator()->create_user();
         $user2 = self::getDataGenerator()->create_user();
 
+        // Send some messages back and forth.
+        $time = 1;
+        $this->send_fake_message($user1, $user2, 'Yo!', 0, $time + 1);
+        $this->send_fake_message($user2, $user1, 'Sup mang?', 0, $time + 2);
+        $this->send_fake_message($user1, $user2, 'Writing PHPUnit tests!', 0, $time + 3);
+        $this->send_fake_message($user2, $user1, 'Word.', 0, $time + 4);
+
+        $conversationid = \core_message\api::get_conversation_between_users([$user1->id, $user2->id]);
+
         // The admin can do anything.
-        $this->assertTrue(\core_message\api::can_delete_conversation($user1->id));
+        $this->assertTrue(\core_message\api::can_delete_conversation($user1->id, $conversationid));
 
         // Set as the user 1.
         $this->setUser($user1);
 
         // They can delete their own messages.
-        $this->assertTrue(\core_message\api::can_delete_conversation($user1->id));
+        $this->assertTrue(\core_message\api::can_delete_conversation($user1->id, $conversationid));
 
         // They can't delete someone elses.
-        $this->assertFalse(\core_message\api::can_delete_conversation($user2->id));
+        $this->assertFalse(\core_message\api::can_delete_conversation($user2->id, $conversationid));
     }
 
     /**
@@ -1181,6 +1191,58 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
 
         // Delete the conversation as user 1.
         \core_message\api::delete_conversation($user1->id, $user2->id);
+        $this->assertDebuggingCalled();
+
+        $muas = $DB->get_records('message_user_actions', array(), 'timecreated ASC');
+        $this->assertCount(4, $muas);
+        // Sort by id.
+        ksort($muas);
+
+        $mua1 = array_shift($muas);
+        $mua2 = array_shift($muas);
+        $mua3 = array_shift($muas);
+        $mua4 = array_shift($muas);
+
+        $this->assertEquals($user1->id, $mua1->userid);
+        $this->assertEquals($m1id, $mua1->messageid);
+        $this->assertEquals(\core_message\api::MESSAGE_ACTION_DELETED, $mua1->action);
+
+        $this->assertEquals($user1->id, $mua2->userid);
+        $this->assertEquals($m2id, $mua2->messageid);
+        $this->assertEquals(\core_message\api::MESSAGE_ACTION_DELETED, $mua2->action);
+
+        $this->assertEquals($user1->id, $mua3->userid);
+        $this->assertEquals($m3id, $mua3->messageid);
+        $this->assertEquals(\core_message\api::MESSAGE_ACTION_DELETED, $mua3->action);
+
+        $this->assertEquals($user1->id, $mua4->userid);
+        $this->assertEquals($m4id, $mua4->messageid);
+        $this->assertEquals(\core_message\api::MESSAGE_ACTION_DELETED, $mua4->action);
+    }
+
+    /**
+     * Tests deleting a conversation by conversation id.
+     */
+    public function test_delete_conversation_by_id() {
+        global $DB;
+
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        // The person doing the search.
+        $this->setUser($user1);
+
+        // Send some messages back and forth.
+        $time = 1;
+        $m1id = $this->send_fake_message($user1, $user2, 'Yo!', 0, $time + 1);
+        $m2id = $this->send_fake_message($user2, $user1, 'Sup mang?', 0, $time + 2);
+        $m3id = $this->send_fake_message($user1, $user2, 'Writing PHPUnit tests!', 0, $time + 3);
+        $m4id = $this->send_fake_message($user2, $user1, 'Word.', 0, $time + 4);
+
+        // Delete the conversation as user 1.
+        $conversationid = \core_message\api::get_conversation_between_users([$user1->id, $user2->id]);
+        \core_message\api::delete_conversation_by_id($user1->id, $conversationid);
 
         $muas = $DB->get_records('message_user_actions', array(), 'timecreated ASC');
         $this->assertCount(4, $muas);
@@ -1273,10 +1335,17 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $user1 = self::getDataGenerator()->create_user();
         $user2 = self::getDataGenerator()->create_user();
 
-        // Set as the user 1.
+        // Set as the first user.
         $this->setUser($user1);
 
-        // They can post to someone else.
+        // With the default privacy setting, users can't message them.
+        $this->assertFalse(\core_message\api::can_post_message($user2));
+
+        // Enrol users to the same course.
+        $course = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id);
+        // After enrolling users to the course, they should be able to message them with the default privacy setting.
         $this->assertTrue(\core_message\api::can_post_message($user2));
     }
 
@@ -1302,6 +1371,27 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->assertFalse(\core_message\api::can_post_message($user2));
     }
 
+    /**
+     * Tests the user can post a message when they are contact.
+     */
+    public function test_can_post_message_when_contact() {
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        // Set as the first user.
+        $this->setUser($user1);
+
+        // Check that we can not send user2 a message.
+        $this->assertFalse(\core_message\api::can_post_message($user2));
+
+        // Add users as contacts.
+        \core_message\api::add_contact($user1->id, $user2->id);
+
+        // Check that the return result is now true.
+        $this->assertTrue(\core_message\api::can_post_message($user2));
+    }
+
     /**
      * Tests the user can't post a message if they are not a contact and the user
      * has requested messages only from contacts.
@@ -1315,7 +1405,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->setUser($user1);
 
         // Set the second user's preference to not receive messages from non-contacts.
-        set_user_preference('message_blocknoncontacts', 1, $user2->id);
+        set_user_preference('message_blocknoncontacts', \core_message\api::MESSAGE_PRIVACY_ONLYCONTACTS, $user2->id);
 
         // Check that we can not send user 2 a message.
         $this->assertFalse(\core_message\api::can_post_message($user2));
@@ -1339,6 +1429,77 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->assertFalse(\core_message\api::can_post_message($user1, $user2));
     }
 
+    /**
+     * Tests the user can post a message when site-wide messaging setting is enabled,
+     * even if they are not a contact and are not members of the same course.
+     */
+    public function test_can_post_message_site_messaging_setting() {
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        // Set as the first user.
+        $this->setUser($user1);
+
+        // Set the second user's preference to receive messages from everybody. As site-wide messaging setting
+        // is disabled by default, the value will be changed to MESSAGE_PRIVACY_COURSEMEMBER.
+        set_user_preference('message_blocknoncontacts', \core_message\api::MESSAGE_PRIVACY_SITE, $user2->id);
+        $this->assertFalse(\core_message\api::can_post_message($user2));
+
+        // Enable site-wide messagging privacy setting. The user will be able to receive messages from everybody.
+        set_config('messagingallusers', true);
+        // Check that we can send user2 a message.
+        $this->assertTrue(\core_message\api::can_post_message($user2));
+    }
+
+    /**
+     * Tests get_user_privacy_messaging_preference method.
+     */
+    public function test_get_user_privacy_messaging_preference() {
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+
+        // Enable site-wide messagging privacy setting. The user will be able to receive messages from everybody.
+        set_config('messagingallusers', true);
+
+        // Set some user preferences.
+        set_user_preference('message_blocknoncontacts', \core_message\api::MESSAGE_PRIVACY_SITE, $user1->id);
+        set_user_preference('message_blocknoncontacts', \core_message\api::MESSAGE_PRIVACY_ONLYCONTACTS, $user2->id);
+
+        // Check the returned value for each user.
+        $this->assertEquals(
+            \core_message\api::MESSAGE_PRIVACY_SITE,
+            \core_message\api::get_user_privacy_messaging_preference($user1->id)
+        );
+        $this->assertEquals(
+            \core_message\api::MESSAGE_PRIVACY_ONLYCONTACTS,
+            \core_message\api::get_user_privacy_messaging_preference($user2->id)
+        );
+        $this->assertEquals(
+            \core_message\api::MESSAGE_PRIVACY_SITE,
+            \core_message\api::get_user_privacy_messaging_preference($user3->id)
+        );
+
+        // Disable site-wide messagging privacy setting. The user will be able to receive messages from members of their course.
+        set_config('messagingallusers', false);
+
+        // Check the returned value for each user.
+        $this->assertEquals(
+            \core_message\api::MESSAGE_PRIVACY_COURSEMEMBER,
+            \core_message\api::get_user_privacy_messaging_preference($user1->id)
+        );
+        $this->assertEquals(
+            \core_message\api::MESSAGE_PRIVACY_ONLYCONTACTS,
+            \core_message\api::get_user_privacy_messaging_preference($user2->id)
+        );
+        $this->assertEquals(
+            \core_message\api::MESSAGE_PRIVACY_COURSEMEMBER,
+            \core_message\api::get_user_privacy_messaging_preference($user3->id)
+        );
+    }
+
     /**
      * Tests that when blocking messages from non-contacts is enabled that
      * non-contacts trying to send a message return false.
@@ -1351,13 +1512,18 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         // Set as the first user.
         $this->setUser($user1);
 
-        // User hasn't sent their preference to block non-contacts, so should return false.
+        // By default, user only can be messaged by contacts and members of any of his/her courses.
+        $this->assertTrue(\core_message\api::is_user_non_contact_blocked($user2));
+
+        // Enable all users privacy messaging and check now the default user's preference has been set to allow receiving
+        // messages from everybody.
+        set_config('messagingallusers', true);
+        // Check that the return result is now false because any site user can contact him/her.
         $this->assertFalse(\core_message\api::is_user_non_contact_blocked($user2));
 
         // Set the second user's preference to not receive messages from non-contacts.
-        set_user_preference('message_blocknoncontacts', 1, $user2->id);
-
-        // Check that the return result is now true.
+        set_user_preference('message_blocknoncontacts', \core_message\api::MESSAGE_PRIVACY_ONLYCONTACTS, $user2->id);
+        // Check that the return result is still true (because is even more restricted).
         $this->assertTrue(\core_message\api::is_user_non_contact_blocked($user2));
 
         // Add the first user as a contact for the second user.
@@ -1366,12 +1532,10 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         // Check that the return result is now false.
         $this->assertFalse(\core_message\api::is_user_non_contact_blocked($user2));
 
-        // Set the first user's preference to not receive messages from non-contacts.
-        set_user_preference('message_blocknoncontacts', 1, $user1->id);
-        $this->setUser($user2);
-        // Confirm it is still false. We want to ensure a contact request works both ways
-        // as it is now an agreement between users.
-        $this->assertFalse(\core_message\api::is_user_non_contact_blocked($user1));
+        // Set the second user's preference to receive messages from course members.
+        set_user_preference('message_blocknoncontacts', \core_message\api::MESSAGE_PRIVACY_COURSEMEMBER, $user2->id);
+        // Check that the return result is still false (because $user1 is still his/her contact).
+        $this->assertFalse(\core_message\api::is_user_non_contact_blocked($user2));
     }
 
     /**
@@ -1696,6 +1860,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
 
         // Get the contacts and the unread message count.
         $messages = \core_message\api::get_contacts_with_unread_message_count($user2->id);
+
         // Confirm the size is correct.
         $this->assertCount(2, $messages);
         ksort($messages);
@@ -2226,6 +2391,31 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->assertTrue(\core_message\api::does_contact_request_exist($user2->id, $user1->id));
     }
 
+    /**
+     * Test the user in conversation check.
+     */
+    public function test_is_user_in_conversation() {
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        $conversationid = \core_message\api::create_conversation_between_users([$user1->id, $user2->id]);
+
+        $this->assertTrue(\core_message\api::is_user_in_conversation($user1->id, $conversationid));
+    }
+
+    /**
+     * Test the user in conversation check when they are not.
+     */
+    public function test_is_user_in_conversation_when_not() {
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+
+        $conversationid = \core_message\api::create_conversation_between_users([$user1->id, $user2->id]);
+
+        $this->assertFalse(\core_message\api::is_user_in_conversation($user3->id, $conversationid));
+    }
+
     /**
      * Comparison function for sorting contacts.
      *
index 25d0c08..c6f3546 100644 (file)
@@ -6,9 +6,16 @@ Feature: Delete all messages
 
   Scenario: Delete all messages
     Given the following "users" exist:
-      | username | firstname | lastname | email            |
+      | username | firstname | lastname | email                |
       | user1    | User      | 1        | user1@example.com    |
       | user2    | User      | 2        | user2@example.com    |
+    And the following "courses" exist:
+      | fullname | shortname |
+      | Course 1 | C1        |
+    And the following "course enrolments" exist:
+      | user     | course | role        |
+      | user1    | C1     | student     |
+      | user2    | C1     | student     |
     And I log in as "user2"
     And I send "User 2 to User 1 message 1" message to "User 1" user
     And I send "User 2 to User 1 message 2" message in the message area
index 600eb43..a97087d 100644 (file)
@@ -6,9 +6,16 @@ Feature: Delete messages
 
   Scenario: Delete messages
     Given the following "users" exist:
-      | username | firstname | lastname | email            |
+      | username | firstname | lastname | email                |
       | user1    | User      | 1        | user1@example.com    |
       | user2    | User      | 2        | user2@example.com    |
+    And the following "courses" exist:
+      | fullname | shortname |
+      | Course 1 | C1        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | user1    | C1     | student        |
+      | user2    | C1     | student        |
     And I log in as "user2"
     And I send "User 2 to User 1 message 1" message to "User 1" user
     And I send "User 2 to User 1 message 2" message in the message area
index d6eb761..a237ac3 100644 (file)
@@ -9,6 +9,13 @@ Feature: Reply message
       | username | firstname | lastname | email            |
       | user1    | User      | 1        | user1@example.com    |
       | user2    | User      | 2        | user2@example.com    |
+    And the following "courses" exist:
+      | fullname | shortname |
+      | Course 1 | C1        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | user1    | C1     | student        |
+      | user2    | C1     | student        |
     And I log in as "user2"
     And I send "User 2 to User 1" message to "User 1" user
     And I log out
index 96a2e0d..5c7d3e9 100644 (file)
@@ -6,10 +6,18 @@ Feature: Search messages
 
   Background:
     Given the following "users" exist:
-      | username | firstname | lastname | email            |
+      | username | firstname | lastname | email                |
       | user1    | User      | 1        | user1@example.com    |
       | user2    | User      | 2        | user2@example.com    |
       | user3    | User      | 3        | user3@example.com    |
+    And the following "courses" exist:
+      | fullname | shortname |
+      | Course 1 | C1        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | user1    | C1     | student        |
+      | user2    | C1     | student        |
+      | user3    | C1     | student        |
     And I log in as "user2"
     And I send "User 2 to User 1" message to "User 1" user
     And I log out
index d4ad844..f979bd9 100644 (file)
@@ -6,10 +6,18 @@ Feature: View messages
 
   Scenario: View messages from multiple users
     Given the following "users" exist:
-      | username | firstname | lastname | email            |
+      | username | firstname | lastname | email                |
       | user1    | User      | 1        | user1@example.com    |
       | user2    | User      | 2        | user2@example.com    |
       | user3    | User      | 3        | user3@example.com    |
+    And the following "courses" exist:
+      | fullname | shortname |
+      | Course 1 | C1        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | user1    | C1     | student        |
+      | user2    | C1     | student        |
+      | user3    | C1     | student        |
     And I log in as "user2"
     And I send "User 2 to User 1" message to "User 1" user
     And I log out
index 25660ce..714461b 100644 (file)
@@ -274,7 +274,9 @@ class core_message_events_testcase extends core_message_messagelib_testcase {
      * Test the message deleted event.
      */
     public function test_message_deleted() {
-        global $DB;
+        global $DB, $USER;
+
+        $this->setAdminUser();
 
         // Create users to send messages between.
         $user1 = $this->getDataGenerator()->create_user();
@@ -294,12 +296,12 @@ class core_message_events_testcase extends core_message_messagelib_testcase {
 
         // Check that the event data is valid.
         $this->assertInstanceOf('\core\event\message_deleted', $event);
-        $this->assertEquals($user1->id, $event->userid); // The user who deleted it.
-        $this->assertEquals($user2->id, $event->relateduserid);
+        $this->assertEquals($USER->id, $event->userid); // The user who deleted it.
+        $this->assertEquals($user1->id, $event->relateduserid);
         $this->assertEquals($mua->id, $event->objectid);
         $this->assertEquals($messageid, $event->other['messageid']);
-        $this->assertEquals($user1->id, $event->other['useridfrom']);
-        $this->assertEquals($user2->id, $event->other['useridto']);
+
+        $this->setUser($user1);
 
         // Create a read message.
         $messageid = $this->send_fake_message($user1, $user2);
@@ -318,12 +320,10 @@ class core_message_events_testcase extends core_message_messagelib_testcase {
 
         // Check that the event data is valid.
         $this->assertInstanceOf('\core\event\message_deleted', $event);
-        $this->assertEquals($user2->id, $event->userid);
-        $this->assertEquals($user1->id, $event->relateduserid);
+        $this->assertEquals($user1->id, $event->userid);
+        $this->assertEquals($user2->id, $event->relateduserid);
         $this->assertEquals($mua->id, $event->objectid);
         $this->assertEquals($messageid, $event->other['messageid']);
-        $this->assertEquals($user1->id, $event->other['useridfrom']);
-        $this->assertEquals($user2->id, $event->other['useridto']);
     }
 
     /**
@@ -336,7 +336,7 @@ class core_message_events_testcase extends core_message_messagelib_testcase {
         $user1 = self::getDataGenerator()->create_user();
         $user2 = self::getDataGenerator()->create_user();
 
-        // The person doing the search.
+        // The person doing the deletion.
         $this->setUser($user1);
 
         // Send some messages back and forth.
@@ -364,6 +364,7 @@ class core_message_events_testcase extends core_message_messagelib_testcase {
         // Trigger and capture the event.
         $sink = $this->redirectEvents();
         \core_message\api::delete_conversation($user1->id, $user2->id);
+        $this->assertDebuggingCalled();
         $events = $sink->get_events();
 
         // Get the user actions for the messages deleted by that user.
@@ -383,18 +384,14 @@ class core_message_events_testcase extends core_message_messagelib_testcase {
         // Check that the event data is valid.
         $i = 1;
         foreach ($events as $event) {
-            $useridfromid = ($i % 2 == 0) ? $user2->id : $user1->id;
-            $useridtoid = ($i % 2 == 0) ? $user1->id : $user2->id;
             $messageid = $messages[$i - 1];
 
             $this->assertInstanceOf('\core\event\message_deleted', $event);
 
             $this->assertEquals($muatest[$messageid]->id, $event->objectid);
             $this->assertEquals($user1->id, $event->userid);
-            $this->assertEquals($user2->id, $event->relateduserid);
+            $this->assertEquals($user1->id, $event->relateduserid);
             $this->assertEquals($messageid, $event->other['messageid']);
-            $this->assertEquals($useridfromid, $event->other['useridfrom']);
-            $this->assertEquals($useridtoid, $event->other['useridto']);
 
             $i++;
         }
index 784ad14..aa7f61f 100644 (file)
@@ -115,6 +115,18 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
 
         $sentmessages = core_message_external::send_instant_messages($messages);
         $sentmessages = external_api::clean_returnvalue(core_message_external::send_instant_messages_returns(), $sentmessages);
+        $this->assertEquals(
+            get_string('usercantbemessaged', 'message', fullname(\core_user::get_user($message1['touserid']))),
+            array_pop($sentmessages)['errormessage']
+        );
+
+        // Add the user1 as a contact.
+        \core_message\api::add_contact($user1->id, $user2->id);
+
+        // Send message again. Now it should work properly.
+        $sentmessages = core_message_external::send_instant_messages($messages);
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $sentmessages = external_api::clean_returnvalue(core_message_external::send_instant_messages_returns(), $sentmessages);
 
         $sentmessage = reset($sentmessages);
 
@@ -165,7 +177,7 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
 
         $sentmessage = reset($sentmessages);
 
-        $this->assertEquals(get_string('userisblockingyou', 'message'), $sentmessage['errormessage']);
+        $this->assertEquals(get_string('usercantbemessaged', 'message', fullname($user2)), $sentmessage['errormessage']);
 
         $this->assertEquals(0, $DB->count_records('messages'));
     }
@@ -187,7 +199,7 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         $this->setUser($user1);
 
         // Set the user preference so user 2 does not accept messages from non-contacts.
-        set_user_preference('message_blocknoncontacts', 1, $user2);
+        set_user_preference('message_blocknoncontacts', \core_message\api::MESSAGE_PRIVACY_ONLYCONTACTS, $user2);
 
         // Create test message data.
         $message1 = array();
@@ -201,7 +213,7 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
 
         $sentmessage = reset($sentmessages);
 
-        $this->assertEquals(get_string('userisblockingyounoncontact', 'message', fullname($user2)), $sentmessage['errormessage']);
+        $this->assertEquals(get_string('usercantbemessaged', 'message', fullname($user2)), $sentmessage['errormessage']);
 
         $this->assertEquals(0, $DB->count_records('messages'));
     }
@@ -223,7 +235,7 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         $this->setUser($user1);
 
         // Set the user preference so user 2 does not accept messages from non-contacts.
-        set_user_preference('message_blocknoncontacts', 1, $user2);
+        set_user_preference('message_blocknoncontacts', \core_message\api::MESSAGE_PRIVACY_ONLYCONTACTS, $user2);
 
         \core_message\api::add_contact($user1->id, $user2->id);
 
@@ -1648,7 +1660,7 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
             $result = core_message_external::delete_message(-1, $user1->id);
             $this->fail('Exception expected due invalid messageid.');
         } catch (dml_missing_record_exception $e) {
-            $this->assertEquals('invalidrecordunknown', $e->errorcode);
+            $this->assertEquals('invalidrecord', $e->errorcode);
         }
 
         // Invalid user.
@@ -3445,6 +3457,13 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         $user2 = self::getDataGenerator()->create_user();
         $user3 = self::getDataGenerator()->create_user();
 
+        // Send some messages back and forth.
+        $time = time();
+        $this->send_message($user1, $user2, 'Yo!', 0, $time);
+        $this->send_message($user2, $user1, 'Sup mang?', 0, $time + 1);
+        $this->send_message($user1, $user2, 'Writing PHPUnit tests!', 0, $time + 2);
+        $this->send_message($user2, $user1, 'Word.', 0, $time + 3);
+
         // The person wanting to delete the conversation.
         $this->setUser($user3);
 
@@ -3476,6 +3495,173 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         core_message_external::delete_conversation($user1->id, $user2->id);
     }
 
+    /**
+     * Test deleting conversations.
+     */
+    public function test_delete_conversations_by_id() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        // The person wanting to delete the conversation.
+        $this->setUser($user1);
+
+        // Send some messages back and forth.
+        $time = time();
+        $m1id = $this->send_message($user1, $user2, 'Yo!', 0, $time);
+        $m2id = $this->send_message($user2, $user1, 'Sup mang?', 0, $time + 1);
+        $m3id = $this->send_message($user1, $user2, 'Writing PHPUnit tests!', 0, $time + 2);
+        $m4id = $this->send_message($user2, $user1, 'Word.', 0, $time + 3);
+
+        $conversationid = \core_message\api::get_conversation_between_users([$user1->id, $user2->id]);
+
+        // Delete the conversation.
+        core_message_external::delete_conversations_by_id($user1->id, [$conversationid]);
+
+        $muas = $DB->get_records('message_user_actions', array(), 'timecreated ASC');
+        $this->assertCount(4, $muas);
+        // Sort by id.
+        ksort($muas);
+
+        $mua1 = array_shift($muas);
+        $mua2 = array_shift($muas);
+        $mua3 = array_shift($muas);
+        $mua4 = array_shift($muas);
+
+        $this->assertEquals($user1->id, $mua1->userid);
+        $this->assertEquals($m1id, $mua1->messageid);
+        $this->assertEquals(\core_message\api::MESSAGE_ACTION_DELETED, $mua1->action);
+
+        $this->assertEquals($user1->id, $mua2->userid);
+        $this->assertEquals($m2id, $mua2->messageid);
+        $this->assertEquals(\core_message\api::MESSAGE_ACTION_DELETED, $mua2->action);
+
+        $this->assertEquals($user1->id, $mua3->userid);
+        $this->assertEquals($m3id, $mua3->messageid);
+        $this->assertEquals(\core_message\api::MESSAGE_ACTION_DELETED, $mua3->action);
+
+        $this->assertEquals($user1->id, $mua4->userid);
+        $this->assertEquals($m4id, $mua4->messageid);
+        $this->assertEquals(\core_message\api::MESSAGE_ACTION_DELETED, $mua4->action);
+    }
+
+    /**
+     * Test deleting conversations as other user.
+     */
+    public function test_delete_conversations_by_id_as_other_user() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $this->setAdminUser();
+
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        // Send some messages back and forth.
+        $time = time();
+        $m1id = $this->send_message($user1, $user2, 'Yo!', 0, $time);
+        $m2id = $this->send_message($user2, $user1, 'Sup mang?', 0, $time + 1);
+        $m3id = $this->send_message($user1, $user2, 'Writing PHPUnit tests!', 0, $time + 2);
+        $m4id = $this->send_message($user2, $user1, 'Word.', 0, $time + 3);
+
+        $conversationid = \core_message\api::get_conversation_between_users([$user1->id, $user2->id]);
+
+        // Delete the conversation.
+        core_message_external::delete_conversations_by_id($user1->id, [$conversationid]);
+
+        $muas = $DB->get_records('message_user_actions', array(), 'timecreated ASC');
+        $this->assertCount(4, $muas);
+        // Sort by id.
+        ksort($muas);
+
+        $mua1 = array_shift($muas);
+        $mua2 = array_shift($muas);
+        $mua3 = array_shift($muas);
+        $mua4 = array_shift($muas);
+
+        $this->assertEquals($user1->id, $mua1->userid);
+        $this->assertEquals($m1id, $mua1->messageid);
+        $this->assertEquals(\core_message\api::MESSAGE_ACTION_DELETED, $mua1->action);
+
+        $this->assertEquals($user1->id, $mua2->userid);
+        $this->assertEquals($m2id, $mua2->messageid);
+        $this->assertEquals(\core_message\api::MESSAGE_ACTION_DELETED, $mua2->action);
+
+        $this->assertEquals($user1->id, $mua3->userid);
+        $this->assertEquals($m3id, $mua3->messageid);
+        $this->assertEquals(\core_message\api::MESSAGE_ACTION_DELETED, $mua3->action);
+
+        $this->assertEquals($user1->id, $mua4->userid);
+        $this->assertEquals($m4id, $mua4->messageid);
+        $this->assertEquals(\core_message\api::MESSAGE_ACTION_DELETED, $mua4->action);
+    }
+
+    /**
+     * Test deleting conversations as other user without proper capability.
+     */
+    public function test_delete_conversations_by_id_as_other_user_without_cap() {
+        $this->resetAfterTest(true);
+
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+
+        // Send some messages back and forth.
+        $time = time();
+        $this->send_message($user1, $user2, 'Yo!', 0, $time);
+        $this->send_message($user2, $user1, 'Sup mang?', 0, $time + 1);
+        $this->send_message($user1, $user2, 'Writing PHPUnit tests!', 0, $time + 2);
+        $this->send_message($user2, $user1, 'Word.', 0, $time + 3);
+
+        $conversationid = \core_message\api::get_conversation_between_users([$user1->id, $user2->id]);
+
+        // The person wanting to delete the conversation.
+        $this->setUser($user3);
+
+        // Ensure an exception is thrown.
+        $this->expectException('moodle_exception');
+        core_message_external::delete_conversations_by_id($user1->id, [$conversationid]);
+    }
+
+    /**
+     * Test deleting conversations with messaging disabled.
+     */
+    public function test_delete_conversations_by_id_messaging_disabled() {
+        global $CFG;
+
+        $this->resetAfterTest(true);
+
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        // Send some messages back and forth.
+        $time = time();
+        $this->send_message($user1, $user2, 'Yo!', 0, $time);
+        $this->send_message($user2, $user1, 'Sup mang?', 0, $time + 1);
+        $this->send_message($user1, $user2, 'Writing PHPUnit tests!', 0, $time + 2);
+        $this->send_message($user2, $user1, 'Word.', 0, $time + 3);
+
+        $conversationid = \core_message\api::get_conversation_between_users([$user1->id, $user2->id]);
+
+        // The person wanting to delete the conversation.
+        $this->setUser($user1);
+
+        // Disable messaging.
+        $CFG->messaging = 0;
+
+        // Ensure an exception is thrown.
+        $this->expectException('moodle_exception');
+        core_message_external::delete_conversations_by_id($user1->id, [$conversationid]);
+    }
+
     /**
      * Test get message processor.
      */
@@ -3507,10 +3693,13 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         $user = self::getDataGenerator()->create_user();
         $this->setUser($user);
 
+        // Enable site-wide messagging privacy setting. The user will be able to receive messages from everybody.
+        set_config('messagingallusers', true);
+
         // Set a couple of preferences to test.
         set_user_preference('message_provider_moodle_instantmessage_loggedin', 'email', $user);
         set_user_preference('message_provider_moodle_instantmessage_loggedoff', 'email', $user);
-        set_user_preference('message_blocknoncontacts', 1, $user);
+        set_user_preference('message_blocknoncontacts', \core_message\api::MESSAGE_PRIVACY_SITE, $user);
 
         $prefs = core_message_external::get_user_message_preferences();
         $prefs = external_api::clean_returnvalue(core_message_external::get_user_message_preferences_returns(), $prefs);
@@ -3518,7 +3707,7 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
 
         // Check components.
         $this->assertCount(1, $prefs['preferences']['components']);
-        $this->assertTrue($prefs['blocknoncontacts']);
+        $this->assertEquals(\core_message\api::MESSAGE_PRIVACY_SITE, $prefs['blocknoncontacts']);
 
         // Check some preferences that we previously set.
         $found = false;
index 3500c79..27e2232 100644 (file)
@@ -160,7 +160,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // Set some message user preferences.
         set_user_preference('message_provider_moodle_instantmessage_loggedin', 'airnotifier', $USER->id);
         set_user_preference('message_provider_moodle_instantmessage_loggedoff', 'popup', $USER->id);
-        set_user_preference('message_blocknoncontacts', 1, $USER->id);
+        set_user_preference('message_blocknoncontacts', \core_message\api::MESSAGE_PRIVACY_ONLYCONTACTS, $USER->id);
         set_user_preference('message_provider_moodle_instantmessage_loggedoff', 'inbound', $user->id);
 
         // Set an unrelated preference.
index 2ada652..b623af2 100644 (file)
@@ -28,10 +28,16 @@ information provided here is intended especially for developers.
   Please see their declaration in lib/deprecatedlib.php to view their alternatives (if applicable).
 * The following methods have been deprecated and should not be used any more:
   - \core_message\api::is_user_blocked()
+  - \core_message\api::delete_conversation()
+* The method \core_message\api::can_delete_conversation() now expects a 'conversationid' to be passed
+  as the second parameter.
 * The following web services have been deprecated. Please do not call these any more.
-  - core_message_external::block_contacts, please use core_message_external::block_user instead.
-  - core_message_external::unblock_contacts, please use core_message_external::unblock_user instead.
-  - core_message_external::create_contacts, please use core_message_external::create_contact_request instead.
+  - core_message_external::block_contacts(), please use core_message_external::block_user() instead.
+  - core_message_external::unblock_contacts(), please use core_message_external::unblock_user() instead.
+  - core_message_external::create_contacts(), please use core_message_external::create_contact_request() instead.
+  - core_message_external::delete_conversation(), please use core_message_external::delete_conversations_by_id() instead.
+* The following function has been added for getting the privacy messaging preference:
+  - get_user_privacy_messaging_preference()
 
 === 3.5 ===
 
index d885b34..5d314bd 100644 (file)
@@ -56,6 +56,12 @@ class backup_assignfeedback_comments_subplugin extends backup_subplugin {
         $subpluginelement->set_source_table('assignfeedback_comments',
                                             array('grade' => backup::VAR_PARENTID));
 
+        $subpluginelement->annotate_files(
+            'assignfeedback_comments',
+            'feedback',
+            'grade'
+        );
+
         return $subplugin;
     }
 }
index 9b95aed..1df4099 100644 (file)
@@ -71,5 +71,13 @@ class restore_assignfeedback_comments_subplugin extends restore_subplugin {
         $data->grade = $this->get_mappingid('grade', $data->grade);
 
         $DB->insert_record('assignfeedback_comments', $data);
+
+        $this->add_related_files(
+            'assignfeedback_comments',
+            'feedback',
+            'grade',
+            null,
+            $oldgradeid
+        );
     }
 }
index ac9aa7f..9302526 100644 (file)
@@ -58,6 +58,8 @@ class provider implements metadataprovider, assignfeedback_provider {
             'commenttext' => 'privacy:metadata:commentpurpose'
         ];
         $collection->add_database_table('assignfeedback_comments', $data, 'privacy:metadata:tablesummary');
+        $collection->link_subsystem('core_files', 'privacy:metadata:filepurpose');
+
         return $collection;
     }
 
@@ -91,13 +93,29 @@ class provider implements metadataprovider, assignfeedback_provider {
         // Get that comment information and jam it into that exporter.
         $assign = $exportdata->get_assign();
         $plugin = $assign->get_plugin_by_type('assignfeedback', 'comments');
-        $comments = $plugin->get_feedback_comments($exportdata->get_pluginobject()->id);
+        $gradeid = $exportdata->get_pluginobject()->id;
+        $comments = $plugin->get_feedback_comments($gradeid);
         if ($comments && !empty($comments->commenttext)) {
-            $data = (object)['commenttext' => format_text($comments->commenttext, $comments->commentformat,
-                    ['context' => $exportdata->get_context()])];
-            writer::with_context($exportdata->get_context())
-                    ->export_data(array_merge($exportdata->get_subcontext(),
-                            [get_string('privacy:commentpath', 'assignfeedback_comments')]), $data);
+            $comments->commenttext = writer::with_context($assign->get_context())->rewrite_pluginfile_urls(
+                [],
+                ASSIGNFEEDBACK_COMMENTS_COMPONENT,
+                ASSIGNFEEDBACK_COMMENTS_FILEAREA,
+                $gradeid,
+                $comments->commenttext
+            );
+
+            $currentpath = array_merge(
+                $exportdata->get_subcontext(),
+                [get_string('privacy:commentpath', 'assignfeedback_comments')]
+            );
+            $data = (object)
+            [
+                'commenttext' => format_text($comments->commenttext, $comments->commentformat,
+                    ['context' => $exportdata->get_context()])
+            ];
+            writer::with_context($exportdata->get_context())->export_data($currentpath, $data);
+            writer::with_context($exportdata->get_context())->export_area_files($currentpath,
+                ASSIGNFEEDBACK_COMMENTS_COMPONENT, ASSIGNFEEDBACK_COMMENTS_FILEAREA, $gradeid);
         }
     }
 
@@ -108,6 +126,10 @@ class provider implements metadataprovider, assignfeedback_provider {
      */
     public static function delete_feedback_for_context(assign_plugin_request_data $requestdata) {
         $assign = $requestdata->get_assign();
+        $fs = get_file_storage();
+        $fs->delete_area_files($requestdata->get_context()->id, ASSIGNFEEDBACK_COMMENTS_COMPONENT,
+            ASSIGNFEEDBACK_COMMENTS_FILEAREA);
+
         $plugin = $assign->get_plugin_by_type('assignfeedback', 'comments');
         $plugin->delete_instance();
     }
@@ -119,6 +141,11 @@ class provider implements metadataprovider, assignfeedback_provider {
      */
     public static function delete_feedback_for_grade(assign_plugin_request_data $requestdata) {
         global $DB;
+
+        $fs = new \file_storage();
+        $fs->delete_area_files($requestdata->get_context()->id, ASSIGNFEEDBACK_COMMENTS_COMPONENT,
+            ASSIGNFEEDBACK_COMMENTS_FILEAREA, $requestdata->get_pluginobject()->id);
+
         $DB->delete_records('assignfeedback_comments', ['assignment' => $requestdata->get_assign()->get_instance()->id,
                 'grade' => $requestdata->get_pluginobject()->id]);
     }
index b8c7551..ee4c540 100644 (file)
@@ -30,6 +30,7 @@ $string['pluginname'] = 'Feedback comments';
 $string['privacy:commentpath'] = 'Feedback comments';
 $string['privacy:metadata:assignmentid'] = 'Assignment ID';
 $string['privacy:metadata:commentpurpose'] = 'The comment text.';
+$string['privacy:metadata:filepurpose'] = 'Feedback files from the teacher for the student.';
 $string['privacy:metadata:gradepurpose'] = 'The grade ID associated with the comment.';
 $string['privacy:metadata:tablesummary'] = 'This stores comments made by the graders as feedback for the student on their submission.';
 $string['commentinline'] = 'Comment inline';
diff --git a/mod/assign/feedback/comments/lib.php b/mod/assign/feedback/comments/lib.php
new file mode 100644 (file)
index 0000000..1939422
--- /dev/null
@@ -0,0 +1,82 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This file contains the moodle hooks for the comments feedback plugin
+ *
+ * @package   assignfeedback_comments
+ * @copyright 2018 Mark Nelson <markn@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Serves assignment comment feedback files.
+ *
+ * @param mixed $course course or id of the course
+ * @param mixed $cm course module or id of the course module
+ * @param context $context
+ * @param string $filearea
+ * @param array $args
+ * @param bool $forcedownload
+ * @param array $options - List of options affecting file serving.
+ * @return bool false if file not found, does not return if found - just send the file
+ */
+function assignfeedback_comments_pluginfile(
+        $course,
+        $cm,
+        context $context,
+        $filearea,
+        $args,
+        $forcedownload,
+        array $options = []) {
+    global $CFG, $DB;
+
+    require_once($CFG->dirroot . '/mod/assign/locallib.php');
+
+    if ($context->contextlevel != CONTEXT_MODULE) {
+        return false;
+    }
+
+    require_login($course, false, $cm);
+    $itemid = (int)array_shift($args);
+    $record = $DB->get_record('assign_grades', array('id' => $itemid), 'userid,assignment', MUST_EXIST);
+    $userid = $record->userid;
+
+    $assign = new assign($context, $cm, $course);
+    $instance = $assign->get_instance();
+
+    if ($instance->id != $record->assignment) {
+        return false;
+    }
+
+    if (!$assign->can_view_submission($userid)) {
+        return false;
+    }
+
+    $relativepath = implode('/', $args);
+
+    $fullpath = "/{$context->id}/assignfeedback_comments/$filearea/$itemid/$relativepath";
+
+    $fs = get_file_storage();
+
+    if (!$file = $fs->get_file_by_hash(sha1($fullpath)) or $file->is_directory()) {
+        return false;
+    }
+
+    // Download MUST be forced - security!
+    send_stored_file($file, 0, 0, true, $options);
+}
index e6c0f4a..e9dc83d 100644 (file)
 
 defined('MOODLE_INTERNAL') || die();
 
+// File component for feedback comments.
+define('ASSIGNFEEDBACK_COMMENTS_COMPONENT', 'assignfeedback_comments');
+
+// File area for feedback comments.
+define('ASSIGNFEEDBACK_COMMENTS_FILEAREA', 'feedback');
+
 /**
  * Library class for comment feedback plugin extending feedback plugin base class.
  *
@@ -116,7 +122,15 @@ class assign_feedback_comments extends assign_feedback_plugin {
             }
         }
 
-        if ($commenttext == $data->assignfeedbackcomments_editor['text']) {
+        $formtext = $data->assignfeedbackcomments_editor['text'];
+
+        // Need to convert the form text to use @@PLUGINFILE@@ and format it so we can compare it with what is stored in the DB.
+        if (isset($data->assignfeedbackcomments_editor['itemid'])) {
+            $formtext = file_rewrite_urls_to_pluginfile($formtext, $data->assignfeedbackcomments_editor['itemid']);
+            $formtext = format_text($formtext, FORMAT_HTML);
+        }
+
+        if ($commenttext == $formtext) {
             return false;
         } else {
             return true;
@@ -254,18 +268,47 @@ class assign_feedback_comments extends assign_feedback_plugin {
      *
      * @param stdClass $submission
      * @param stdClass $data - Form data to be filled with the converted submission text and format.
+     * @param stdClass|null $grade
      * @return boolean - True if feedback text was set.
      */
-    protected function convert_submission_text_to_feedback($submission, $data) {
+    protected function convert_submission_text_to_feedback($submission, $data, $grade) {
+        global $DB;
+
         $format = false;
         $text = '';
 
         foreach ($this->assignment->get_submission_plugins() as $plugin) {
             $fields = $plugin->get_editor_fields();
             if ($plugin->is_enabled() && $plugin->is_visible() && !$plugin->is_empty($submission) && !empty($fields)) {
+                $user = $DB->get_record('user', ['id' => $submission->userid]);
+                // Copy the files to the feedback area.
+                if ($files = $plugin->get_files($submission, $user)) {
+                    $fs = get_file_storage();
+                    $component = 'assignfeedback_comments';
+                    $filearea = ASSIGNFEEDBACK_COMMENTS_FILEAREA;
+                    $itemid = $grade->id;
+                    $fieldupdates = [
+                        'component' => $component,
+                        'filearea' => $filearea,
+                        'itemid' => $itemid
+                    ];
+                    foreach ($files as $file) {
+                        if ($file instanceof stored_file) {
+                            // Before we create it, check that it doesn't already exist.
+                            if (!$fs->file_exists(
+                                    $file->get_contextid(),
+                                    $component,
+                                    $filearea,
+                                    $itemid,
+                                    $file->get_filepath(),
+                                    $file->get_filename())) {
+                                $fs->create_file_from_storedfile($fieldupdates, $file);
+                            }
+                        }
+                    }
+                }
                 foreach ($fields as $key => $description) {
-                    $rawtext = strip_pluginfile_content($plugin->get_editor_text($key, $submission->id));
-
+                    $rawtext = clean_text($plugin->get_editor_text($key, $submission->id));
                     $newformat = $plugin->get_editor_format($key, $submission->id);
 
                     if ($format !== false && $newformat != $format) {
@@ -282,8 +325,8 @@ class assign_feedback_comments extends assign_feedback_plugin {
         if ($format === false) {
             $format = FORMAT_HTML;
         }
-        $data->assignfeedbackcomments_editor['text'] = $text;
-        $data->assignfeedbackcomments_editor['format'] = $format;
+        $data->assignfeedbackcomments = $text;
+        $data->assignfeedbackcommentsformat = $format;
 
         return true;
     }
@@ -306,16 +349,29 @@ class assign_feedback_comments extends assign_feedback_plugin {
         }
 
         if ($feedbackcomments && !empty($feedbackcomments->commenttext)) {
-            $data->assignfeedbackcomments_editor['text'] = $feedbackcomments->commenttext;
-            $data->assignfeedbackcomments_editor['format'] = $feedbackcomments->commentformat;
+            $data->assignfeedbackcomments = $feedbackcomments->commenttext;
+            $data->assignfeedbackcommentsformat = $feedbackcomments->commentformat;
         } else {
             // No feedback given yet - maybe we need to copy the text from the submission?
             if (!empty($commentinlinenabled) && $submission) {
-                $this->convert_submission_text_to_feedback($submission, $data);
+                $this->convert_submission_text_to_feedback($submission, $data, $grade);
+            } else { // Set it to empty.
+                $data->assignfeedbackcomments = '';
+                $data->assignfeedbackcommentsformat = FORMAT_HTML;
             }
         }
 
-        $mform->addElement('editor', 'assignfeedbackcomments_editor', $this->get_name(), null, null);
+        file_prepare_standard_editor(
+            $data,
+            'assignfeedbackcomments',
+            $this->get_editor_options(),
+            $this->assignment->get_context(),
+            ASSIGNFEEDBACK_COMMENTS_COMPONENT,
+            ASSIGNFEEDBACK_COMMENTS_FILEAREA,
+            $grade->id
+        );
+
+        $mform->addElement('editor', 'assignfeedbackcomments_editor', $this->get_name(), null, $this->get_editor_options());
 
         return true;
     }