Merge branch 'MDL-64987-master' of git://github.com/abgreeve/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Mon, 18 Mar 2019 23:21:07 +0000 (00:21 +0100)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Mon, 18 Mar 2019 23:21:07 +0000 (00:21 +0100)
119 files changed:
admin/classes/form/testoutgoingmailconf_form.php [new file with mode: 0644]
admin/message.php
admin/roles/classes/check_users_selector.php
admin/settings/messaging.php [new file with mode: 0644]
admin/settings/plugins.php
admin/settings/server.php
admin/settings/subsystems.php
admin/settings/top.php
admin/testoutgoingmailconf.php [new file with mode: 0644]
admin/tool/analytics/amd/build/model.min.js
admin/tool/analytics/amd/src/model.js
admin/tool/analytics/classes/output/model_logs.php
admin/tool/analytics/classes/output/models_list.php
admin/tool/analytics/cli/evaluate_model.php
admin/tool/analytics/lang/en/tool_analytics.php
admin/tool/analytics/model.php
admin/tool/analytics/templates/evaluation_mode_selection.mustache [new file with mode: 0644]
admin/tool/customlang/classes/output/renderer.php [new file with mode: 0644]
admin/tool/customlang/classes/output/translator.php [new file with mode: 0644]
admin/tool/customlang/renderer.php [deleted file]
admin/tool/customlang/styles.css [deleted file]
admin/tool/customlang/templates/translator.mustache [new file with mode: 0644]
admin/tool/dataprivacy/tests/coverage.php [new file with mode: 0644]
admin/tool/policy/classes/output/page_agreedocs.php
admin/tool/policy/tests/behat/acceptances.feature
analytics/classes/classifier.php
analytics/classes/model.php
analytics/classes/regressor.php
analytics/classes/stats.php [new file with mode: 0644]
analytics/tests/prediction_test.php
analytics/tests/stats_test.php [new file with mode: 0644]
analytics/upgrade.txt
blocks/myoverview/lang/en/block_myoverview.php
blocks/myoverview/lang/en/deprecated.txt
blocks/myoverview/templates/nav-sort-selector.mustache
blocks/myoverview/templates/progress-bar.mustache
enrol/externallib.php
enrol/tests/externallib_test.php
enrol/upgrade.txt
files/renderer.php
group/lib.php
install/lang/hi/moodle.php
lang/en/admin.php
lang/en/deprecated.txt
lang/en/hub.php
lang/en/message.php
lang/en/plugin.php
lib/adminlib.php
lib/behat/classes/partial_named_selector.php
lib/classes/hub/registration.php
lib/classes/message/manager.php
lib/classes/output/icon_system_fontawesome.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/mlbackend/php/classes/processor.php
lib/mlbackend/python/classes/processor.php
lib/phpunit/classes/coverage_info.php [new file with mode: 0644]
lib/phpunit/classes/util.php
lib/tests/behat/behat_hooks.php
lib/tests/coverage.php [new file with mode: 0644]
lib/tests/messagelib_test.php
lib/upgrade.txt
message/amd/build/message_drawer_events.min.js
message/amd/build/message_drawer_view_conversation.min.js
message/amd/build/message_drawer_view_conversation_constants.min.js
message/amd/build/message_drawer_view_conversation_patcher.min.js
message/amd/build/message_drawer_view_conversation_renderer.min.js
message/amd/build/message_drawer_view_conversation_state_manager.min.js
message/amd/build/message_drawer_view_overview_section.min.js
message/amd/build/message_repository.min.js
message/amd/src/message_drawer_events.js
message/amd/src/message_drawer_view_conversation.js
message/amd/src/message_drawer_view_conversation_constants.js
message/amd/src/message_drawer_view_conversation_patcher.js
message/amd/src/message_drawer_view_conversation_renderer.js
message/amd/src/message_drawer_view_conversation_state_manager.js
message/amd/src/message_drawer_view_overview_section.js
message/amd/src/message_repository.js
message/classes/api.php
message/classes/privacy/provider.php
message/defaultoutputs.php
message/externallib.php
message/renderer.php
message/templates/message_drawer_conversations_list.mustache
message/templates/message_drawer_view_conversation_header_content_type_private.mustache
message/templates/message_drawer_view_conversation_header_content_type_private_no_controls.mustache
message/templates/message_drawer_view_conversation_header_content_type_public.mustache
message/tests/api_test.php
message/tests/behat/behat_message.php
message/tests/behat/group_conversation.feature [new file with mode: 0644]
message/tests/externallib_test.php
message/tests/privacy_provider_test.php
mod/forum/db/services.php
mod/forum/externallib.php
mod/forum/tests/externallib_test.php
mod/forum/version.php
mod/quiz/lib.php
mod/quiz/tests/lib_test.php
mod/workshop/lib.php
mod/workshop/tests/lib_test.php
phpunit.xml.dist
pix/i/muted.png [new file with mode: 0644]
pix/i/muted.svg [new file with mode: 0644]
privacy/tests/coverage.php [new file with mode: 0644]
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/filemanager.scss
theme/boost/style/moodle.css
theme/boost/tests/behat/group_conversation.feature [new file with mode: 0644]
theme/bootstrapbase/less/moodle/filemanager.less
theme/bootstrapbase/style/moodle.css
theme/bootstrapbase/templates/core_message/message_drawer_view_conversation_header_content_type_private.mustache
theme/bootstrapbase/templates/core_message/message_drawer_view_conversation_header_content_type_private_no_controls.mustache
theme/bootstrapbase/templates/core_message/message_drawer_view_conversation_header_content_type_public.mustache
user/lib.php
version.php
webservice/externallib.php
webservice/tests/externallib_test.php
webservice/upgrade.txt

diff --git a/admin/classes/form/testoutgoingmailconf_form.php b/admin/classes/form/testoutgoingmailconf_form.php
new file mode 100644 (file)
index 0000000..fbf8ac4
--- /dev/null
@@ -0,0 +1,59 @@
+<?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 outgoing mail configuration form
+ *
+ * @package    core
+ * @copyright  2019 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_admin\form;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir.'/formslib.php');
+
+/**
+ * Test mail form
+ *
+ * @package    core
+ * @copyright 2019 Victor Deniz <victor@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class testoutgoingmailconf_form extends \moodleform {
+    /**
+     * Add elements to form
+     */
+    public function definition() {
+        $mform = $this->_form;
+
+        // Recipient.
+        $options = ['maxlength' => '100', 'size' => '25'];
+        $mform->addElement('text', 'recipient', get_string('testoutgoingmailconf_toemail', 'admin'), $options);
+        $mform->setType('recipient', PARAM_EMAIL);
+        $mform->addRule('recipient', get_string('required'), 'required');
+
+        $buttonarray = array();
+        $buttonarray[] = $mform->createElement('submit', 'send', get_string('testoutgoingmailconf_sendtest', 'admin'));
+        $buttonarray[] = $mform->createElement('cancel');
+
+        $mform->addGroup($buttonarray, 'buttonar', '', array(' '), false);
+        $mform->closeHeaderBefore('buttonar');
+
+    }
+}
index c10034a..9a43f61 100644 (file)
@@ -25,47 +25,101 @@ require_once(__DIR__ . '/../config.php');
 require_once($CFG->dirroot . '/message/lib.php');
 require_once($CFG->libdir.'/adminlib.php');
 
-// This is an admin page
+// This is an admin page.
 admin_externalpage_setup('managemessageoutputs');
 
-// Get the submitted params
-$disable    = optional_param('disable', 0, PARAM_INT);
-$enable     = optional_param('enable', 0, PARAM_INT);
+// Fetch processors.
+$allprocessors = get_message_processors();
+$processors = array_filter($allprocessors, function($processor) {
+    return $processor->enabled;
+});
+// Fetch message providers.
+$providers = get_message_providers();
+// Fetch the manage message outputs interface.
+$preferences = get_message_output_default_preferences();
 
-$headingtitle = get_string('managemessageoutputs', 'message');
+if (($form = data_submitted()) && confirm_sesskey()) {
+    $preferences = array();
+    // Prepare default message outputs settings.
+    foreach ($providers as $provider) {
+        $componentproviderbase = $provider->component.'_'.$provider->name;
+        $disableprovidersetting = $componentproviderbase.'_disable';
+        $providerdisabled = false;
+        if (!isset($form->$disableprovidersetting)) {
+            $providerdisabled = true;
+            $preferences[$disableprovidersetting] = 1;
+        } else {
+            $preferences[$disableprovidersetting] = 0;
+        }
 
-if (!empty($disable) && confirm_sesskey()) {
-    if (!$processor = $DB->get_record('message_processors', array('id'=>$disable))) {
-        print_error('outputdoesnotexist', 'message');
+        foreach (array('permitted', 'loggedin', 'loggedoff') as $setting) {
+            $value = null;
+            $componentprovidersetting = $componentproviderbase.'_'.$setting;
+            if ($setting == 'permitted') {
+                // If we deal with permitted select element, we need to create individual
+                // setting for each possible processor. Note that this block will
+                // always be processed first after entring parental foreach iteration
+                // so we can change form values on this stage.
+                foreach ($allprocessors as $processor) {
+                    $value = '';
+                    if (isset($form->{$componentprovidersetting}[$processor->name])) {
+                        $value = $form->{$componentprovidersetting}[$processor->name];
+                    }
+                    // Ensure that loggedin loggedoff options are set correctly for this permission.
+                    if (($value == 'disallowed') || $providerdisabled) {
+                        // It might be better to unset them, but I can't figure out why that cause error.
+                        $form->{$componentproviderbase.'_loggedin'}[$processor->name] = 0;
+                        $form->{$componentproviderbase.'_loggedoff'}[$processor->name] = 0;
+                    } else if ($value == 'forced') {
+                        $form->{$componentproviderbase.'_loggedin'}[$processor->name] = 1;
+                        $form->{$componentproviderbase.'_loggedoff'}[$processor->name] = 1;
+                    }
+                    // Record the site preference.
+                    $preferences[$processor->name.'_provider_'.$componentprovidersetting] = $value;
+                }
+            } else if (array_key_exists($componentprovidersetting, $form)) {
+                // We must be processing loggedin or loggedoff checkboxes. Store
+                // defained comma-separated processors as setting value.
+                // Using array_filter eliminates elements set to 0 above.
+                $value = join(',', array_keys(array_filter($form->{$componentprovidersetting})));
+                if (empty($value)) {
+                    $value = null;
+                }
+            }
+            if ($setting != 'permitted') {
+                // We have already recoded site preferences for 'permitted' type.
+                $preferences['message_provider_'.$componentprovidersetting] = $value;
+            }
+        }
+    }
+
+    // Update database.
+    $transaction = $DB->start_delegated_transaction();
+
+    // Save processors enabled/disabled status.
+    foreach ($allprocessors as $processor) {
+        $enabled = isset($form->{$processor->name});
+        \core_message\api::update_processor_status($processor, $enabled);
     }
-    \core_message\api::update_processor_status($processor, 0);     // Disable output.
-    core_plugin_manager::reset_caches();
-}
 
-if (!empty($enable) && confirm_sesskey()) {
-    if (!$processor = $DB->get_record('message_processors', array('id'=>$enable))) {
-        print_error('outputdoesnotexist', 'message');
+    foreach ($preferences as $name => $value) {
+        set_config($name, $value, 'message');
     }
-    \core_message\api::update_processor_status($processor, 1);      // Enable output.
+    $transaction->allow_commit();
+
     core_plugin_manager::reset_caches();
-}
 
-if ($disable || $enable) {
     $url = new moodle_url('message.php');
     redirect($url);
 }
+
 // Page settings
 $PAGE->set_context(context_system::instance());
+$PAGE->requires->js_init_call('M.core_message.init_defaultoutputs');
 
-// Grab the renderer
 $renderer = $PAGE->get_renderer('core', 'message');
 
-// Display the manage message outputs interface
-$processors = get_message_processors();
-$messageoutputs = $renderer->manage_messageoutputs($processors);
-
-// Display the page
+// Display the page.
 echo $OUTPUT->header();
-echo $OUTPUT->heading($headingtitle);
-echo $messageoutputs;
+echo $renderer->manage_messageoutput_settings($allprocessors, $processors, $providers, $preferences);
 echo $OUTPUT->footer();
index 5ac4c26..31929bd 100644 (file)
@@ -69,8 +69,11 @@ class core_role_check_users_selector extends user_selector_base {
 
         if ($coursecontext and $coursecontext != SITEID) {
             $sql1 = " FROM {user} u
-                      JOIN {user_enrolments} ue ON (ue.userid = u.id)
-                      JOIN {enrol} e ON (e.id = ue.enrolid AND e.courseid = :courseid1)
+                      JOIN (SELECT DISTINCT subu.id
+                              FROM {user} subu
+                              JOIN {user_enrolments} ue ON (ue.userid = subu.id)
+                              JOIN {enrol} e ON (e.id = ue.enrolid AND e.courseid = :courseid1)
+                           ) subq ON subq.id = u.id
                      WHERE $wherecondition";
             $params['courseid1'] = $coursecontext->instanceid;
 
diff --git a/admin/settings/messaging.php b/admin/settings/messaging.php
new file mode 100644 (file)
index 0000000..2221e72
--- /dev/null
@@ -0,0 +1,78 @@
+<?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/>.
+
+/**
+ * Adds messaging related settings links for Messaging category to admin tree.
+ *
+ * @copyright 2019 Amaia Anabitarte <amaia@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+if ($hassiteconfig) {
+    $temp = new admin_settingpage('messages', new lang_string('messagingssettings', 'admin'));
+    $temp->add(new admin_setting_configcheckbox('messaging',
+        new lang_string('messaging', 'admin'),
+        new lang_string('configmessaging', 'admin'),
+        1));
+    $temp->add(new admin_setting_configcheckbox('messagingallusers',
+            new lang_string('messagingallusers', 'admin'),
+            new lang_string('configmessagingallusers', 'admin'),
+             0)
+    );
+    $temp->add(new admin_setting_configcheckbox('messagingdefaultpressenter',
+            new lang_string('messagingdefaultpressenter', 'admin'),
+            new lang_string('configmessagingdefaultpressenter', 'admin'),
+            1)
+    );
+    $options = array(
+        DAYSECS => new lang_string('secondstotime86400'),
+        WEEKSECS => new lang_string('secondstotime604800'),
+        2620800 => new lang_string('nummonths', 'moodle', 1),
+        7862400 => new lang_string('nummonths', 'moodle', 3),
+        15724800 => new lang_string('nummonths', 'moodle', 6),
+        0 => new lang_string('never')
+    );
+    $temp->add(new admin_setting_configselect(
+            'messagingdeletereadnotificationsdelay',
+            new lang_string('messagingdeletereadnotificationsdelay', 'admin'),
+            new lang_string('configmessagingdeletereadnotificationsdelay', 'admin'),
+            604800,
+            $options)
+    );
+    $temp->add(new admin_setting_configselect(
+            'messagingdeleteallnotificationsdelay',
+            new lang_string('messagingdeleteallnotificationsdelay', 'admin'),
+            new lang_string('configmessagingdeleteallnotificationsdelay', 'admin'),
+            2620800,
+            $options)
+    );
+    $temp->add(new admin_setting_configcheckbox('messagingallowemailoverride',
+        new lang_string('messagingallowemailoverride', 'admin'),
+        new lang_string('configmessagingallowemailoverride', 'admin'),
+        0));
+    $ADMIN->add('messaging', $temp);
+    $ADMIN->add('messaging', new admin_page_managemessageoutputs());
+
+    // Notification outputs plugins.
+    $plugins = core_plugin_manager::instance()->get_plugins_of_type('message');
+    core_collator::asort_objects_by_property($plugins, 'displayname');
+    foreach ($plugins as $plugin) {
+        /** @var \core\plugininfo\message $plugin */
+        $plugin->load_settings($ADMIN, 'messaging', $hassiteconfig);
+    }
+}
index 485eb01..925829c 100644 (file)
@@ -81,17 +81,6 @@ if ($hassiteconfig) {
         $plugin->load_settings($ADMIN, 'blocksettings', $hassiteconfig);
     }
 
-    // message outputs
-    $ADMIN->add('modules', new admin_category('messageoutputs', new lang_string('messageoutputs', 'message')));
-    $ADMIN->add('messageoutputs', new admin_page_managemessageoutputs());
-    $ADMIN->add('messageoutputs', new admin_page_defaultmessageoutputs());
-    $plugins = core_plugin_manager::instance()->get_plugins_of_type('message');
-    core_collator::asort_objects_by_property($plugins, 'displayname');
-    foreach ($plugins as $plugin) {
-        /** @var \core\plugininfo\message $plugin */
-        $plugin->load_settings($ADMIN, 'messageoutputs', $hassiteconfig);
-    }
-
     // authentication plugins
     $ADMIN->add('modules', new admin_category('authsettings', new lang_string('authentication', 'admin')));
 
index 96c7871..8c47a56 100644 (file)
@@ -176,6 +176,8 @@ $ADMIN->add('server', $temp);
 
 $ADMIN->add('server', new admin_externalpage('environment', new lang_string('environment','admin'), "$CFG->wwwroot/$CFG->admin/environment.php"));
 $ADMIN->add('server', new admin_externalpage('phpinfo', new lang_string('phpinfo'), "$CFG->wwwroot/$CFG->admin/phpinfo.php"));
+$ADMIN->add('server', new admin_externalpage('testoutgoingmailconf', new lang_string('testoutgoingmailconf', 'admin'),
+            new moodle_url("$CFG->wwwroot/$CFG->admin/testoutgoingmailconf.php"), 'moodle/site:config', true));
 
 
 // "performance" settingpage
@@ -326,6 +328,10 @@ $temp->add(new admin_setting_configtextarea('allowedemaildomains',
         new lang_string('allowedemaildomains', 'admin'),
         new lang_string('configallowedemaildomains', 'admin'),
         ''));
+$url = new moodle_url('/admin/testoutgoingmailconf.php');
+$link = html_writer::link($url, get_string('testoutgoingmailconf', 'admin'));
+$temp->add(new admin_setting_heading('testoutgoinmailc', new lang_string('testoutgoingmailconf', 'admin'),
+        new lang_string('testoutgoingmaildetail', 'admin', $link)));
 $temp->add(new admin_setting_heading('emaildoesnotfit', new lang_string('doesnotfit', 'admin'),
         new lang_string('doesnotfitdetail', 'admin')));
 $charsets = get_list_of_charsets();
index b559de3..de5f75b 100644 (file)
@@ -13,45 +13,6 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
 
     $optionalsubsystems->add(new admin_setting_configcheckbox('enablewebservices', new lang_string('enablewebservices', 'admin'), new lang_string('configenablewebservices', 'admin'), 0));
 
-    $optionalsubsystems->add(new admin_setting_configcheckbox('messaging', new lang_string('messaging', 'admin'), new lang_string('configmessaging','admin'), 1));
-
-    $optionalsubsystems->add(new admin_setting_configcheckbox('messagingallusers',
-        new lang_string('messagingallusers', 'admin'),
-        new lang_string('configmessagingallusers', 'admin'),
-        0)
-    );
-
-    $optionalsubsystems->add(new admin_setting_configcheckbox('messagingdefaultpressenter',
-        new lang_string('messagingdefaultpressenter', 'admin'),
-        new lang_string('configmessagingdefaultpressenter', 'admin'),
-        1)
-    );
-
-    $options = array(
-        DAYSECS => new lang_string('secondstotime86400'),
-        WEEKSECS => new lang_string('secondstotime604800'),
-        2620800 => new lang_string('nummonths', 'moodle', 1),
-        7862400 => new lang_string('nummonths', 'moodle', 3),
-        15724800 => new lang_string('nummonths', 'moodle', 6),
-        0 => new lang_string('never')
-    );
-    $optionalsubsystems->add(new admin_setting_configselect(
-        'messagingdeletereadnotificationsdelay',
-        new lang_string('messagingdeletereadnotificationsdelay', 'admin'),
-        new lang_string('configmessagingdeletereadnotificationsdelay', 'admin'),
-        604800,
-        $options)
-    );
-    $optionalsubsystems->add(new admin_setting_configselect(
-        'messagingdeleteallnotificationsdelay',
-        new lang_string('messagingdeleteallnotificationsdelay', 'admin'),
-        new lang_string('configmessagingdeleteallnotificationsdelay', 'admin'),
-        2620800,
-        $options)
-    );
-
-    $optionalsubsystems->add(new admin_setting_configcheckbox('messagingallowemailoverride', new lang_string('messagingallowemailoverride', 'admin'), new lang_string('configmessagingallowemailoverride','admin'), 0));
-
     $optionalsubsystems->add(new admin_setting_configcheckbox('enablestats', new lang_string('enablestats', 'admin'), new lang_string('configenablestats', 'admin'), 0));
 
     $optionalsubsystems->add(new admin_setting_configcheckbox('enablerssfeeds', new lang_string('enablerssfeeds', 'admin'), new lang_string('configenablerssfeeds', 'admin'), 0));
index 102b758..32d91c6 100644 (file)
@@ -33,6 +33,7 @@ $ADMIN->add('root', new admin_category('competencies', new lang_string('competen
 $ADMIN->add('root', new admin_category('badges', new lang_string('badges'), empty($CFG->enablebadges)));
 $ADMIN->add('root', new admin_category('location', new lang_string('location','admin')));
 $ADMIN->add('root', new admin_category('language', new lang_string('language')));
+$ADMIN->add('root', new admin_category('messaging', new lang_string('messagingcategory', 'admin')));
 $ADMIN->add('root', new admin_category('modules', new lang_string('plugins', 'admin')));
 $ADMIN->add('root', new admin_category('security', new lang_string('security','admin')));
 $ADMIN->add('root', new admin_category('appearance', new lang_string('appearance','admin')));
diff --git a/admin/testoutgoingmailconf.php b/admin/testoutgoingmailconf.php
new file mode 100644 (file)
index 0000000..ce5857f
--- /dev/null
@@ -0,0 +1,92 @@
+<?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/>.
+
+/**
+ * Test output mail configuration page
+ *
+ * @copyright 2019 Victor Deniz <victor@moodle.com>, based on Michael Milette <michael.milette@tngconsulting.ca> code
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(__DIR__ . '/../config.php');
+require_once($CFG->libdir.'/adminlib.php');
+
+// This is an admin page.
+admin_externalpage_setup('testoutgoingmailconf');
+
+$headingtitle = get_string('testoutgoingmailconf', 'admin');
+$homeurl = new moodle_url('/admin/category.php', array('category' => 'email'));
+$returnurl = new moodle_url('/admin/testoutgoingconf.php');
+
+$form = new core_admin\form\testoutgoingmailconf_form(null, ['returnurl' => $returnurl]);
+if ($form->is_cancelled()) {
+    redirect($homeurl);
+}
+
+// Display the page.
+echo $OUTPUT->header();
+echo $OUTPUT->heading($headingtitle);
+
+$data = $form->get_data();
+if ($data) {
+    $emailuser = new stdClass();
+    $emailuser->email = $data->recipient;
+    $emailuser->id = -99;
+
+    $subject = get_string('testoutgoingmailconf_subject', 'admin', $SITE->fullname);
+    $messagetext = get_string('testoutgoingmailconf_message', 'admin');
+
+    // Manage Moodle debugging options.
+    $debuglevel = $CFG->debug;
+    $debugdisplay = $CFG->debugdisplay;
+    $debugsmtp = $CFG->debugsmtp;
+    $CFG->debugdisplay = true;
+    $CFG->debugsmtp = true;
+    $CFG->debug = 15;
+
+    // Send test email.
+    ob_start();
+    $success = email_to_user($emailuser, $USER, $subject, $messagetext);
+    $smtplog = ob_get_contents();
+    ob_end_clean();
+
+    // Restore Moodle debugging options.
+    $CFG->debug = $debuglevel;
+    $CFG->debugdisplay = $debugdisplay;
+    $CFG->debugsmtp = $debugsmtp;
+
+    if ($success) {
+        $msgparams = new stdClass();
+        $msgparams->fromemail = $USER->email;
+        $msgparams->toemail = $emailuser->email;
+        $msg = get_string('testoutgoingmailconf_sentmail', 'admin', $msgparams);
+        $notificationtype = 'notifysuccess';
+    } else {
+        $notificationtype = 'notifyproblem';
+        // No communication between Moodle and the SMTP server - no error output.
+        if (trim($smtplog) == false) {
+            $msg = get_string('testoutgoingmailconf_errorcommunications', 'admin');
+        } else {
+            $msg = $smtplog;
+        }
+    }
+
+    // Show result.
+    echo $OUTPUT->notification($msg, $notificationtype);
+}
+
+$form->display();
+echo $OUTPUT->footer();
index c66254a..180805d 100644 (file)
Binary files a/admin/tool/analytics/amd/build/model.min.js and b/admin/tool/analytics/amd/build/model.min.js differ
index 2f0b605..f39c577 100644 (file)
@@ -20,8 +20,8 @@
  * @copyright  2017 David Monllao
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/str', 'core/log', 'core/notification', 'core/modal_factory', 'core/modal_events'],
-    function($, Str, log, Notification, ModalFactory, ModalEvents) {
+define(['jquery', 'core/str', 'core/log', 'core/notification', 'core/modal_factory', 'core/modal_events', 'core/templates'],
+    function($, Str, log, Notification, ModalFactory, ModalEvents, Templates) {
 
     /**
      * List of actions that require confirmation and confirmation message.
@@ -94,10 +94,65 @@ define(['jquery', 'core/str', 'core/log', 'core/notification', 'core/modal_facto
                     modal.getRoot().on(ModalEvents.save, function() {
                         window.location.href = a.attr('href');
                     });
+                    modal.show();
+                    return modal;
+                }).fail(Notification.exception);
+            });
+        },
+
+        /**
+         * Displays a select-evaluation-mode choice.
+         *
+         * @param  {String}  actionId
+         * @param  {Boolean} trainedOnlyExternally
+         */
+        selectEvaluationMode: function(actionId, trainedOnlyExternally) {
+            $('[data-action-id="' + actionId + '"]').on('click', function(ev) {
+                ev.preventDefault();
+
+                var a = $(ev.currentTarget);
+
+                if (!trainedOnlyExternally) {
+                    // We can not evaluate trained models if the model was trained using data from this site.
+                    // Default to evaluate the model configuration if that is the case.
+                    window.location.href = a.attr('href');
+                    return;
+                }
+
+                var stringsPromise = Str.get_strings([
+                    {
+                        key: 'evaluatemodel',
+                        component: 'tool_analytics'
+                    }, {
+                        key: 'evaluationmode',
+                        component: 'tool_analytics'
+                    }
+                ]);
+                var modalPromise = ModalFactory.create({type: ModalFactory.types.SAVE_CANCEL});
+                var bodyPromise = Templates.render('tool_analytics/evaluation_mode_selection', {});
+
+                $.when(stringsPromise, modalPromise).then(function(strings, modal) {
+
+
+                    modal.getRoot().on(ModalEvents.hidden, modal.destroy.bind(modal));
+
+                    modal.setTitle(strings[1]);
+                    modal.setSaveButtonText(strings[0]);
+                    modal.setBody(bodyPromise);
+
+                    modal.getRoot().on(ModalEvents.save, function() {
+                        var evaluationMode = $("input[name='evaluationmode']:checked").val();
+                        if (evaluationMode == 'trainedmodel') {
+                            a.attr('href', a.attr('href') + '&mode=trainedmodel');
+                        }
+                        window.location.href = a.attr('href');
+                        return;
+                    });
+
                     modal.show();
                     return modal;
                 }).fail(Notification.exception);
             });
         }
     };
-});
+});
\ No newline at end of file
index df59038..2e3a552 100644 (file)
@@ -41,6 +41,11 @@ class model_logs extends \table_sql {
      */
     protected $model = null;
 
+    /**
+     * @var string|false
+     */
+    protected $evaluationmode = false;
+
     /**
      * Sets up the table_log parameters.
      *
@@ -57,21 +62,32 @@ class model_logs extends \table_sql {
         $this->set_attribute('class', 'modellog generaltable generalbox');
         $this->set_attribute('aria-live', 'polite');
 
-        $this->define_columns(array('time', 'version', 'indicators', 'timesplitting', 'accuracy', 'info', 'usermodified'));
+        $this->define_columns(array('time', 'version', 'evaluationmode', 'indicators', 'timesplitting',
+            'accuracy', 'info', 'usermodified'));
         $this->define_headers(array(
             get_string('time'),
             get_string('version'),
+            get_string('evaluationmode', 'tool_analytics'),
             get_string('indicators', 'tool_analytics'),
             get_string('timesplittingmethod', 'analytics'),
             get_string('accuracy', 'tool_analytics'),
             get_string('info', 'tool_analytics'),
             get_string('fullnameuser'),
         ));
+
+        $evaluationmodehelp = new \help_icon('evaluationmode', 'tool_analytics');
+        $this->define_help_for_headers([null, null, $evaluationmodehelp, null, null, null, null, null]);
+
         $this->pageable(true);
         $this->collapsible(false);
         $this->sortable(false);
         $this->is_downloadable(false);
 
+        $this->evaluationmode = optional_param('evaluationmode', false, PARAM_ALPHANUM);
+        if ($this->evaluationmode && $this->evaluationmode != 'configuration' && $this->evaluationmode != 'trainedmodel') {
+            $this->evaluationmode = '';
+        }
+
         $this->define_baseurl($PAGE->url);
     }
 
@@ -86,6 +102,15 @@ class model_logs extends \table_sql {
         return userdate($log->version, $recenttimestr);
     }
 
+    /**
+     * Generate the evaluation mode column.
+     *
+     * @param \stdClass $log log data.
+     * @return string HTML for the evaluationmode column
+     */
+    public function col_evaluationmode($log) {
+        return get_string('evaluationmodecol' . $log->evaluationmode, 'tool_analytics');
+    }
     /**
      * Generate the time column.
      *
index b3a1c43..351dae4 100644 (file)
@@ -187,15 +187,21 @@ class models_list implements \renderable, \templatable {
 
             // Evaluate machine-learning-based models.
             if (!$onlycli && $model->get_indicators() && !$model->is_static()) {
+
+                // Extra is_trained call as trained_locally returns false if the model has not been trained yet.
+                $trainedonlyexternally = !$model->trained_locally() && $model->is_trained();
+
+                $actionid = 'evaluate-' . $model->get_id();
+                $PAGE->requires->js_call_amd('tool_analytics/model', 'selectEvaluationMode', [$actionid, $trainedonlyexternally]);
                 $urlparams['action'] = 'evaluate';
                 $url = new \moodle_url('model.php', $urlparams);
                 $icon = new \action_menu_link_secondary($url, new \pix_icon('i/calc', get_string('evaluate', 'tool_analytics')),
-                    get_string('evaluate', 'tool_analytics'));
+                    get_string('evaluate', 'tool_analytics'), ['data-action-id' => $actionid]);
                 $actionsmenu->add($icon);
             }
 
             // Machine-learning-based models evaluation log.
-            if (!$model->is_static()) {
+            if (!$model->is_static() && $model->get_logs()) {
                 $urlparams['action'] = 'log';
                 $url = new \moodle_url('model.php', $urlparams);
                 $icon = new \action_menu_link_secondary($url, new \pix_icon('i/report', get_string('viewlog', 'tool_analytics')),
index 25d182f..2836ca7 100644 (file)
@@ -35,6 +35,8 @@ Options:
 --non-interactive      Not interactive questions
 --timesplitting        Restrict the evaluation to 1 single time splitting method (Optional)
 --filter               Analyser dependant. e.g. A courseid would evaluate the model using a single course (Optional)
+--mode                 'configuration' or 'trainedmodel'. You can only use mode=trainedmodel when the trained" .
+    " model was imported" . "
 --reuse-prev-analysed  Reuse recently analysed courses instead of analysing the whole site. Set it to false while" .
     " coding indicators. Defaults to true (Optional)" . "
 -h, --help             Print out this help
@@ -50,6 +52,7 @@ list($options, $unrecognized) = cli_get_params(
         'modelid'               => false,
         'list'                  => false,
         'timesplitting'         => false,
+        'mode'                  => 'configuration',
         'reuse-prev-analysed'   => true,
         'non-interactive'       => false,
         'filter'                => false
@@ -64,16 +67,30 @@ if ($options['help']) {
     exit(0);
 }
 
-if ($options['list'] || $options['modelid'] === false) {
+if ($options['list']) {
     \tool_analytics\clihelper::list_models();
     exit(0);
 }
 
+if ($options['modelid'] === false) {
+    // All actions but --list require a modelid.
+    echo $help;
+    exit(0);
+}
+
 // Reformat them as an array.
 if ($options['filter'] !== false) {
     $options['filter'] = explode(',', $options['filter']);
 }
 
+if ($options['mode'] !== 'configuration' && $options['mode'] !== 'trainedmodel') {
+    cli_error('Error: The provided mode is not supported');
+}
+
+if ($options['mode'] == 'trainedmodel' && $options['timesplitting']) {
+    cli_error('Sorry, no time splitting method can be specified when using \'trainedmodel\' mode.');
+}
+
 // We need admin permissions.
 \core\session\manager::set_user(get_admin());
 
@@ -89,6 +106,7 @@ $analyseroptions = array(
     'filter' => $options['filter'],
     'timesplitting' => $options['timesplitting'],
     'reuseprevanalysed' => $options['reuse-prev-analysed'],
+    'mode' => $options['mode'],
 );
 // Evaluate its suitability to predict accurately.
 $results = $model->evaluate($analyseroptions);
index 47b40d0..36eed2f 100644 (file)
@@ -53,6 +53,18 @@ $string['erroronlycli'] = 'Execution only allowed via command line';
 $string['errortrainingdataexport'] = 'The model training data could not be exported';
 $string['evaluate'] = 'Evaluate';
 $string['evaluatemodel'] = 'Evaluate model';
+$string['evaluationmode'] = 'Evaluation mode';
+$string['evaluationmode_help'] = 'There are two evaluation modes:
+
+* Trained model -  Site data is used as testing data to evaluate the accuracy of the trained model.
+* Configuration - Site data is split into training and testing data, to both train and test the accuracy of the model configuration.
+
+Trained model is only available if a trained model has been imported into the site, and has not yet been re-trained using site data.';
+$string['evaluationmodeinfo'] = 'This model has been imported into the site. You can either evaluate the performance of the model, or you can evaluate the performance of the model configuration using site data.';
+$string['evaluationmodetrainedmodel'] = 'Evaluate the trained model';
+$string['evaluationmodecoltrainedmodel'] = 'Trained model';
+$string['evaluationmodecolconfiguration'] = 'Configuration';
+$string['evaluationmodeconfiguration'] = 'Evaluate the model configuration';
 $string['evaluationinbatches'] = 'The site contents are calculated and stored in batches. The evaluation process may be stopped at any time. The next time it is run, it will continue from the point when it was stopped.';
 $string['exportmodel'] = 'Export configuration';
 $string['exporttrainingdata'] = 'Export training data';
@@ -104,7 +116,7 @@ $string['trainingprocessfinished'] = 'Training process finished';
 $string['trainingresults'] = 'Training results';
 $string['trainmodels'] = 'Train models';
 $string['versionnotsame'] = 'Imported file was from a different moodle version ({$a->importedversion}) than the current one ({$a->version})';
-$string['viewlog'] = 'Log';
+$string['viewlog'] = 'Evaluation log';
 $string['weeksenddateautomaticallyset'] = 'End date automatically set based on start date and the number of sections';
 $string['weeksenddatedefault'] = 'End date automatically calculated from the course start date.';
 $string['privacy:metadata'] = 'The Analytic models plugin does not store any personal data.';
index 23d8454..71f303a 100644 (file)
@@ -169,7 +169,13 @@ switch ($action) {
         // Web interface is used by people who can not use CLI nor code stuff, always use
         // cached stuff as they will change the model through the web interface as well
         // which invalidates the previously analysed stuff.
-        $results = $model->evaluate(array('reuseprevanalysed' => true));
+        $options = ['reuseprevanalysed' => true];
+
+        $mode = optional_param('mode', false, PARAM_ALPHANUM);
+        if ($mode == 'trainedmodel') {
+            $options['mode'] = 'trainedmodel';
+        }
+        $results = $model->evaluate($options);
         $renderer = $PAGE->get_renderer('tool_analytics');
         echo $renderer->render_evaluate_results($results, $model->get_analyser()->get_logs());
         break;
diff --git a/admin/tool/analytics/templates/evaluation_mode_selection.mustache b/admin/tool/analytics/templates/evaluation_mode_selection.mustache
new file mode 100644 (file)
index 0000000..e9b32ce
--- /dev/null
@@ -0,0 +1,42 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template tool_analytics/evaluation_mode_selector
+
+    Evaluation mode selector.
+
+    The purpose of this template is to render the evaluation mode radio button.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Example context (json):
+    {
+    }
+}}
+<div class="box mb-4">{{#str}} evaluationmodeinfo, tool_analytics {{/str}}</div>
+<div class="form-check">
+    <input class="form-check-input" type="radio" name="evaluationmode" id="id-mode-trainedmodel" value="trainedmodel" checked>
+    <label class="form-check-label" for="id-mode-trainedmodel">{{#str}} evaluationmodetrainedmodel, tool_analytics {{/str}}</label>
+</div>
+<div class="form-check">
+    <input class="form-check-input" type="radio" name="evaluationmode" id="id-mode-configuration" value="configuration">
+    <label class="form-check-label" for="id-mode-configuration">{{#str}} evaluationmodeconfiguration, tool_analytics {{/str}}</label>
+</div>
\ No newline at end of file
diff --git a/admin/tool/customlang/classes/output/renderer.php b/admin/tool/customlang/classes/output/renderer.php
new file mode 100644 (file)
index 0000000..b183daf
--- /dev/null
@@ -0,0 +1,64 @@
+<?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/>.
+
+/**
+ * Renderer class for tool customlang
+ *
+ * @package     tool_customlang
+ * @category    output
+ * @copyright   2019 Bas Brands <bas@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_customlang\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+
+/**
+ * Renderer for the customlang tool.
+ *
+ * @copyright 2019 Bas Brands <bas@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class renderer extends \plugin_renderer_base {
+
+    /**
+     * Defer to template.
+     *
+     * @param tool_customlang_translator $translator
+     * @return string Html for the translator
+     */
+    protected function render_tool_customlang_translator(\tool_customlang_translator $translator) {
+        $renderabletranslator = new translator($translator);
+        $templatevars = $renderabletranslator->export_for_template($this);
+        return $this->render_from_template('tool_customlang/translator', $templatevars);
+    }
+
+    /**
+     * Defer to template.
+     *
+     * @param tool_customlang_menu $menu
+     * @return string html the customlang menu buttons
+     */
+    protected function render_tool_customlang_menu(\tool_customlang_menu $menu) {
+        $output = '';
+        foreach ($menu->get_items() as $item) {
+            $output .= $this->single_button($item->url, $item->title, $item->method);
+        }
+        return $this->box($output, 'menu');
+    }
+}
diff --git a/admin/tool/customlang/classes/output/translator.php b/admin/tool/customlang/classes/output/translator.php
new file mode 100644 (file)
index 0000000..9b7ac7d
--- /dev/null
@@ -0,0 +1,93 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * customlang specific renderers.
+ *
+ * @package   tool_customlang
+ * @copyright 2019 Moodle
+ * @author    Bas Brands
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_customlang\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+use renderable;
+use templatable;
+use renderer_base;
+use stdClass;
+
+/**
+ * Class containing data for customlang translator page
+ *
+ * @copyright  2019 Bas Brands
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class translator implements renderable, templatable {
+
+    /**
+     * @var tool_customlang_translator $translator object.
+     */
+    private $translator;
+
+    /**
+     * Construct this renderable.
+     *
+     * @param tool_customlang_translator $translator The translator object.
+     */
+    public function __construct(\tool_customlang_translator $translator) {
+        $this->translator = $translator;
+    }
+
+    /**
+     * Export the data.
+     *
+     * @param renderer_base $output
+     * @return stdClass
+     */
+    public function export_for_template(renderer_base $output) {
+        $data = new stdClass();
+
+        $data->nostrings = $output->notification(get_string('nostringsfound', 'tool_customlang'));
+        $data->formurl = $this->translator->handler;
+        $data->currentpage = $this->translator->currentpage;
+        $data->sesskey = sesskey();
+        $data->strings = [];
+
+        if (!empty($this->translator->strings)) {
+            $data->hasstrings = true;
+            foreach ($this->translator->strings as $string) {
+                // Find strings that use placeholders.
+                if (preg_match('/\{\$a(->.+)?\}/', $string->master)) {
+                    $string->placeholderhelp = $output->help_icon('placeholder', 'tool_customlang',
+                            get_string('placeholderwarning', 'tool_customlang'));
+                }
+                if (!is_null($string->local) and $string->outdated) {
+                    $string->outdatedhelp = $output->help_icon('markinguptodate', 'tool_customlang');
+                    $string->checkupdated = true;
+                }
+                if ($string->original !== $string->master) {
+                    $string->showoriginalvsmaster = true;
+                }
+                $string->local = s($string->local);
+                $data->strings[] = $string;
+            }
+        }
+        return $data;
+    }
+}
\ No newline at end of file
diff --git a/admin/tool/customlang/renderer.php b/admin/tool/customlang/renderer.php
deleted file mode 100644 (file)
index aea7fd6..0000000
+++ /dev/null
@@ -1,149 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * Output rendering of Language customization admin tool
- *
- * @package    tool
- * @subpackage customlang
- * @copyright  2010 David Mudrak <david@moodle.com>
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-/**
- * Rendering methods for the tool widgets
- */
-class tool_customlang_renderer extends plugin_renderer_base {
-
-    /**
-     * Renders customlang tool menu
-     *
-     * @return string HTML
-     */
-    protected function render_tool_customlang_menu(tool_customlang_menu $menu) {
-        $output = '';
-        foreach ($menu->get_items() as $item) {
-            $output .= $this->single_button($item->url, $item->title, $item->method);
-        }
-        return $this->box($output, 'menu');
-    }
-
-    /**
-     * Renders customlang translation table
-     *
-     * @param tool_customlang_translator $translator
-     * @return string HTML
-     */
-    protected function render_tool_customlang_translator(tool_customlang_translator $translator) {
-        $output = '';
-
-        if (empty($translator->strings)) {
-            return $this->notification(get_string('nostringsfound', 'tool_customlang'));
-        }
-
-        $table = new html_table();
-        $table->id = 'translator';
-        $table->head = array(
-            get_string('headingcomponent', 'tool_customlang'),
-            get_string('headingstringid', 'tool_customlang'),
-            get_string('headingstandard', 'tool_customlang'),
-            get_string('headinglocal', 'tool_customlang'),
-        );
-
-        foreach ($translator->strings as $string) {
-            $cells = array();
-            // component name
-            $cells[0] = new html_table_cell($string->component);
-            $cells[0]->attributes['class'] = 'component';
-            // string identification code
-            $cells[1] = new html_table_cell(html_writer::tag('div', s($string->stringid), array('class' => 'stringid')));
-            $cells[1]->attributes['class'] = 'stringid';
-            // master translation of the string
-            $master = html_writer::tag('div', s($string->master), array('class' => 'preformatted'));
-            $minheight = strlen($string->master) / 200;
-            if (preg_match('/\{\$a(->.+)?\}/', $string->master)) {
-                $master .= html_writer::tag('div', $this->help_icon('placeholder', 'tool_customlang',
-                        get_string('placeholderwarning', 'tool_customlang')), array('class' => 'placeholderinfo'));
-            }
-            $cells[2] = new html_table_cell($master);
-            $cells[2]->attributes['class'] = 'standard master';
-            // local customization of the string
-            $textareaattributes = array('name'=>'cust['.$string->id.']', 'cols'=>40, 'rows'=>3);
-            if ($minheight>1) {
-               $textareaattributes['style'] = 'min-height:' . (int) 4*$minheight . 'em;';
-            }
-            $textarea = html_writer::tag('textarea', s($string->local), $textareaattributes);
-            $cells[3] = new html_table_cell($textarea);
-            if (!is_null($string->local) and $string->outdated) {
-                $mark  = html_writer::empty_tag('input', array('type' => 'checkbox', 'id' => 'update_' . $string->id,
-                                                               'name' => 'updates[]', 'value' => $string->id));
-                $help  = $this->help_icon('markinguptodate', 'tool_customlang');
-                $mark .= html_writer::tag('label', get_string('markuptodate', 'tool_customlang') . $help,
-                                          array('for' => 'update_' . $string->id));
-                $mark  = html_writer::tag('div', $mark, array('class' => 'uptodatewrapper'));
-            } else {
-                $mark  = '';
-            }
-            $cells[3] = new html_table_cell($textarea."\n".$mark);
-            $cells[3]->attributes['class'] = 'local';
-            $cells[3]->id = 'id_'.$string->id;
-            if (!is_null($string->local)) {
-                $cells[3]->attributes['class'] .= ' customized';
-            }
-            if ($string->outdated) {
-                $cells[3]->attributes['class'] .= ' outdated';
-            }
-            if ($string->modified) {
-                $cells[3]->attributes['class'] .= ' modified';
-            }
-
-            if ($string->original !== $string->master) {
-                $cells[0]->rowspan = $cells[1]->rowspan = $cells[3]->rowspan = 2;
-            }
-
-            $row = new html_table_row($cells);
-            $table->data[] = $row;
-
-            if ($string->original !== $string->master) {
-                $cells = array();
-                // original of the string
-                $cells[2] = new html_table_cell(html_writer::tag('div', s($string->original), array('class' => 'preformatted')));
-                $cells[2]->attributes['class'] = 'standard original';
-                $row = new html_table_row($cells);
-                $table->data[] = $row;
-            }
-        }
-
-        $output .= html_writer::start_tag('form', array('method'=>'post', 'action'=>$translator->handler->out()));
-        $output .= html_writer::start_tag('div');
-        $output .= html_writer::empty_tag('input', array('type'=>'hidden', 'name'=>'translatorsubmitted', 'value'=>1));
-        $output .= html_writer::empty_tag('input', array('type'=>'hidden', 'name'=>'sesskey', 'value'=>sesskey()));
-        $output .= html_writer::empty_tag('input', array('type'=>'hidden', 'name'=>'p', 'value'=>$translator->currentpage));
-        $save1   = html_writer::empty_tag('input', array('type' => 'submit', 'name' => 'savecontinue',
-            'value' => get_string('savecontinue', 'tool_customlang'), 'class' => 'btn btn-secondary'));
-        $save2   = html_writer::empty_tag('input', array('type' => 'submit', 'name' => 'savecheckin',
-            'value' => get_string('savecheckin', 'tool_customlang'), 'class' => 'btn btn-secondary'));
-        $output .= html_writer::tag('fieldset', $save1 . ' ' . $save2, array('class' => 'buttonsbar'));
-        $output .= html_writer::table($table);
-        $output .= html_writer::tag('fieldset', $save1 . ' ' . $save2, array('class' => 'buttonsbar'));
-        $output .= html_writer::end_tag('div');
-        $output .= html_writer::end_tag('form');
-
-        return $output;
-    }
-}
diff --git a/admin/tool/customlang/styles.css b/admin/tool/customlang/styles.css
deleted file mode 100644 (file)
index 9f9fa98..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-.path-admin-tool-customlang .langselectorbox,
-.path-admin-tool-customlang fieldset.buttonsbar,
-.path-admin-tool-customlang .menu {
-    margin: 5px auto;
-    text-align: center;
-}
-
-.path-admin-tool-customlang .menu .singlebutton,
-.path-admin-tool-customlang .menu .singlebutton form,
-.path-admin-tool-customlang .menu .singlebutton form div {
-    display: inline;
-}
-
-.path-admin-tool-customlang .mform.filterform {
-    width: 70%;
-    margin-left: auto;
-    margin-right: auto;
-}
-
-.path-admin-tool-customlang .mform.filterform .fitem .fitemtitle {
-    width: 30%;
-}
-
-.path-admin-tool-customlang .mform.filterform .fitem .felement {
-    width: 60%;
-    margin-left: 31%;
-}
-
-.path-admin-tool-customlang #translator {
-    width: 100%;
-}
-
-.path-admin-tool-customlang #translator .standard,
-.path-admin-tool-customlang #translator .local {
-    min-width: 35%;
-}
-
-.path-admin-tool-customlang #translator .customized {
-    background-color: #e7f1c3;
-}
-
-.path-admin-tool-customlang #translator .customized.outdated {
-    background-color: #f3f2aa;
-}
-
-.path-admin-tool-customlang #translator .modified {
-    background-color: #ffd3d9;
-}
-
-.path-admin-tool-customlang #translator .customized.modified {
-    background-color: #d2ebff;
-}
-
-.path-admin-tool-customlang #translator textarea {
-    width: 100%;
-    min-height: 4em;
-}
-
-.path-admin-tool-customlang #translator .placeholderinfo {
-    text-align: center;
-    border: 1px dotted #ddd;
-    background-color: #f6f6f6;
-    margin-top: 0.5em;
-}
-
-#page-admin-tool-customlang-index .continuebutton {
-    margin-top: 1em;
-}
-
-.path-admin-tool-customlang #translator .standard.master.cell.c2 {
-    word-break: break-all;
-}
diff --git a/admin/tool/customlang/templates/translator.mustache b/admin/tool/customlang/templates/translator.mustache
new file mode 100644 (file)
index 0000000..8864f83
--- /dev/null
@@ -0,0 +1,150 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template tool_customlang/translator
+
+    Template for the custom language translator page.
+
+    Classes required for JS:
+    -
+
+    Data attributes required for JS:
+    -
+
+    Context variables required for this template:
+    * strings
+
+    Example context (json):
+    {
+        "hasstrings": true,
+        "formurl": "admin/tool/customlang/edit.php?lng=en",
+        "currentpage": 0,
+        "sesskey" : "AZyeeQgmcs",
+        "strings": [
+            {
+                "id": 11,
+                "component": "core",
+                "componentid": 1,
+                "stringid": "course",
+                "original": "Course",
+                "master": "Cursus",
+                "local": "Hoofdstuk",
+                "outdated": 0,
+                "modified": 1
+            }
+        ]
+    }
+}}
+
+{{^hasstrings}}
+    {{{ nostrings }}}
+{{/hasstrings}}
+{{#hasstrings}}
+<form method="post" action="{{{formurl}}}">
+    <input type="hidden" name="translatorsubmitted" value="1">
+    <input type="hidden" name="sesskey" value="{{{ sesskey }}}">
+    <input type="hidden" name="p" value="{{ currentpage }}">
+
+    <fieldset class="m-a-1 m-3">
+        <button type="submit" name="savecontinue" class="btn btn-secondary">
+            {{#str}}savecontinue, tool_customlang{{/str}}
+        </button>
+        <button type="submit" name="savecheckin" class="btn btn-secondary">
+            {{#str}}savecheckin, tool_customlang{{/str}}
+        </button>
+    </fieldset>
+
+    <div class="list-group">
+        <div class="container-fluid d-none d-md-block list-group-item border-bottom-0">
+            <div class="row-fluid">
+                <div class="col-sm-4 col-md-2 span2">
+                    <strong>{{#str}}headingcomponent, tool_customlang{{/str}}</strong>
+                </div>
+                <div class="col-sm-4 col-md-2 span2">
+                    <strong>{{#str}}headingstringid, tool_customlang{{/str}}</strong>
+                </div>
+                <div class="col-sm-4 col-md-2 span2">
+                    <strong>{{#str}}headingstandard, tool_customlang{{/str}}</strong>
+                </div>
+                <div class="col-sm-12 col-md-6 span6">
+                    <span class="p-l-1 pl-3">
+                        <strong>{{#str}}headinglocal, tool_customlang{{/str}}</strong>
+                    </span>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div class="list-group">
+    {{#strings}}
+        <div class="container-fluid list-group-item
+                {{#local}}list-group-item-info{{/local}}
+                {{#outdated}}list-group-item-warning{{/outdated}}
+                {{#modified}}list-group-item-info{{/modified}}"
+            >
+            <div class="row-fluid ">
+                <div class="col-sm-4 col-md-2 span2">
+                    <div class="d-md-none">
+                        <strong>{{#str}}headingcomponent, tool_customlang{{/str}}</strong>
+                    </div>
+                    {{{ component }}}
+                </div>
+                <div class="col-sm-4 col-md-2 span2 text-break">
+                    <div class="d-md-none">
+                        <strong>{{#str}}headingstringid, tool_customlang{{/str}}</strong>
+                    </div>
+                    {{{ stringid }}}
+                </div>
+                <div class="col-sm-4 col-md-2 span2">
+                    <div class="d-md-none">
+                        <strong>{{#str}}headingstandard, tool_customlang{{/str}}</strong>
+                    </div>
+                    {{{ master }}}
+                    <div class="info">
+                        {{{ placeholderhelp }}}
+                        {{{ outdatedhelp}}}
+                    </div>
+                    {{#showoriginalvsmaster}}
+                    <div class="alert-secondary mt-3 m-t-1">
+                        {{{ original }}}
+                    </div>
+                    {{/showoriginalvsmaster}}
+                </div>
+                <div class="col-sm-12 col-md-6 mt-sm-3 mt-md-0 span6">
+                    <div class="d-md-none">
+                        <strong>{{#str}}headinglocal, tool_customlang{{/str}}</strong>
+                    </div>
+                    <div class="py-2 py-md-0 px-md-3">
+                        <textarea class="form-control w-100 border-box" name="cust[{{id}}]" cols="40" rows="3">{{{ local }}}</textarea>
+
+                        {{#checkupdated}}
+                        <div class="uptodatewrapper">
+                            <div class="form-check">
+                                <input id="update_{{id}}" class="form-check-input" name="updates[]" type="checkbox" value="{{id}}">
+                                <label for="update_{{id}}" class="form-check-label">{{#str}}markuptodate, tool_customlang{{/str}}</label>
+                                {{{ outdatedhelp }}}
+                            </div>
+                        </div>
+                        {{/checkupdated}}
+                    </div>
+                </div>
+            </div>
+        </div>
+    {{/strings}}
+    </div>
+</form>
+{{/hasstrings}}
diff --git a/admin/tool/dataprivacy/tests/coverage.php b/admin/tool/dataprivacy/tests/coverage.php
new file mode 100644 (file)
index 0000000..9af4b87
--- /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/>.
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Coverage information for the tool_dataprivacy plugin.
+ *
+ * @package    core
+ * @category   phpunit
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Coverage information for the tool_dataprivacy plugin.
+ *
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+return new class extends phpunit_coverage_info {
+    /** @var array The list of folders relative to the plugin root to whitelist in coverage generation. */
+    protected $whitelistfolders = [
+        'classes',
+    ];
+
+    /** @var array The list of files relative to the plugin root to whitelist in coverage generation. */
+    protected $whitelistfiles = [];
+
+    /** @var array The list of folders relative to the plugin root to excludelist in coverage generation. */
+    protected $excludelistfolders = [
+    ];
+
+    /** @var array The list of files relative to the plugin root to excludelist in coverage generation. */
+    protected $excludelistfiles = [];
+};
index 9a62976..8681715 100644 (file)
@@ -301,6 +301,8 @@ class page_agreedocs implements renderable, templatable {
                 redirect(new moodle_url('/admin/tool/policy/view.php', $urlparams));
             }
         } else {
+            // Update the policyagreed for the user to avoid infinite loop because there are no policies to-be-accepted.
+            api::update_policyagreed($userid);
             $this->redirect_to_previous_url();
         }
     }
index b35a20e..c996d8b 100644 (file)
@@ -292,3 +292,23 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
     When I press "Give consent"
     Then "Accepted on user's behalf" "text" should exist in the "User One" "table_row"
     And "Accepted on user's behalf" "text" should exist in the "User Two" "table_row"
+
+  Scenario: View acceptances made by users on their own after inactivating a policy
+    Given I log in as "user1"
+    And I should see "This site policy"
+    And I should not see "Course overview"
+    And I press "Next"
+    And I set the field "I agree to the This site policy" to "1"
+    And I press "Next"
+    And I should see "Course overview"
+    And I log out
+    And I log in as "admin"
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
+    And I click on "Actions" "link_or_button" in the "This privacy policy" "table_row"
+    And I click on "Set status to \"Active\"" "link" in the "This privacy policy" "table_row"
+    And I press "Continue"
+    And I click on "Set status to \"Inactive\"" "link" in the "This privacy policy" "table_row"
+    And I press "Continue"
+    And I log out
+    When I log in as "user1"
+    Then I should see "Course overview"
index be0d3a0..919e9f6 100644 (file)
@@ -63,7 +63,9 @@ interface classifier extends predictor {
      * @param int $niterations
      * @param \stored_file $dataset
      * @param string $outputdir
+     * @param  string $trainedmodeldir
      * @return \stdClass
      */
-    public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir);
+    public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
+            $outputdir, $trainedmodeldir);
 }
index 3b6c6e4..d4a073b 100644 (file)
@@ -537,6 +537,29 @@ class model {
         }
 
         $options['evaluation'] = true;
+
+        if (empty($options['mode'])) {
+            $options['mode'] = 'configuration';
+        }
+
+        switch ($options['mode']) {
+            case 'trainedmodel':
+
+                // We are only interested on the time splitting method used by the trained model.
+                $options['timesplitting'] = $this->model->timesplitting;
+
+                // Provide the trained model directory to the ML backend if that is what we want to evaluate.
+                $trainedmodeldir = $this->get_output_dir(['execution']);
+                break;
+            case 'configuration':
+
+                $trainedmodeldir = false;
+                break;
+
+            default:
+                throw new \moodle_exception('errorunknownaction', 'analytics');
+        }
+
         $this->init_analyser($options);
 
         if (empty($this->get_indicators())) {
@@ -575,10 +598,10 @@ class model {
             // Evaluate the dataset, the deviation we accept in the results depends on the amount of iterations.
             if ($this->get_target()->is_linear()) {
                 $predictorresult = $predictor->evaluate_regression($this->get_unique_id(), self::ACCEPTED_DEVIATION,
-                self::EVALUATION_ITERATIONS, $dataset, $outputdir);
+                    self::EVALUATION_ITERATIONS, $dataset, $outputdir, $trainedmodeldir);
             } else {
                 $predictorresult = $predictor->evaluate_classification($this->get_unique_id(), self::ACCEPTED_DEVIATION,
-                self::EVALUATION_ITERATIONS, $dataset, $outputdir);
+                    self::EVALUATION_ITERATIONS, $dataset, $outputdir, $trainedmodeldir);
             }
 
             $result->status = $predictorresult->status;
@@ -596,7 +619,7 @@ class model {
                 $dir = $predictorresult->dir;
             }
 
-            $result->logid = $this->log_result($timesplitting->get_id(), $result->score, $dir, $result->info);
+            $result->logid = $this->log_result($timesplitting->get_id(), $result->score, $dir, $result->info, $options['mode']);
 
             $results[$timesplitting->get_id()] = $result;
         }
@@ -1462,6 +1485,29 @@ class model {
         return \core_analytics\dataset_manager::export_training_data($this->get_id(), $timesplittingid);
     }
 
+    /**
+     * Has the model been trained using data from this site?
+     *
+     * This method is useful to determine if a trained model can be evaluated as
+     * we can not use the same data for training and for evaluation.
+     *
+     * @return bool
+     */
+    public function trained_locally() : bool {
+        global $DB;
+
+        if (!$this->is_trained() || $this->is_static()) {
+            // Early exit.
+            return false;
+        }
+
+        if ($DB->record_exists('analytics_train_samples', ['modelid' => $this->model->id])) {
+            return true;
+        }
+
+        return false;
+    }
+
     /**
      * Flag the provided file as used for training or prediction.
      *
@@ -1487,14 +1533,16 @@ class model {
      * @param float $score
      * @param string $dir
      * @param array $info
+     * @param string $evaluationmode
      * @return int The inserted log id
      */
-    protected function log_result($timesplittingid, $score, $dir = false, $info = false) {
+    protected function log_result($timesplittingid, $score, $dir = false, $info = false, $evaluationmode = 'configuration') {
         global $DB, $USER;
 
         $log = new \stdClass();
         $log->modelid = $this->get_id();
         $log->version = $this->model->version;
+        $log->evaluationmode = $evaluationmode;
         $log->target = $this->model->target;
         $log->indicators = $this->model->indicators;
         $log->timesplitting = $timesplittingid;
index c2d2a89..c8e0bcf 100644 (file)
@@ -63,7 +63,9 @@ interface regressor extends predictor {
      * @param int $niterations
      * @param \stored_file $dataset
      * @param string $outputdir
+     * @param  string $trainedmodeldir
      * @return \stdClass
      */
-    public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir);
+    public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
+            $outputdir, $trainedmodeldir);
 }
diff --git a/analytics/classes/stats.php b/analytics/classes/stats.php
new file mode 100644 (file)
index 0000000..0caf975
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+// This file is part of Moodle - https://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/>.
+
+/**
+ * Provides the {@link \core_analytics\stats} class.
+ *
+ * @package     core_analytics
+ * @copyright   2019 David Mudrák <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_analytics;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Provides stats and meta information about the analytics usage on this site.
+ *
+ * @copyright 2019 David Mudrák <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class stats {
+
+    /**
+     * Return the number of models enabled on this site.
+     *
+     * @return int
+     */
+    public static function enabled_models() : int {
+        return count(manager::get_all_models(true));
+    }
+
+    /**
+     * Return the number of predictions generated by the system.
+     *
+     * @return int
+     */
+    public static function predictions() : int {
+        global $DB;
+
+        return $DB->count_records('analytics_predictions');
+    }
+
+    /**
+     * Return the number of suggested actions executed by users.
+     *
+     * @return int
+     */
+    public static function actions() : int {
+        global $DB;
+
+        return $DB->count_records('analytics_prediction_actions');
+    }
+
+    /**
+     * Return the number of suggested actions flagged as not useful.
+     *
+     * @return int
+     */
+    public static function actions_not_useful() : int {
+        global $DB;
+
+        return $DB->count_records('analytics_prediction_actions', ['actionname' => prediction::ACTION_NOT_USEFUL]);
+    }
+}
index 18582c3..9b69093 100644 (file)
@@ -274,7 +274,7 @@ class core_analytics_prediction_testcase extends advanced_testcase {
      * test_ml_export_import
      *
      * @param string $predictionsprocessorclass The class name
-     * @dataProvider provider_ml_export_import
+     * @dataProvider provider_ml_processors
      */
     public function test_ml_export_import($predictionsprocessorclass) {
 
@@ -296,6 +296,7 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         $model->update(true, false, '\core\analytics\time_splitting\quarters', get_class($predictionsprocessor));
 
         $model->train();
+        $this->assertTrue($model->trained_locally());
 
         $this->generate_courses(10, ['visible' => 0]);
 
@@ -314,16 +315,18 @@ class core_analytics_prediction_testcase extends advanced_testcase {
             $this->assertEquals($importedmodelresults->predictions[$sampleid]->prediction, $prediction->prediction);
         }
 
+        $this->assertFalse($importmodel->trained_locally());
+
         set_config('enabled_stores', '', 'tool_log');
         get_log_manager(true);
     }
 
     /**
-     * provider_ml_export_import
+     * provider_ml_processors
      *
      * @return array
      */
-    public function provider_ml_export_import() {
+    public function provider_ml_processors() {
         $cases = [
             'case' => [],
         ];
@@ -425,14 +428,14 @@ class core_analytics_prediction_testcase extends advanced_testcase {
     /**
      * Basic test to check that prediction processors work as expected.
      *
-     * @dataProvider provider_ml_test_evaluation
+     * @dataProvider provider_ml_test_evaluation_configuration
      * @param string $modelquality
      * @param int $ncourses
      * @param array $expected
      * @param string $predictionsprocessorclass
      * @return void
      */
-    public function test_ml_evaluation($modelquality, $ncourses, $expected, $predictionsprocessorclass) {
+    public function test_ml_evaluation_configuration($modelquality, $ncourses, $expected, $predictionsprocessorclass) {
         $this->resetAfterTest(true);
         $this->setAdminuser();
         set_config('enabled_stores', 'logstore_standard', 'tool_log');
@@ -473,6 +476,46 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         get_log_manager(true);
     }
 
+    /**
+     * Tests the evaluation of already trained models.
+     *
+     * @dataProvider provider_ml_processors
+     * @param  string $predictionsprocessorclass
+     * @return null
+     */
+    public function test_ml_evaluation_trained_model($predictionsprocessorclass) {
+        $this->resetAfterTest(true);
+        $this->setAdminuser();
+        set_config('enabled_stores', 'logstore_standard', 'tool_log');
+        set_config('timesplittings',
+            '\core\analytics\time_splitting\quarters,\core\analytics\time_splitting\quarters_accum', 'analytics');
+
+        $model = $this->add_perfect_model();
+
+        // Generate training data.
+        $this->generate_courses(50);
+
+        // We repeat the test for all prediction processors.
+        $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
+        if ($predictionsprocessor->is_ready() !== true) {
+            $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
+        }
+
+        $model->update(true, false, '\\core\\analytics\\time_splitting\\quarters', get_class($predictionsprocessor));
+        $model->train();
+
+        $zipfilename = 'model-zip-' . microtime() . '.zip';
+        $zipfilepath = $model->export_model($zipfilename);
+        $importmodel = \core_analytics\model::import_model($zipfilepath);
+
+        $results = $importmodel->evaluate(['mode' => 'trainedmodel']);
+        $this->assertEquals(0, $results['\\core\\analytics\\time_splitting\\quarters']->status);
+        $this->assertEquals(1, $results['\\core\\analytics\\time_splitting\\quarters']->score);
+
+        set_config('enabled_stores', '', 'tool_log');
+        get_log_manager(true);
+    }
+
     /**
      * test_read_indicator_calculations
      *
@@ -547,11 +590,11 @@ class core_analytics_prediction_testcase extends advanced_testcase {
     }
 
     /**
-     * provider_ml_test_evaluation
+     * provider_ml_test_evaluation_configuration
      *
      * @return array
      */
-    public function provider_ml_test_evaluation() {
+    public function provider_ml_test_evaluation_configuration() {
 
         $cases = array(
             'bad' => array(
diff --git a/analytics/tests/stats_test.php b/analytics/tests/stats_test.php
new file mode 100644 (file)
index 0000000..d92f403
--- /dev/null
@@ -0,0 +1,162 @@
+<?php
+// This file is part of Moodle - https://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/>.
+
+/**
+ * Provides the {@link analytics_stats_testcase} class.
+ *
+ * @package     core_analytics
+ * @category    test
+ * @copyright   2019 David Mudrák <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once(__DIR__ . '/fixtures/test_indicator_fullname.php');
+require_once(__DIR__ . '/fixtures/test_target_shortname.php');
+
+/**
+ * Unit tests for the analytics stats.
+ *
+ * @copyright 2019 David Mudrák <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class analytics_stats_testcase extends advanced_testcase {
+
+    /**
+     * Set up the test environment.
+     */
+    public function setUp() {
+
+        $this->setAdminUser();
+    }
+
+    /**
+     * Test the {@link \core_analytics\stats::enabled_models()} implementation.
+     */
+    public function test_enabled_models() {
+
+        $this->resetAfterTest(true);
+
+        // By default, sites have {@link \core\analytics\target\no_teaching} enabled.
+        $this->assertEquals(1, \core_analytics\stats::enabled_models());
+
+        $model = \core_analytics\model::create(
+            \core_analytics\manager::get_target('\core\analytics\target\course_dropout'),
+            [
+                \core_analytics\manager::get_indicator('\core\analytics\indicator\any_write_action'),
+            ]
+        );
+
+        // Purely adding a new model does not make it included in the stats.
+        $this->assertEquals(1, \core_analytics\stats::enabled_models());
+
+        // New models must be enabled to have them counted.
+        $model->enable('\core\analytics\time_splitting\quarters');
+        $this->assertEquals(2, \core_analytics\stats::enabled_models());
+    }
+
+    /**
+     * Test the {@link \core_analytics\stats::predictions()} implementation.
+     */
+    public function test_predictions() {
+
+        $this->resetAfterTest(true);
+
+        $model = \core_analytics\model::create(
+            \core_analytics\manager::get_target('test_target_shortname'),
+            [
+                \core_analytics\manager::get_indicator('test_indicator_fullname'),
+            ]
+        );
+
+        $model->enable('\core\analytics\time_splitting\no_splitting');
+
+        // Train the model.
+        $this->getDataGenerator()->create_course(['shortname' => 'a', 'fullname' => 'a', 'visible' => 1]);
+        $this->getDataGenerator()->create_course(['shortname' => 'b', 'fullname' => 'b', 'visible' => 1]);
+        $model->train();
+
+        // No predictions yet.
+        $this->assertEquals(0, \core_analytics\stats::predictions());
+
+        // Get one new prediction.
+        $this->getDataGenerator()->create_course(['shortname' => 'aa', 'fullname' => 'aa', 'visible' => 0]);
+        $result = $model->predict();
+
+        $this->assertEquals(1, count($result->predictions));
+        $this->assertEquals(1, \core_analytics\stats::predictions());
+
+        // Nothing changes if there is no new prediction.
+        $result = $model->predict();
+        $this->assertFalse(isset($result->predictions));
+        $this->assertEquals(1, \core_analytics\stats::predictions());
+
+        // Get two more predictions, we have three in total now.
+        $this->getDataGenerator()->create_course(['shortname' => 'bb', 'fullname' => 'bb', 'visible' => 0]);
+        $this->getDataGenerator()->create_course(['shortname' => 'cc', 'fullname' => 'cc', 'visible' => 0]);
+
+        $result = $model->predict();
+        $this->assertEquals(2, count($result->predictions));
+        $this->assertEquals(3, \core_analytics\stats::predictions());
+    }
+
+    /**
+     * Test the {@link \core_analytics\stats::actions()} and {@link \core_analytics\stats::actions_not_useful()} implementation.
+     */
+    public function test_actions() {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        $model = \core_analytics\model::create(
+            \core_analytics\manager::get_target('test_target_shortname'),
+            [
+                \core_analytics\manager::get_indicator('test_indicator_fullname'),
+            ]
+        );
+
+        $model->enable('\core\analytics\time_splitting\no_splitting');
+
+        // Train the model.
+        $this->getDataGenerator()->create_course(['shortname' => 'a', 'fullname' => 'a', 'visible' => 1]);
+        $this->getDataGenerator()->create_course(['shortname' => 'b', 'fullname' => 'b', 'visible' => 1]);
+        $model->train();
+
+        // Generate two predictions.
+        $this->getDataGenerator()->create_course(['shortname' => 'aa', 'fullname' => 'aa', 'visible' => 0]);
+        $this->getDataGenerator()->create_course(['shortname' => 'bb', 'fullname' => 'bb', 'visible' => 0]);
+        $model->predict();
+
+        list($p1, $p2) = array_values($DB->get_records('analytics_predictions'));
+
+        $p1 = new \core_analytics\prediction($p1, []);
+        $p2 = new \core_analytics\prediction($p2, []);
+
+        // No actions executed at the start.
+        $this->assertEquals(0, \core_analytics\stats::actions());
+        $this->assertEquals(0, \core_analytics\stats::actions_not_useful());
+
+        // The user has acknowledged the first prediction.
+        $p1->action_executed(\core_analytics\prediction::ACTION_FIXED, $model->get_target());
+        $this->assertEquals(1, \core_analytics\stats::actions());
+        $this->assertEquals(0, \core_analytics\stats::actions_not_useful());
+
+        // The user has marked the other prediction as not useful.
+        $p2->action_executed(\core_analytics\prediction::ACTION_NOT_USEFUL, $model->get_target());
+        $this->assertEquals(2, \core_analytics\stats::actions());
+        $this->assertEquals(1, \core_analytics\stats::actions_not_useful());
+    }
+}
index 550a3da..3187365 100644 (file)
@@ -1,6 +1,12 @@
 This files describes API changes in analytics sub system,
 information provided here is intended especially for developers.
 
+=== 3.7 ===
+
+* \core_analytics\regressor::evaluate_regression and \core_analytics\classifier::evaluate_classification
+  have been updated to include a new $trainedmodeldir param. This new param will be used to evaluate the
+  existing trained model.
+
 === 3.5 ===
 
 * There are two new methods for analysers, processes_user_data() and join_sample_user(). You
index 910f9fb..6a79717 100644 (file)
@@ -48,7 +48,7 @@ $string['aria:sortingdropdown'] = 'Sorting drop-down menu';
 $string['card'] = 'Card';
 $string['cards'] = 'Cards';
 $string['courseprogress'] = 'Course progress:';
-$string['complete'] = 'complete';
+$string['completepercent'] = '{$a}% complete';
 $string['favourites'] = 'Starred';
 $string['future'] = 'Future';
 $string['inprogress'] = 'In progress';
@@ -93,4 +93,5 @@ $string['privacy:metadata:overviewlasttab'] = 'This stores the last tab selected
 $string['viewcourse'] = 'View course';
 
 // Deprecated since Moodle 3.7.
-$string['nocourses'] = 'No courses';
\ No newline at end of file
+$string['complete'] = 'complete';
+$string['nocourses'] = 'No courses';
index 4e22e76..fb6f86c 100644 (file)
@@ -13,4 +13,5 @@ sortbydates,block_myoverview
 timeline,block_myoverview
 viewcoursename,block_myoverview
 privacy:metadata:overviewlasttab,block_myoverview
-nocourses,block_myoverview
\ No newline at end of file
+nocourses,block_myoverview
+complete,block_myoverview
\ No newline at end of file
index 2567555..574b5e5 100644 (file)
 }}
 
 <div class="m-b-1 mr-1 d-flex align-items-center">
-    <div class="d-none d-md-inline-block mr-1">{{#str}} sortby, core {{/str}}</div>
     <div class="dropdown">
         <button id="sortingdropdown" type="button" class="btn btn-outline-secondary dropdown-toggle" data-toggle="dropdown"  aria-haspopup="true" aria-expanded="false" aria-label="{{#str}} aria:sortingdropdown, block_myoverview {{/str}}">
-            <span data-active-item-text>
+            {{#pix}} t/sort_by {{/pix}}
+            <span class="d-sm-inline-block" data-active-item-text>
                 {{#title}}{{#str}} title, block_myoverview {{/str}}{{/title}}
                 {{#lastaccessed}}{{#str}} lastaccessed, block_myoverview {{/str}}{{/lastaccessed}}
             </span>
index 9bb0a57..02fa1f0 100644 (file)
@@ -30,5 +30,5 @@
 </div>
 <div class="small">
     <span class="sr-only">{{#str}}aria:courseprogress, block_myoverview{{/str}}</span>
-    <strong>{{progress}}%</strong> {{#str}}complete, block_myoverview{{/str}}
+    {{#str}}completepercent, block_myoverview, <strong>{{progress}}</strong>{{/str}}
 </div>
index d76f06e..9ba48d2 100644 (file)
@@ -165,8 +165,13 @@ class core_enrol_external extends external_api {
                 $courseusers['capability'] = $capability;
 
                 list($enrolledsql, $enrolledparams) = get_enrolled_sql($coursecontext, $capability, $groupid, $onlyactive);
+                $enrolledparams['courseid'] = $courseid;
 
-                $sql = "SELECT u.* FROM {user} u WHERE u.id IN ($enrolledsql) ORDER BY u.id ASC";
+                $sql = "SELECT u.*, COALESCE(ul.timeaccess, 0) AS lastcourseaccess
+                          FROM {user} u
+                     LEFT JOIN {user_lastaccess} ul ON (ul.userid = u.id AND ul.courseid = :courseid)
+                         WHERE u.id IN ($enrolledsql)
+                      ORDER BY u.id ASC";
 
                 $enrolledusers = $DB->get_recordset_sql($sql, $enrolledparams, $limitfrom, $limitnumber);
                 $users = array();
@@ -216,6 +221,7 @@ class core_enrol_external extends external_api {
                     'interests'   => new external_value(PARAM_TEXT, 'user interests (separated by commas)', VALUE_OPTIONAL),
                     'firstaccess' => new external_value(PARAM_INT, 'first access to the site (0 if never)', VALUE_OPTIONAL),
                     'lastaccess'  => new external_value(PARAM_INT, 'last access to the site (0 if never)', VALUE_OPTIONAL),
+                    'lastcourseaccess'  => new external_value(PARAM_INT, 'last access to the course (0 if never)', VALUE_OPTIONAL),
                     'description' => new external_value(PARAM_RAW, 'User profile description', VALUE_OPTIONAL),
                     'descriptionformat' => new external_value(PARAM_INT, 'User profile description format', VALUE_OPTIONAL),
                     'city'        => new external_value(PARAM_NOTAGS, 'Home city of the user', VALUE_OPTIONAL),
@@ -734,15 +740,18 @@ class core_enrol_external extends external_api {
                 return array();
             }
         }
-        $sql = "SELECT us.*
+        $sql = "SELECT us.*, COALESCE(ul.timeaccess, 0) AS lastcourseaccess
                   FROM {user} us
                   JOIN (
                       SELECT DISTINCT u.id $ctxselect
                         FROM {user} u $ctxjoin $groupjoin
                        WHERE u.id IN ($enrolledsql)
                   ) q ON q.id = us.id
+             LEFT JOIN {user_lastaccess} ul ON (ul.userid = us.id AND ul.courseid = :courseid)
                 ORDER BY $sortby $sortdirection";
         $enrolledparams = array_merge($enrolledparams, $sortparams);
+        $enrolledparams['courseid'] = $courseid;
+
         $enrolledusers = $DB->get_recordset_sql($sql, $enrolledparams, $limitfrom, $limitnumber);
         $users = array();
         foreach ($enrolledusers as $user) {
@@ -785,6 +794,7 @@ class core_enrol_external extends external_api {
                     'interests'   => new external_value(PARAM_TEXT, 'user interests (separated by commas)', VALUE_OPTIONAL),
                     'firstaccess' => new external_value(PARAM_INT, 'first access to the site (0 if never)', VALUE_OPTIONAL),
                     'lastaccess'  => new external_value(PARAM_INT, 'last access to the site (0 if never)', VALUE_OPTIONAL),
+                    'lastcourseaccess'  => new external_value(PARAM_INT, 'last access to the course (0 if never)', VALUE_OPTIONAL),
                     'description' => new external_value(PARAM_RAW, 'User profile description', VALUE_OPTIONAL),
                     'descriptionformat' => new external_format_value('description', VALUE_OPTIONAL),
                     'city'        => new external_value(PARAM_NOTAGS, 'Home city of the user', VALUE_OPTIONAL),
index 526169f..0fa3155 100644 (file)
@@ -665,6 +665,46 @@ class core_enrol_externallib_testcase extends externallib_advanced_testcase {
         $this->assertArrayNotHasKey('email', $enrolledusers[0]);
     }
 
+
+    /**
+     * Test get_enrolled_users last course access.
+     */
+    public function test_get_enrolled_users_including_lastcourseaccess() {
+        global $DB;
+        $capability = 'moodle/course:viewparticipants';
+        $data = $this->get_enrolled_users_setup($capability);
+
+        // Call the external function.
+        $enrolledusers = core_enrol_external::get_enrolled_users($data->course->id);
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $enrolledusers = external_api::clean_returnvalue(core_enrol_external::get_enrolled_users_returns(), $enrolledusers);
+
+        // Check the result set.
+        $this->assertEquals(3, count($enrolledusers));
+        $this->assertArrayHasKey('email', $enrolledusers[0]);
+        $this->assertEquals(0, $enrolledusers[0]['lastcourseaccess']);
+        $this->assertEquals(0, $enrolledusers[1]['lastcourseaccess']);
+        $this->assertNotEquals(0, $enrolledusers[2]['lastcourseaccess']);   // We forced an access to the course via setUser.
+
+        // Force last access.
+        $timenow = time();
+        $lastaccess = array(
+            'userid' => $enrolledusers[0]['id'],
+            'courseid' => $data->course->id,
+            'timeaccess' => $timenow
+        );
+        $DB->insert_record('user_lastaccess', $lastaccess);
+
+        $enrolledusers = core_enrol_external::get_enrolled_users($data->course->id);
+        $enrolledusers = external_api::clean_returnvalue(core_enrol_external::get_enrolled_users_returns(), $enrolledusers);
+
+        // Check the result set.
+        $this->assertEquals(3, count($enrolledusers));
+        $this->assertEquals($timenow, $enrolledusers[0]['lastcourseaccess']);
+        $this->assertEquals(0, $enrolledusers[1]['lastcourseaccess']);
+        $this->assertNotEquals(0, $enrolledusers[2]['lastcourseaccess']);
+    }
+
     /**
      * Test get_enrolled_users from core_enrol_external with capability to
      * viewparticipants removed.
@@ -781,6 +821,56 @@ class core_enrol_externallib_testcase extends externallib_advanced_testcase {
         $this->assertEquals($data->student1->id, $expecteduser['id']);
     }
 
+    /**
+     * Test get_enrolled_users last course access.
+     */
+    public function test_get_enrolled_users_with_capability_including_lastcourseaccess() {
+        global $DB;
+        $capability = 'moodle/course:viewparticipants';
+        $data = $this->get_enrolled_users_with_capability_setup($capability);
+
+        $parameters = array(
+            'coursecapabilities' => array(
+                'courseid' => $data->course->id,
+                'capabilities' => array(
+                    $capability,
+                ),
+            ),
+        );
+
+        $result = core_enrol_external::get_enrolled_users_with_capability($parameters, array());
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $result = external_api::clean_returnvalue(core_enrol_external::get_enrolled_users_with_capability_returns(), $result);
+
+        // Check an array containing the expected user for the course capability is returned.
+        $expecteduserlist = $result[0];
+        $this->assertEquals($data->course->id, $expecteduserlist['courseid']);
+        $this->assertEquals($capability, $expecteduserlist['capability']);
+        $this->assertEquals(2, count($expecteduserlist['users']));
+        // We forced an access to the course via setUser.
+        $this->assertNotEquals(0, $expecteduserlist['users'][0]['lastcourseaccess']);
+        $this->assertEquals(0, $expecteduserlist['users'][1]['lastcourseaccess']);
+
+        // Force last access.
+        $timenow = time();
+        $lastaccess = array(
+            'userid' => $expecteduserlist['users'][1]['id'],
+            'courseid' => $data->course->id,
+            'timeaccess' => $timenow
+        );
+        $DB->insert_record('user_lastaccess', $lastaccess);
+
+        $result = core_enrol_external::get_enrolled_users_with_capability($parameters, array());
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $result = external_api::clean_returnvalue(core_enrol_external::get_enrolled_users_with_capability_returns(), $result);
+
+        // Check the result set.
+        $expecteduserlist = $result[0];
+        $this->assertEquals(2, count($expecteduserlist['users']));
+        $this->assertNotEquals(0, $expecteduserlist['users'][0]['lastcourseaccess']);
+        $this->assertEquals($timenow, $expecteduserlist['users'][1]['lastcourseaccess']);
+    }
+
     /**
      * Test for core_enrol_external::edit_user_enrolment().
      */
index 5443467..34c840d 100644 (file)
@@ -22,6 +22,8 @@ information provided here is intended especially for developers.
   - completionhascriteria: Whether completion criteria is set for the course.
   - isfavourite: Whether the user marked the course as favourite.
   - hidden: Whether the user hide the course from the dashboard.
+* External functions core_enrol_external::get_enrolled_users and core_enrol_external::get_enrolled_users_with_capability now return
+  the last access time for the users in the given course.
 
 === 3.5 ===
 
index 5484ff5..f48feb7 100644 (file)
@@ -168,7 +168,7 @@ class core_files_renderer extends plugin_renderer_base {
         <div class="fp-reficons2"></div>
     </div>
     <div class="fp-filename-field">
-        <div class="fp-filename"></div>
+        <div class="fp-filename text-truncate"></div>
     </div>
     </a>
     <a class="fp-contextmenu" href="#">'.$this->pix_icon('i/menu', '▶').'</a>
@@ -335,7 +335,7 @@ class core_files_renderer extends plugin_renderer_base {
         <div class="fp-reficons2"></div>
     </div>
     <div class="fp-filename-field">
-        <p class="fp-filename"></p>
+        <p class="fp-filename text-truncate"></p>
     </div>
 </a>';
         return $rv;
index c2edce9..42e7fbe 100644 (file)
@@ -548,12 +548,20 @@ function groups_delete_group($grouporid) {
         }
     }
 
+    $context = context_course::instance($group->courseid);
+
     // delete group calendar events
     $DB->delete_records('event', array('groupid'=>$groupid));
     //first delete usage in groupings_groups
     $DB->delete_records('groupings_groups', array('groupid'=>$groupid));
     //delete members
     $DB->delete_records('groups_members', array('groupid'=>$groupid));
+
+    // Delete any members in a conversation related to this group.
+    if ($conversation = \core_message\api::get_conversation_by_area('core_group', 'groups', $groupid, $context->id)) {
+        \core_message\api::delete_all_conversation_data($conversation->id);
+    }
+
     //group itself last
     $DB->delete_records('groups', array('id'=>$groupid));
 
index 3aa5287..38af08e 100644 (file)
@@ -32,3 +32,4 @@ defined('MOODLE_INTERNAL') || die();
 
 $string['language'] = 'भाषा';
 $string['next'] = 'अगला';
+$string['reload'] = 'सीमा से अधिक लादना';
index 36c0c7e..779a889 100644 (file)
@@ -773,9 +773,11 @@ $string['mediapluginyoutube'] = 'Enable YouTube links filter';
 $string['messaging'] = 'Enable messaging system';
 $string['messagingallowemailoverride'] = 'Notification email override';
 $string['messagingallusers'] = 'Allow site-wide messaging';
+$string['messagingcategory'] = 'Messaging';
 $string['messagingdefaultpressenter'] = 'Use enter to send enabled by default';
 $string['messagingdeletereadnotificationsdelay'] = 'Delete read notifications';
 $string['messagingdeleteallnotificationsdelay'] = 'Delete all notifications';
+$string['messagingssettings'] = 'Messaging settings';
 $string['minpassworddigits'] = 'Digits';
 $string['minpasswordlength'] = 'Password length';
 $string['minpasswordlower'] = 'Lowercase letters';
@@ -1250,6 +1252,14 @@ $string['taskstatscron'] = 'Background processing for statistics';
 $string['tasktagcron'] = 'Background processing for tags';
 $string['tasktempfilecleanup'] = 'Delete stale temp files';
 $string['tempdatafoldercleanup'] = 'Clean up temporary data files older than';
+$string['testoutgoingmailconf'] = 'Test outgoing mail configuration';
+$string['testoutgoingmaildetail'] = 'Before testing you have to save the configuration.<br />{$a}';
+$string['testoutgoingmailconf_message'] = 'This is a test message. Please disregard. If you received this email, it means that you have successfully configured your Moodle site\'s email settings.';
+$string['testoutgoingmailconf_errorcommunications'] = 'Moodle could not communicate with your mail server. Start by checking your Moodle Outgoing mail configuration.';
+$string['testoutgoingmailconf_sendtest'] = 'Send a test message';
+$string['testoutgoingmailconf_sentmail'] = 'Moodle successfully delivered the test message to the mail server.<br />From: {$a->fromemail}<br />To: {$a->toemail}';
+$string['testoutgoingmailconf_subject'] = '{$a}: test message';
+$string['testoutgoingmailconf_toemail'] = 'To email address';
 $string['themedesignermode'] = 'Theme designer mode';
 $string['themedesignermodewarning'] = 'Theme designer mode is enabled. This should not be enabled on production sites as it can significantly reduce performance.';
 $string['themelist'] = 'Theme list';
index 0d2e502..951f257 100644 (file)
@@ -142,4 +142,8 @@ userisblockingyounoncontact,core_message
 error:invalidbadgeurl,core_badges
 nomessages,core_message
 searchallavailablecourses_desc,core_admin
-search:mycourse,core_search
\ No newline at end of file
+search:mycourse,core_search
+outputdisabled,core_message
+outputdoesnotexist,core_message
+outputenabled,core_message
+outputnotconfigured,core_message
index 2e2bcf7..92af304 100644 (file)
@@ -29,6 +29,10 @@ $string['advertised'] = 'For people to join';
 $string['advertiseon'] = 'Share this course on {$a}';
 $string['readvertiseon'] = 'Update advertising information on {$a}';
 $string['advertisepublication_help'] = 'This course will be listed on Moodle.net as a course that people can enrol in and participate. Email-based self-registration should be enabled on the site and you need to enable self enrolment in this course.';
+$string['analyticsactions'] = 'Number of actions taken on generated predictions ({$a})';
+$string['analyticsactionsnotuseful'] = 'Number of actions marking a prediction as not useful ({$a})';
+$string['analyticsenabledmodels'] = 'Number of enabled prediction models ({$a})';
+$string['analyticspredictions'] = 'Number of generated predictions ({$a})';
 $string['audience'] = 'Audience';
 $string['audience_help'] = 'Select the intended audience for the course.';
 $string['audienceeducators'] = 'Educators';
index f9a045a..b00647f 100644 (file)
@@ -47,7 +47,7 @@ $string['contactrequests'] = 'Contact requests';
 $string['contactrequestsent'] = 'Contact request sent';
 $string['contacts'] = 'Contacts';
 $string['decline'] = 'Decline';
-$string['defaultmessageoutputs'] = 'Default message outputs';
+$string['defaultmessageoutputs'] = 'Notification settings';
 $string['defaults'] = 'Defaults';
 $string['deleteallconfirm'] = "Are you sure you would like to delete this entire conversation? This will not delete it for other conversation participants.";
 $string['deleteallmessages'] = "Delete all messages";
@@ -90,8 +90,8 @@ $string['loggedindescription'] = 'When you are logged into Moodle';
 $string['loggedoff'] = 'Offline';
 $string['loggedoff_help'] = 'Configure how you would like to receive notifications when you are not logged into Moodle';
 $string['loggedoffdescription'] = 'When you are not logged into Moodle';
-$string['managemessageoutputs'] = 'Manage message outputs';
-$string['messageoutputs'] = 'Message outputs';
+$string['managemessageoutputs'] = 'Default notification preferences';
+$string['messageoutputs'] = 'Notification plugins';
 $string['messagepreferences'] = 'Message preferences';
 $string['message'] = 'Message';
 $string['messagecontactrequestsnotification'] = '{$a} wants to be added as a contact';
@@ -107,6 +107,7 @@ $string['messagepreferences'] = 'Message preferences';
 $string['messages'] = 'Messages';
 $string['messagesselected:'] = 'Messages selected:';
 $string['messagingdatahasnotbeenmigrated'] = 'Your messages are temporarily unavailable due to upgrades in the messaging infrastructure. Please wait for them to be migrated.';
+$string['muteconversation'] = 'Mute';
 $string['newonlymsg'] = 'Show only new';
 $string['newmessage'] = 'New message';
 $string['newmessagesearch'] = 'Select or search for a contact to send a new message.';
@@ -135,11 +136,7 @@ $string['offline'] = 'Offline';
 $string['on'] = 'On';
 $string['online'] = 'Online';
 $string['otherparticipants'] = 'Other participants';
-$string['outputdisabled'] = 'Output disabled';
-$string['outputdoesnotexist'] = 'Message output does not exist';
-$string['outputenabled'] = 'Output enabled';
 $string['outputnotavailable'] = 'Not available';
-$string['outputnotconfigured'] = 'Not configured';
 $string['participants'] = 'Participants';
 $string['permitted'] = 'Permitted';
 $string['privacy'] = 'Privacy';
@@ -162,6 +159,11 @@ $string['privacy:metadata:message_contact_requests'] = 'The list of contact requ
 $string['privacy:metadata:message_contact_requests:requesteduserid'] = 'The ID of the user who received the contact request';
 $string['privacy:metadata:message_contact_requests:timecreated'] = 'The time when the contact request was created';
 $string['privacy:metadata:message_contact_requests:userid'] = 'The ID of the user who sent the contact request';
+$string['privacy:metadata:message_conversation_actions'] = 'The list of conversation user actions';
+$string['privacy:metadata:message_conversation_actions:action'] = 'The action that was performed';
+$string['privacy:metadata:message_conversation_actions:conversationid'] = 'The ID of the conversation this action belongs to';
+$string['privacy:metadata:message_conversation_actions:timecreated'] = 'The time when the action was created';
+$string['privacy:metadata:message_conversation_actions:userid'] = 'The ID of the user who performed this action';
 $string['privacy:metadata:message_conversation_members'] = 'The list of users in a conversation';
 $string['privacy:metadata:message_conversation_members:conversationid'] = 'The ID of the conversation';
 $string['privacy:metadata:message_conversation_members:timecreated'] = 'The time when the member was created';
@@ -233,6 +235,7 @@ $string['unblockcontact'] = 'Unblock contact';
 $string['unblockuser'] = 'Unblock user';
 $string['unblockuserconfirm'] = 'Are you sure you want to unblock {$a}?';
 $string['unknownuser'] = 'Unknown user';
+$string['unmuteconversation'] = 'Unmute';
 $string['unreadnotification'] = 'Unread notification: {$a}';
 $string['unreadnewgroupconversationmessage'] = 'New message from {$a->name} in {$a->conversationname}';
 $string['unreadnewmessage'] = 'New message from {$a}';
@@ -260,3 +263,7 @@ $string['userisblockingyounoncontact'] = '{$a} only accepts messages from their
 
 // Deprecated since Moodle 3.7.
 $string['nomessages'] = 'No messages';
+$string['outputdisabled'] = 'Output disabled';
+$string['outputdoesnotexist'] = 'Message output does not exist';
+$string['outputenabled'] = 'Output enabled';
+$string['outputnotconfigured'] = 'Not configured';
index 2f3322b..c0d4da1 100644 (file)
@@ -152,8 +152,8 @@ $string['type_local'] = 'Local plugin';
 $string['type_local_plural'] = 'Local plugins';
 $string['type_media'] = 'Media player';
 $string['type_media_plural'] = 'Media players';
-$string['type_message'] = 'Messaging output';
-$string['type_message_plural'] = 'Messaging outputs';
+$string['type_message'] = 'Notification plugin';
+$string['type_message_plural'] = 'Notification plugins';
 $string['type_mnetservice'] = 'MNet service';
 $string['type_mnetservice_plural'] = 'MNet services';
 $string['type_mod'] = 'Activity module';
index edf5998..07f66e4 100644 (file)
@@ -6339,7 +6339,10 @@ class admin_page_managemessageoutputs extends admin_externalpage {
      */
     public function __construct() {
         global $CFG;
-        parent::__construct('managemessageoutputs', get_string('managemessageoutputs', 'message'), new moodle_url('/admin/message.php'));
+        parent::__construct('managemessageoutputs',
+            get_string('defaultmessageoutputs', 'message'),
+            new moodle_url('/admin/message.php')
+        );
     }
 
     /**
@@ -6385,14 +6388,24 @@ class admin_page_managemessageoutputs extends admin_externalpage {
 /**
  * Default message outputs configuration
  *
+ * @deprecated since Moodle 3.7 MDL-64495. Please use admin_page_managemessageoutputs instead.
+ * @todo       MDL-64866 This will be deleted in Moodle 4.1.
+ *
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class admin_page_defaultmessageoutputs extends admin_page_managemessageoutputs {
     /**
      * Calls parent::__construct with specific arguments
+     *
+     * @deprecated since Moodle 3.7 MDL-64495. Please use admin_page_managemessageoutputs instead.
+     * @todo       MDL-64866 This will be deleted in Moodle 4.1.
      */
     public function __construct() {
         global $CFG;
+
+        debugging('admin_page_defaultmessageoutputs class is deprecated. Please use admin_page_managemessageoutputs instead.',
+            DEBUG_DEVELOPER);
+
         admin_externalpage::__construct('defaultmessageoutputs', get_string('defaultmessageoutputs', 'message'), new moodle_url('/message/defaultoutputs.php'));
     }
 }
index def514b..9175784 100644 (file)
@@ -65,8 +65,6 @@ class behat_partial_named_selector extends \Behat\Mink\Selector\PartialNamedSele
         'fieldset' => 'fieldset',
         'icon' => 'icon',
         'list_item' => 'list_item',
-        'message_area_region' => 'message_area_region',
-        'message_area_region_content' => 'message_area_region_content',
         'question' => 'question',
         'region' => 'region',
         'section' => 'section',
@@ -90,13 +88,14 @@ class behat_partial_named_selector extends \Behat\Mink\Selector\PartialNamedSele
         'fieldset' => 'fieldset',
         'file' => 'file',
         'filemanager' => 'filemanager',
+        'group_message' => 'group_message',
+        'group_message_header' => 'group_message_header',
+        'group_message_member' => 'group_message_member',
+        'group_message_tab' => 'group_message_tab',
         'icon' => 'icon',
         'link' => 'link',
         'link_or_button' => 'link_or_button',
         'list_item' => 'list_item',
-        'message_area_action' => 'message_area_action',
-        'message_area_region' => 'message_area_region',
-        'message_area_region_content' => 'message_area_region_content',
         'optgroup' => 'optgroup',
         'option' => 'option',
         'question' => 'question',
@@ -151,6 +150,21 @@ XPATH
             and
         normalize-space(descendant::*[contains(concat(' ', normalize-space(@class), ' '), ' modal-header ')] = %locator%)
     ]
+XPATH
+        , 'group_message' => <<<XPATH
+        .//*[@data-conversation-id]//img[contains(@alt, %locator%)]/..
+XPATH
+        , 'group_message_header' => <<<XPATH
+        .//*[@data-region='message-drawer']//div[@data-region='header-container']//*[text()[contains(., %locator%)]]
+XPATH
+    , 'group_message_member' => <<<XPATH
+        .//*[@data-region='message-drawer']//div[@data-region='group-info-content-container']
+        //div[@class='list-group' and not(contains(@class, 'hidden'))]//*[text()[contains(., %locator%)]] |
+        .//*[@data-region='message-drawer']//div[@data-region='group-info-content-container']
+        //div[@data-region='empty-message-container' and not(contains(@class, 'hidden')) and contains(., %locator%)]
+XPATH
+    , 'group_message_tab' => <<<XPATH
+        .//*[@data-region='message-drawer']//button[@data-toggle='collapse']//*[text()[contains(., %locator%)]]/..
 XPATH
         , 'icon' => <<<XPATH
 .//*[contains(concat(' ', normalize-space(@class), ' '), ' icon ') and ( contains(normalize-space(@title), %locator%))]
@@ -183,15 +197,6 @@ XPATH
 XPATH
         , 'form_row' => <<<XPATH
 .//*[self::label or self::div[contains(concat(' ', @class, ' '), ' fstaticlabel ')]][contains(., %locator%)]/ancestor::*[contains(concat(' ', @class, ' '), ' fitem ')]
-XPATH
-        , 'message_area_region' => <<<XPATH
-.//div[@data-region='message-drawer']/descendant::*[@data-region = %locator%]
-XPATH
-        , 'message_area_region_content' => <<<XPATH
-.//div[@data-region='message-drawer']/descendant::*[@data-region-content = %locator%]
-XPATH
-        , 'message_area_action' => <<<XPATH
-.//div[@data-region='message-drawer']/descendant::*[@data-action = %locator%]
 XPATH
         , 'autocomplete_selection' => <<<XPATH
 .//div[contains(concat(' ', normalize-space(@class), ' '), concat(' ', 'form-autocomplete-selection', ' '))]/span[@role='listitem'][contains(normalize-space(.), %locator%)]
index 823e47f..805434f 100644 (file)
@@ -56,6 +56,8 @@ class registration {
             'commnews', // Receive communication news. This was added in 3.4 and is "On" by default. Admin must confirm or opt-out.
             'mobileservicesenabled', 'mobilenotificationsenabled', 'registereduserdevices', 'registeredactiveuserdevices' // Mobile stats added in 3.4.
         ],
+        // Analytics stats added in Moodle 3.7.
+        2019022200 => ['analyticsenabledmodels', 'analyticspredictions', 'analyticsactions', 'analyticsactionsnotuseful'],
     ];
 
     /** @var Site privacy: not displayed */
@@ -196,6 +198,12 @@ class registration {
             }
         }
 
+        // Analytics related data follow.
+        $siteinfo['analyticsenabledmodels'] = \core_analytics\stats::enabled_models();
+        $siteinfo['analyticspredictions'] = \core_analytics\stats::predictions();
+        $siteinfo['analyticsactions'] = \core_analytics\stats::actions();
+        $siteinfo['analyticsactionsnotuseful'] = \core_analytics\stats::actions_not_useful();
+
         // IMPORTANT: any new fields in siteinfo have to be added to the constant CONFIRM_NEW_FIELDS.
 
         return $siteinfo;
@@ -236,6 +244,10 @@ class registration {
             'mobilenotificationsenabled' => get_string('mobilenotificationsenabled', 'hub', $mobilenotificationsenabled),
             'registereduserdevices' => get_string('registereduserdevices', 'hub', $siteinfo['registereduserdevices']),
             'registeredactiveuserdevices' => get_string('registeredactiveuserdevices', 'hub', $siteinfo['registeredactiveuserdevices']),
+            'analyticsenabledmodels' => get_string('analyticsenabledmodels', 'hub', $siteinfo['analyticsenabledmodels']),
+            'analyticspredictions' => get_string('analyticspredictions', 'hub', $siteinfo['analyticspredictions']),
+            'analyticsactions' => get_string('analyticsactions', 'hub', $siteinfo['analyticsactions']),
+            'analyticsactionsnotuseful' => get_string('analyticsactionsnotuseful', 'hub', $siteinfo['analyticsactionsnotuseful']),
         ];
 
         foreach ($senddata as $key => $str) {
index 5440a58..de30732 100644 (file)
@@ -84,13 +84,16 @@ class manager {
         // Get user records for all members of the conversation.
         // We must fetch distinct users, because it's possible for a user to message themselves via bulk user actions.
         // In such cases, there will be 2 records referring to the same user.
-        $sql = "SELECT u.*
+        $sql = "SELECT u.*, mca.id as ismuted
                   FROM {user} u
+             LEFT JOIN {message_conversation_actions} mca
+                    ON mca.userid = u.id AND mca.conversationid = ? AND mca.action = ?
                  WHERE u.id IN (
                           SELECT mcm.userid FROM {message_conversation_members} mcm
-                           WHERE mcm.conversationid = :convid
+                           WHERE mcm.conversationid = ?
                  )";
-        $members = $DB->get_records_sql($sql, ['convid' => $eventdata->convid]);
+        $members = $DB->get_records_sql($sql, [$eventdata->convid, \core_message\api::CONVERSATION_ACTION_MUTED,
+            $eventdata->convid]);
         if (empty($members)) {
             throw new \moodle_exception("Conversation has no members or does not exist.");
         }
@@ -138,7 +141,10 @@ class manager {
         foreach ($otherusers as $recipient) {
             // If this message was a legacy (1:1) message, then we use the userto.
             if ($legacymessage) {
+                $ismuted = $recipient->ismuted;
+
                 $recipient = $eventdata->userto;
+                $recipient->ismuted = $ismuted;
             }
 
             $usertoisrealuser = (\core_user::is_real_user($recipient->id) != false);
@@ -195,8 +201,9 @@ class manager {
 
             // Fill in the array of processors to be used based on default and user preferences.
             // This applies only to individual conversations. Messages to group conversations ignore processors.
+            // Do not process muted conversations.
             $processorlist = [];
-            if ($conv->type == \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL) {
+            if ($conv->type == \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL && !$recipient->ismuted) {
                 foreach ($processors as $processor) {
                     // Skip adding processors for internal user, if processor doesn't support sending message to internal user.
                     if (!$usertoisrealuser && !$processor->object->can_send_to_any_users()) {
index 8d9672f..f187601 100644 (file)
@@ -260,6 +260,7 @@ class icon_system_fontawesome extends icon_system_font {
             'core:i/moodle_host' => 'fa-graduation-cap',
             'core:i/moremenu' => 'fa-ellipsis-h',
             'core:i/move_2d' => 'fa-arrows',
+            'core:i/muted' => 'fa-microphone-slash',
             'core:i/navigationitem' => 'fa-fw',
             'core:i/ne_red_mark' => 'fa-remove',
             'core:i/new' => 'fa-bolt',
index e106f9c..fd1548f 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20190122" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20190308" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
         <KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id"/>
       </KEYS>
     </TABLE>
+    <TABLE NAME="message_conversation_actions" COMMENT="Stores all per-user actions on individual conversations">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="conversationid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="action" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id"/>
+        <KEY NAME="conversationid" TYPE="foreign" FIELDS="conversationid" REFTABLE="message_conversations" REFFIELDS="id"/>
+      </KEYS>
+    </TABLE>
     <TABLE NAME="message_user_actions" COMMENT="Stores all per-user actions on individual messages">
       <FIELDS>
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
         <FIELD NAME="modelid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="version" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="evaluationmode" TYPE="char" LENGTH="50" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="target" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="indicators" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="timesplitting" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
index e69b247..ea5f502 100644 (file)
@@ -874,6 +874,24 @@ $functions = array(
         'type' => 'write',
         'capabilities' => 'moodle/course:managegroups'
     ),
+    'core_message_mute_conversations' => array(
+        'classname' => 'core_message_external',
+        'methodname' => 'mute_conversations',
+        'classpath' => 'message/externallib.php',
+        'description' => 'Mutes a list of conversations',
+        'type' => 'write',
+        'ajax' => true,
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
+    'core_message_unmute_conversations' => array(
+        'classname' => 'core_message_external',
+        'methodname' => 'unmute_conversations',
+        'classpath' => 'message/externallib.php',
+        'description' => 'Unmutes a list of conversations',
+        'type' => 'write',
+        'ajax' => true,
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
     'core_message_block_user' => array(
         'classname' => 'core_message_external',
         'methodname' => 'block_user',
index 51d5157..71a4fff 100644 (file)
@@ -2753,5 +2753,157 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2019030100.01);
     }
 
+    if ($oldversion < 2019030700.01) {
+
+        // Define field evaluationmode to be added to analytics_models_log.
+        $table = new xmldb_table('analytics_models_log');
+        $field = new xmldb_field('evaluationmode', XMLDB_TYPE_CHAR, '50', null, null, null,
+            null, 'version');
+
+        // Conditionally launch add field evaluationmode.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+
+            $updatesql = "UPDATE {analytics_models_log}
+                             SET evaluationmode = 'configuration'";
+            $DB->execute($updatesql, []);
+
+            // Changing nullability of field evaluationmode on table block_instances to not null.
+            $field = new xmldb_field('evaluationmode', XMLDB_TYPE_CHAR, '50', null, XMLDB_NOTNULL,
+                null, null, 'version');
+
+            // Launch change of nullability for field evaluationmode.
+            $dbman->change_field_notnull($table, $field);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2019030700.01);
+    }
+
+    if ($oldversion < 2019030800.00) {
+        // Define table 'message_conversation_actions' to be created.
+        // Note - I would have preferred 'message_conversation_user_actions' but due to Oracle we can't. Boo.
+        $table = new xmldb_table('message_conversation_actions');
+
+        // Adding fields to table 'message_conversation_actions'.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('conversationid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('action', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+
+        // Adding keys to table 'message_conversation_actions'.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+        $table->add_key('userid', XMLDB_KEY_FOREIGN, ['userid'], 'user', ['id']);
+        $table->add_key('conversationid', XMLDB_KEY_FOREIGN, ['conversationid'], 'message_conversations', ['id']);
+
+        // Conditionally launch create table for 'message_conversation_actions'.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2019030800.00);
+    }
+
+    if ($oldversion < 2019030800.02) {
+        // Remove any conversations and their members associated with non-existent groups.
+        $sql = "SELECT mc.id
+                  FROM {message_conversations} mc
+             LEFT JOIN {groups} g
+                    ON mc.itemid = g.id
+                 WHERE mc.component = :component
+                   AND mc.itemtype = :itemtype
+                   AND g.id is NULL";
+        $conversations = $DB->get_records_sql($sql, ['component' => 'core_group', 'itemtype' => 'groups']);
+
+        if ($conversations) {
+            $conversationids = array_keys($conversations);
+
+            $DB->delete_records_list('message_conversations', 'id', $conversationids);
+            $DB->delete_records_list('message_conversation_members', 'conversationid', $conversationids);
+            $DB->delete_records_list('message_conversation_actions', 'conversationid', $conversationids);
+
+            // Now, go through each conversation and delete any messages and related message actions.
+            foreach ($conversationids as $conversationid) {
+                if ($messages = $DB->get_records('messages', ['conversationid' => $conversationid])) {
+                    $messageids = array_keys($messages);
+
+                    // Delete the actions.
+                    list($insql, $inparams) = $DB->get_in_or_equal($messageids);
+                    $DB->delete_records_select('message_user_actions', "messageid $insql", $inparams);
+
+                    // Delete the messages.
+                    $DB->delete_records('messages', ['conversationid' => $conversationid]);
+                }
+            }
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2019030800.02);
+    }
+
+    if ($oldversion < 2019030800.03) {
+
+        // Add missing indicators to course_dropout.
+        $params = [
+            'target' => '\core\analytics\target\course_dropout',
+            'trained' => 0,
+            'enabled' => 0,
+        ];
+        $models = $DB->get_records('analytics_models', $params);
+        foreach ($models as $model) {
+            $indicators = json_decode($model->indicators);
+
+            $potentiallymissingindicators = [
+                '\core_course\analytics\indicator\completion_enabled',
+                '\core_course\analytics\indicator\potential_cognitive_depth',
+                '\core_course\analytics\indicator\potential_social_breadth',
+                '\core\analytics\indicator\any_access_after_end',
+                '\core\analytics\indicator\any_access_before_start',
+                '\core\analytics\indicator\any_write_action_in_course',
+                '\core\analytics\indicator\read_actions'
+            ];
+
+            $missing = false;
+            foreach ($potentiallymissingindicators as $potentiallymissingindicator) {
+                if (!in_array($potentiallymissingindicator, $indicators)) {
+                    // Add the missing indicator to sites upgraded before 2017072000.02.
+                    $indicators[] = $potentiallymissingindicator;
+                    $missing = true;
+                }
+            }
+
+            if ($missing) {
+                $model->indicators = json_encode($indicators);
+                $model->version = time();
+                $model->timemodified = time();
+                $DB->update_record('analytics_models', $model);
+            }
+        }
+
+        // Add missing indicators to no_teaching.
+        $params = [
+            'target' => '\core\analytics\target\no_teaching',
+        ];
+        $models = $DB->get_records('analytics_models', $params);
+        foreach ($models as $model) {
+            $indicators = json_decode($model->indicators);
+            if (!in_array('\core_course\analytics\indicator\no_student', $indicators)) {
+                // Add the missing indicator to sites upgraded before 2017072000.02.
+
+                $indicators[] = '\core_course\analytics\indicator\no_student';
+
+                $model->indicators = json_encode($indicators);
+                $model->version = time();
+                $model->timemodified = time();
+                $DB->update_record('analytics_models', $model);
+            }
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2019030800.03);
+    }
+
     return true;
 }
index 4457350..016e86c 100644 (file)
@@ -174,14 +174,7 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor
      */
     public function classify($uniqueid, \stored_file $dataset, $outputdir) {
 
-        $modelfilepath = $this->get_model_filepath($outputdir);
-
-        if (!file_exists($modelfilepath)) {
-            throw new \moodle_exception('errorcantloadmodel', 'mlbackend_php', '', $modelfilepath);
-        }
-
-        $modelmanager = new ModelManager();
-        $classifier = $modelmanager->restoreFromFile($modelfilepath);
+        $classifier = $this->load_classifier($outputdir);
 
         $fh = $dataset->get_content_file_handle();
 
@@ -244,11 +237,19 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor
      * @param int $niterations
      * @param \stored_file $dataset
      * @param string $outputdir
+     * @param  string $trainedmodeldir
      * @return \stdClass
      */
-    public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir) {
+    public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
+            $outputdir, $trainedmodeldir) {
         $fh = $dataset->get_content_file_handle();
 
+        if ($trainedmodeldir) {
+            // We overwrite the number of iterations as the results will always be the same.
+            $niterations = 1;
+            $classifier = $this->load_classifier($trainedmodeldir);
+        }
+
         // The first lines are var names and the second one values.
         $metadata = $this->extract_metadata($fh);
 
@@ -308,15 +309,19 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor
         // Evaluate the model multiple times to confirm the results are not significantly random due to a short amount of data.
         for ($i = 0; $i < $niterations; $i++) {
 
-            $classifier = new \Phpml\Classification\Linear\LogisticRegression(self::TRAIN_ITERATIONS, Normalizer::NORM_L2);
-
-            // Split up the dataset in classifier and testing.
-            $data = new RandomSplit(new ArrayDataset($samples, $targets), 0.2);
+            if (!$trainedmodeldir) {
+                $classifier = new \Phpml\Classification\Linear\LogisticRegression(self::TRAIN_ITERATIONS, Normalizer::NORM_L2);
 
-            $classifier->train($data->getTrainSamples(), $data->getTrainLabels());
+                // Split up the dataset in classifier and testing.
+                $data = new RandomSplit(new ArrayDataset($samples, $targets), 0.2);
 
-            $predictedlabels = $classifier->predict($data->getTestSamples());
-            $phis[] = $this->get_phi($data->getTestLabels(), $predictedlabels);
+                $classifier->train($data->getTrainSamples(), $data->getTrainLabels());
+                $predictedlabels = $classifier->predict($data->getTestSamples());
+                $phis[] = $this->get_phi($data->getTestLabels(), $predictedlabels);
+            } else {
+                $predictedlabels = $classifier->predict($samples);
+                $phis[] = $this->get_phi($targets, $predictedlabels);
+            }
         }
 
         // Let's fill the results changing the returned status code depending on the phi-related calculated metrics.
@@ -381,6 +386,24 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor
         return $resultobj;
     }
 
+    /**
+     * Loads the pre-trained classifier.
+     *
+     * @throws \moodle_exception
+     * @param string $outputdir
+     * @return \Phpml\Classification\Linear\LogisticRegression
+     */
+    protected function load_classifier($outputdir) {
+        $modelfilepath = $this->get_model_filepath($outputdir);
+
+        if (!file_exists($modelfilepath)) {
+            throw new \moodle_exception('errorcantloadmodel', 'mlbackend_php', '', $modelfilepath);
+        }
+
+        $modelmanager = new ModelManager();
+        return $modelmanager->restoreFromFile($modelfilepath);
+    }
+
     /**
      * Train this processor regression model using the provided supervised learning dataset.
      *
@@ -416,9 +439,11 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor
      * @param int $niterations
      * @param \stored_file $dataset
      * @param string $outputdir
+     * @param  string $trainedmodeldir
      * @return \stdClass
      */
-    public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir) {
+    public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
+            $outputdir, $trainedmodeldir) {
         throw new \coding_exception('This predictor does not support regression yet.');
     }
 
index ee9e726..e74de28 100644 (file)
@@ -38,7 +38,7 @@ class processor implements  \core_analytics\classifier, \core_analytics\regresso
     /**
      * The required version of the python package that performs all calculations.
      */
-    const REQUIRED_PIP_PACKAGE_VERSION = '0.37.0';
+    const REQUIRED_PIP_PACKAGE_VERSION = '1.0.0';
 
     /**
      * The path to the Python bin.
@@ -229,9 +229,11 @@ class processor implements  \core_analytics\classifier, \core_analytics\regresso
      * @param int $niterations
      * @param \stored_file $dataset
      * @param string $outputdir
+     * @param  string $trainedmodeldir
      * @return \stdClass
      */
-    public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir) {
+    public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
+            $outputdir, $trainedmodeldir) {
 
         // Obtain the physical route to the file.
         $datasetpath = $this->get_file_path($dataset);
@@ -244,6 +246,10 @@ class processor implements  \core_analytics\classifier, \core_analytics\regresso
             escapeshellarg($maxdeviation) . ' ' .
             escapeshellarg($niterations);
 
+        if ($trainedmodeldir) {
+            $cmd .= ' ' . escapeshellarg($trainedmodeldir);
+        }
+
         if (!PHPUNIT_TEST && CLI_SCRIPT) {
             debugging($cmd, DEBUG_DEVELOPER);
         }
@@ -370,9 +376,11 @@ class processor implements  \core_analytics\classifier, \core_analytics\regresso
      * @param int $niterations
      * @param \stored_file $dataset
      * @param string $outputdir
+     * @param  string $trainedmodeldir
      * @return \stdClass
      */
-    public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir) {
+    public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
+            $outputdir, $trainedmodeldir) {
         throw new \coding_exception('This predictor does not support regression yet.');
     }
 
diff --git a/lib/phpunit/classes/coverage_info.php b/lib/phpunit/classes/coverage_info.php
new file mode 100644 (file)
index 0000000..0eb20db
--- /dev/null
@@ -0,0 +1,95 @@
+<?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/>.
+
+/**
+ * Coverage information for PHPUnit.
+ *
+ * @package    core
+ * @category   phpunit
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Coverage information for PHPUnit.
+ *
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class phpunit_coverage_info {
+
+    /** @var array The list of folders relative to the plugin root to whitelist in coverage generation. */
+    protected $whitelistfolders = [];
+
+    /** @var array The list of files relative to the plugin root to whitelist in coverage generation. */
+    protected $whitelistfiles = [];
+
+    /** @var array The list of folders relative to the plugin root to excludelist in coverage generation. */
+    protected $excludelistfolders = [];
+
+    /** @var array The list of files relative to the plugin root to excludelist in coverage generation. */
+    protected $excludelistfiles = [];
+
+    /**
+     * Get the formatted XML list of files and folders to whitelist.
+     *
+     * @param   string  $plugindir The root of the plugin, relative to the dataroot.
+     * @return  array
+     */
+    final public function get_whitelists(string $plugindir) : array {
+        $filters = [];
+
+        if (!empty($plugindir)) {
+            $plugindir .= "/";
+        }
+
+        foreach ($this->whitelistfolders as $folder) {
+            $filters[] = html_writer::tag('directory', "{$plugindir}{$folder}", ['suffix' => '.php']);
+        }
+
+        foreach ($this->whitelistfiles as $file) {
+            $filters[] = html_writer::tag('file', "{$plugindir}{$file}");
+        }
+
+        return $filters;
+    }
+
+    /**
+     * Get the formatted XML list of files and folders to exclude.
+     *
+     * @param   string  $plugindir The root of the plugin, relative to the dataroot.
+     * @return  array
+     */
+    final public function get_excludelists(string $plugindir) : array {
+        $filters = [];
+
+        if (!empty($plugindir)) {
+            $plugindir .= "/";
+        }
+
+        foreach ($this->excludelistfolders as $folder) {
+            $filters[] = html_writer::tag('directory', "{$plugindir}{$folder}", ['suffix' => '.php']);
+        }
+
+        foreach ($this->excludelistfiles as $file) {
+            $filters[] = html_writer::tag('file', "{$plugindir}{$file}");
+        }
+
+        return $filters;
+    }
+}
index 8c594f6..798b69c 100644 (file)
@@ -24,6 +24,7 @@
  */
 
 require_once(__DIR__.'/../../testing/classes/util.php');
+require_once(__DIR__ . "/coverage_info.php");
 
 /**
  * Collection of utility methods.
@@ -489,29 +490,62 @@ class phpunit_util extends testing_util {
         <testsuite name="@component@_testsuite">
             <directory suffix="_test.php">@dir@</directory>
         </testsuite>';
+        $filtertemplate = '
+        <testsuite name="@component@_testsuite">
+            <directory suffix="_test.php">@dir@</directory>
+        </testsuite>';
         $data = file_get_contents("$CFG->dirroot/phpunit.xml.dist");
 
         $suites = '';
+        $whitelists = [];
+        $excludelists = [];
+
+        $subsystems = core_component::get_core_subsystems();
+        $subsystems['core'] = $CFG->dirroot . '/lib';
+        foreach ($subsystems as $subsystem => $fulldir) {
+            if (empty($fulldir)) {
+                continue;
+            }
+            if (!file_exists("{$fulldir}/tests/")) {
+                // There are no tests - skip this directory.
+                continue;
+            }
+
+            $dir = substr($fulldir, strlen($CFG->dirroot) + 1);
+            if ($coverageinfo = self::get_coverage_info($fulldir)) {
+                $whitelists = array_merge($whitelists, $coverageinfo->get_whitelists($dir));
+                $excludelists = array_merge($excludelists, $coverageinfo->get_excludelists($dir));
+            }
+        }
 
         $plugintypes = core_component::get_plugin_types();
         ksort($plugintypes);
-        foreach ($plugintypes as $type=>$unused) {
+        foreach (array_keys($plugintypes) as $type) {
             $plugs = core_component::get_plugin_list($type);
             ksort($plugs);
-            foreach ($plugs as $plug=>$fullplug) {
-                if (!file_exists("$fullplug/tests/")) {
+            foreach ($plugs as $plug => $plugindir) {
+                if (!file_exists("{$plugindir}/tests/")) {
+                    // There are no tests - skip this directory.
                     continue;
                 }
-                $dir = substr($fullplug, strlen($CFG->dirroot)+1);
-                $dir .= '/tests';
-                $component = $type.'_'.$plug;
+
+                $dir = substr($plugindir, strlen($CFG->dirroot) + 1);
+                $testdir = "{$dir}/tests";
+                $component = "{$type}_{$plug}";
 
                 $suite = str_replace('@component@', $component, $template);
-                $suite = str_replace('@dir@', $dir, $suite);
+                $suite = str_replace('@dir@', $testdir, $suite);
 
                 $suites .= $suite;
+
+                if ($coverageinfo = self::get_coverage_info($plugindir)) {
+
+                    $whitelists = array_merge($whitelists, $coverageinfo->get_whitelists($dir));
+                    $excludelists = array_merge($excludelists, $coverageinfo->get_excludelists($dir));
+                }
             }
         }
+
         // Start a sequence between 100000 and 199000 to ensure each call to init produces
         // different ids in the database.  This reduces the risk that hard coded values will
         // end up being placed in phpunit or behat test code.
@@ -523,6 +557,9 @@ class phpunit_util extends testing_util {
             '<const name="PHPUNIT_SEQUENCE_START" value="' . $sequencestart . '"/>',
             $data);
 
+        $filters = self::get_filter_config($whitelists, $excludelists);
+        $data = str_replace('<!--@filterlist@-->', $filters, $data);
+
         $result = false;
         if (is_writable($CFG->dirroot)) {
             if ($result = file_put_contents("$CFG->dirroot/phpunit.xml", $data)) {
@@ -551,19 +588,18 @@ class phpunit_util extends testing_util {
         global $CFG;
 
         $template = '
-        <testsuites>
-            <testsuite name="@component@_testsuite">
-                <directory suffix="_test.php">.</directory>
-            </testsuite>
-        </testsuites>
-        <filter>
+    <testsuites>
+        <testsuite name="@component@_testsuite">
+            <directory suffix="_test.php">.</directory>
+        </testsuite>
+    </testsuites>';
+        $filterdefault = '
             <whitelist processUncoveredFilesFromWhitelist="false">
                 <directory suffix=".php">.</directory>
                 <exclude>
                     <directory suffix="_test.php">.</directory>
                 </exclude>
-            </whitelist>
-        </filter>';
+            </whitelist>';
 
         // Start a sequence between 100000 and 199000 to ensure each call to init produces
         // different ids in the database.  This reduces the risk that hard coded values will
@@ -583,8 +619,17 @@ class phpunit_util extends testing_util {
             $ctemplate = $template;
             $ctemplate = str_replace('@component@', $cname, $ctemplate);
 
-            // Apply it to the file template
             $fcontents = str_replace('<!--@component_suite@-->', $ctemplate, $ftemplate);
+
+            // Check for filter configurations.
+            if ($coverageinfo = self::get_coverage_info($cpath)) {
+                $filters = self::get_filter_config($coverageinfo->get_whitelists(''), $coverageinfo->get_excludelists(''));
+            } else {
+                $filters = $filterdefault;
+            }
+            $fcontents = str_replace('<!--@filterlist@-->', $filters, $fcontents);
+
+            // Apply it to the file template.
             $fcontents = str_replace(
                 '<const name="PHPUNIT_SEQUENCE_START" value=""/>',
                 '<const name="PHPUNIT_SEQUENCE_START" value="' . $sequencestart . '"/>',
@@ -881,4 +926,62 @@ class phpunit_util extends testing_util {
         $method->setAccessible(true);
         return $method->invokeArgs($object, $params);
     }
+
+    /**
+     * Pad the supplied string with $level levels of indentation.
+     *
+     * @param   string  $string The string to pad
+     * @param   int     $level The number of levels of indentation to pad
+     * @return  string
+     */
+    protected static function pad(string $string, int $level) : string {
+        return str_repeat(" ", $level * 4) . "{$string}\n";
+    }
+
+    /**
+     * Get the filter config for the supplied whitelist and excludelist configuration.
+     *
+     * @param   array[] $whitelists The list of files/folders in the whitelist.
+     * @param   array[] $excludelists The list of files/folders in the excludelist.
+     * @return  string
+     */
+    protected static function get_filter_config(array $whitelists, array $excludelists) : string {
+        $filters = '';
+        if (!empty($whitelists)) {
+            $filters .= self::pad("<whitelist>", 2);
+            foreach ($whitelists as $line) {
+                $filters .= self::pad($line, 3);
+            }
+            if (!empty($excludelists)) {
+                $filters .= self::pad("<exclude>", 3);
+                foreach ($excludelists as $line) {
+                    $filters .= self::pad($line, 4);
+                }
+                $filters .= self::pad("</exclude>", 3);
+            }
+            $filters .= self::pad("</whitelist>", 2);
+        }
+
+        return $filters;
+    }
+
+    /**
+     * Get the phpunit_coverage_info for the specified plugin or subsystem directory.
+     *
+     * @param   string  $fulldir The directory to find the coverage info file in.
+     * @return  phpunit_coverage_info
+     */
+    protected static function get_coverage_info(string $fulldir): ?phpunit_coverage_info {
+        $coverageconfig = "{$fulldir}/tests/coverage.php";
+        if (file_exists($coverageconfig)) {
+            $coverageinfo = require($coverageconfig);
+            if (!$coverageinfo instanceof phpunit_coverage_info) {
+                throw new \coding_exception("{$coverageconfig} does not return a phpunit_coverage_info");
+            }
+
+            return $coverageinfo;
+        }
+
+        return null;
+    }
 }
index 3b523f1..a869ea0 100644 (file)
@@ -37,6 +37,7 @@ use Behat\Testwork\Hook\Scope\BeforeSuiteScope,
     Behat\Behat\Hook\Scope\AfterScenarioScope,
     Behat\Behat\Hook\Scope\BeforeStepScope,
     Behat\Behat\Hook\Scope\AfterStepScope,
+    Behat\Mink\Exception\ExpectationException,
     Behat\Mink\Exception\DriverException as DriverException,
     WebDriver\Exception\NoSuchWindow as NoSuchWindow,
     WebDriver\Exception\UnexpectedAlertOpen as UnexpectedAlertOpen,
@@ -72,6 +73,11 @@ class behat_hooks extends behat_base {
      */
     protected static $initprocessesfinished = false;
 
+    /**
+     * @var bool Scenario running
+     */
+    protected $scenariorunning = false;
+
     /**
      * Some exceptions can only be caught in a before or after step hook,
      * they can not be thrown there as they will provoke a framework level
@@ -370,22 +376,8 @@ class behat_hooks extends behat_base {
             self::$runningsuite = $suitename;
         }
 
-        // Start always in the the homepage.
-        try {
-            // Let's be conservative as we never know when new upstream issues will affect us.
-            $session->visit($this->locate_path('/'));
-        } catch (UnknownError $e) {
-            throw new behat_stop_exception($e->getMessage());
-        }
-
-        // Checking that the root path is a Moodle test site.
-        if (self::is_first_scenario()) {
-            $notestsiteexception = new behat_stop_exception('The base URL (' . $CFG->wwwroot . ') is not a behat test site, ' .
-                'ensure you started the built-in web server in the correct directory or your web server is correctly started and set up');
-            $this->find("xpath", "//head/child::title[normalize-space(.)='" . behat_util::BEHATSITENAME . "']", $notestsiteexception);
-
-            self::$initprocessesfinished = true;
-        }
+        // Reset the scenariorunning variable to ensure that Step 0 occurs.
+        $this->scenariorunning = false;
 
         // Run all test with medium (1024x768) screen size, to avoid responsive problems.
         $this->resize_window('medium');
@@ -399,6 +391,46 @@ class behat_hooks extends behat_base {
         }
     }
 
+    /**
+     * Hook to open the site root before the first step in the suite.
+     * Yes, this is in a strange location and should be in the BeforeScenario hook, but failures in the test setUp lead
+     * to the test being incorrectly marked as skipped with no way to force the test to be failed.
+     *
+     * @param   BeforeStepScope $scope
+     * @BeforeStep
+     */
+    public function before_step(BeforeStepScope $scope) {
+        global $CFG;
+
+        if (!$this->scenariorunning) {
+            // We need to visit / before the first step in any Scenario.
+            // This is our Step 0.
+            // Ideally this would be in the BeforeScenario hook, but any exception in there will lead to the test being
+            // skipped rather than it being failed.
+            //
+            // We also need to check that the site returned is a Behat site.
+            // Again, this would be better in the BeforeSuite hook, but that does not have access to the selectors in
+            // order to perform the necessary searches.
+            $session = $this->getSession();
+            $session->visit($this->locate_path('/'));
+
+            // Checking that the root path is a Moodle test site.
+            if (self::is_first_scenario()) {
+                $message = "The base URL ({$CFG->wwwroot}) is not a behat test site. " .
+                    'Ensure that you started the built-in web server in the correct directory, ' .
+                    'or that your web server is correctly set up and started.';
+
+                $this->find(
+                        "xpath", "//head/child::title[normalize-space(.)='" . behat_util::BEHATSITENAME . "']",
+                        new ExpectationException($message, $session)
+                    );
+
+                self::$initprocessesfinished = true;
+            }
+            $this->scenariorunning = true;
+        }
+    }
+
     /**
      * Sets up the tags for the current scenario.
      *
diff --git a/lib/tests/coverage.php b/lib/tests/coverage.php
new file mode 100644 (file)
index 0000000..8251d45
--- /dev/null
@@ -0,0 +1,53 @@
+<?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/>.
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Coverage information for the core subsystem.
+ *
+ * @package    core
+ * @category   phpunit
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Coverage information for the core subsystem.
+ *
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+return new class extends phpunit_coverage_info {
+    /** @var array The list of folders relative to the plugin root to whitelist in coverage generation. */
+    protected $whitelistfolders = [
+        'classes',
+
+        // This is a legacy hangup which relates to parts of the file storage API being placed in the wrong location.
+        'filestorage',
+    ];
+
+    /** @var array The list of files relative to the plugin root to whitelist in coverage generation. */
+    protected $whitelistfiles = [];
+
+    /** @var array The list of folders relative to the plugin root to excludelist in coverage generation. */
+    protected $excludelistfolders = [
+        'filestorage/tests',
+    ];
+
+    /** @var array The list of files relative to the plugin root to excludelist in coverage generation. */
+    protected $excludelistfiles = [];
+};
index 01ec3fb..489a485 100644 (file)
@@ -1118,6 +1118,51 @@ class core_messagelib_testcase extends advanced_testcase {
         $this->assertTrue($storedfileexists);
     }
 
+    public function test_send_message_when_muted() {
+        $this->preventResetByRollback();
+        $this->resetAfterTest();
+
+        $userfrom = $this->getDataGenerator()->create_user();
+        $userto = $this->getDataGenerator()->create_user();
+
+        // Create a conversation between the users.
+        $conversation = \core_message\api::create_conversation(
+            \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [
+                $userfrom->id,
+                $userto->id
+            ]
+        );
+
+        $message = new \core\message\message();
+        $message->courseid = 1;
+        $message->component = 'moodle';
+        $message->name = 'instantmessage';
+        $message->userfrom = $userfrom;
+        $message->convid = $conversation->id;
+        $message->subject = 'message subject 1';
+        $message->fullmessage = 'message body';
+        $message->fullmessageformat = FORMAT_MARKDOWN;
+        $message->fullmessagehtml = '<p>message body</p>';
+        $message->smallmessage = 'small message';
+        $message->notification = '0';
+
+        $sink = $this->redirectEmails();
+        message_send($message);
+        $emails = $sink->get_messages();
+        $this->assertCount(1, $emails);
+        $sink->clear();
+
+        // Mute the conversation.
+        \core_message\api::mute_conversation($userto->id, $conversation->id);
+
+        $sink = $this->redirectEmails();
+        message_send($message);
+        $emails = $sink->get_messages();
+        $this->assertCount(0, $emails);
+        $sink->clear();
+    }
+
     /**
      * Is a particular message type in the list of message types.
      * @param string $component
index 7218fab..eac3b25 100644 (file)
@@ -15,6 +15,8 @@ attribute on forms to avoid collisions in forms loaded in AJAX requests.
   It is recommended that privacy providers using this function call rewrite any long query into a number of separate
   calls to add_from_sql for improved performance, and that the new argument is used.
   This will allow queries to remain backwards-compatible with older versions of Moodle but will have significantly better performance in version supporting the innerjoin parameter.
+* /message/defaultoutputs.php file and admin_page_defaultmessageoutputs class have been deprecated
+  and all their settings moved to admin/message.php (see MDL-64495). Please use admin_page_managemessageoutputs class instead.
 
 === 3.6 ===
 
index 15244eb..6cf3713 100644 (file)
Binary files a/message/amd/build/message_drawer_events.min.js and b/message/amd/build/message_drawer_events.min.js differ
index 8399306..6d5e118 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation.min.js and b/message/amd/build/message_drawer_view_conversation.min.js differ
index afd8fe0..a45db35 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation_constants.min.js and b/message/amd/build/message_drawer_view_conversation_constants.min.js differ
index a7d2929..7ec4cfd 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation_patcher.min.js and b/message/amd/build/message_drawer_view_conversation_patcher.min.js differ
index cf35154..b23ddc6 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation_renderer.min.js and b/message/amd/build/message_drawer_view_conversation_renderer.min.js differ
index 313aa09..c0f4082 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation_state_manager.min.js and b/message/amd/build/message_drawer_view_conversation_state_manager.min.js differ
index 0154211..48c4b7f 100644 (file)
Binary files a/message/amd/build/message_drawer_view_overview_section.min.js and b/message/amd/build/message_drawer_view_overview_section.min.js differ
index 9f480a6..999ba17 100644 (file)
Binary files a/message/amd/build/message_repository.min.js and b/message/amd/build/message_repository.min.js differ
index 002d218..fa5ce01 100644 (file)
@@ -35,7 +35,9 @@ define([], function() {
         CONVERSATION_DELETED: 'message-drawer-conversation-deleted',
         CONVERSATION_READ: 'message-drawer-conversation-read',
         CONVERSATION_SET_FAVOURITE: 'message-drawer-conversation-set-favourite',
+        CONVERSATION_SET_MUTED: 'message-drawer-conversation-set-muted',
         CONVERSATION_UNSET_FAVOURITE: 'message-drawer-conversation-unset-favourite',
+        CONVERSATION_UNSET_MUTED: 'message-drawer-conversation-unset-muted',
         PREFERENCES_UPDATED: 'message-drawer-preferences-updated',
         ROUTE_CHANGED: 'message-drawer-route-change',
         SHOW: 'message-drawer-show',
index 486ee7c..8b8949b 100644 (file)
@@ -240,6 +240,7 @@ function(
             subname: state.subname,
             imageUrl: state.imageUrl,
             isFavourite: state.isFavourite,
+            isMuted: state.isMuted,
             type: state.type,
             totalMemberCount: state.totalMemberCount,
             loggedInUserId: state.loggedInUserId,
@@ -328,6 +329,7 @@ function(
         newState = StateManager.setImageUrl(newState, imageUrl);
         newState = StateManager.setTotalMemberCount(newState, conversation.membercount);
         newState = StateManager.setIsFavourite(newState, conversation.isfavourite);
+        newState = StateManager.setIsMuted(newState, conversation.ismuted);
         newState = StateManager.addMessages(newState, conversation.messages);
         return newState;
     };
@@ -795,6 +797,50 @@ function(
             });
     };
 
+    /**
+     * Set the current conversation as a muted conversation.
+     *
+     * @return {Promise} Renderer promise.
+     */
+    var setMuted = function() {
+        var userId = viewState.loggedInUserId;
+        var conversationId = viewState.id;
+
+        return Repository.setMutedConversations(userId, [conversationId])
+            .then(function() {
+                var newState = StateManager.setIsMuted(viewState, true);
+                return render(newState);
+            })
+            .then(function() {
+                return PubSub.publish(
+                    MessageDrawerEvents.CONVERSATION_SET_MUTED,
+                    formatConversationForEvent(viewState)
+                );
+            });
+    };
+
+    /**
+     * Unset the current conversation as a muted conversation.
+     *
+     * @return {Promise} Renderer promise.
+     */
+    var unsetMuted = function() {
+        var userId = viewState.loggedInUserId;
+        var conversationId = viewState.id;
+
+        return Repository.unsetMutedConversations(userId, [conversationId])
+            .then(function() {
+                var newState = StateManager.setIsMuted(viewState, false);
+                return render(newState);
+            })
+            .then(function() {
+                return PubSub.publish(
+                    MessageDrawerEvents.CONVERSATION_UNSET_MUTED,
+                    formatConversationForEvent(viewState)
+                );
+            });
+    };
+
     /**
      * Tell the statemanager there is a request to delete the selected messages
      * and run the renderer to show confirm delete messages dialogue.
@@ -1211,6 +1257,29 @@ function(
 
     /**
      * Show the view group info page.
+     * Set this conversation as muted.
+     *
+     * @param {Object} e Element this event handler is called on.
+     * @param {Object} data Data for this event.
+     */
+    var handleSetMuted = function(e, data) {
+        setMuted().catch(Notification.exception);
+        data.originalEvent.preventDefault();
+    };
+
+    /**
+     * Unset this conversation as muted.
+     *
+     * @param {Object} e Element this event handler is called on.
+     * @param {Object} data Data for this event.
+     */
+    var handleUnsetMuted = function(e, data) {
+        unsetMuted().catch(Notification.exception);
+        data.originalEvent.preventDefault();
+    };
+
+    /**
+     * Show the view contact page.
      *
      * @param {String} namespace Unique identifier for the Routes
      * @return {Function} View group info handler.
@@ -1254,7 +1323,9 @@ function(
             [SELECTORS.ACTION_VIEW_CONTACT, generateHandleViewContact(namespace)],
             [SELECTORS.ACTION_VIEW_GROUP_INFO, generateHandleViewGroupInfo(namespace)],
             [SELECTORS.ACTION_CONFIRM_FAVOURITE, handleSetFavourite],
+            [SELECTORS.ACTION_CONFIRM_MUTE, handleSetMuted],
             [SELECTORS.ACTION_CONFIRM_UNFAVOURITE, handleUnsetFavourite],
+            [SELECTORS.ACTION_CONFIRM_UNMUTE, handleUnsetMuted]
         ];
         var bodyActivateHandlers = [
             [SELECTORS.ACTION_CANCEL_CONFIRM, generateConfirmActionHandler(cancelRequest)],
index 83507c0..3bd84c8 100644 (file)
@@ -31,9 +31,11 @@ define([], function() {
         ACTION_CONFIRM_DELETE_SELECTED_MESSAGES: '[data-action="confirm-delete-selected-messages"]',
         ACTION_CONFIRM_DELETE_CONVERSATION: '[data-action="confirm-delete-conversation"]',
         ACTION_CONFIRM_FAVOURITE: '[data-action="confirm-favourite"]',
+        ACTION_CONFIRM_MUTE: '[data-action="confirm-mute"]',
         ACTION_CONFIRM_UNFAVOURITE: '[data-action="confirm-unfavourite"]',
         ACTION_CONFIRM_REMOVE_CONTACT: '[data-action="confirm-remove-contact"]',
         ACTION_CONFIRM_UNBLOCK: '[data-action="confirm-unblock"]',
+        ACTION_CONFIRM_UNMUTE: '[data-action="confirm-unmute"]',
         ACTION_DECLINE_CONTACT_REQUEST: '[data-action="decline-contact-request"]',
         ACTION_REQUEST_ADD_CONTACT: '[data-action="request-add-contact"]',
         ACTION_REQUEST_BLOCK: '[data-action="request-block"]',
@@ -74,6 +76,7 @@ define([], function() {
         MESSAGES_SELECTED_COUNT: '[data-region="message-selected-court"]',
         MESSAGE_TEXT_AREA: '[data-region="send-message-txt"]',
         MORE_MESSAGES_LOADING_ICON_CONTAINER: '[data-region="more-messages-loading-icon-container"]',
+        MUTED_ICON_CONTAINER: '[data-region="muted-icon-container"]',
         PLACEHOLDER_CONTAINER: '[data-region="placeholder-container"]',
         SEND_MESSAGE_BUTTON: '[data-action="send-message"]',
         SEND_MESSAGE_ICON_CONTAINER: '[data-region="send-icon-container"]',
index dd7556a..43ea13c 100644 (file)
@@ -298,6 +298,7 @@ function(
                     totalmembercount: newState.totalMemberCount,
                     imageurl: newState.imageUrl,
                     isfavourite: newState.isFavourite,
+                    ismuted: newState.isMuted,
                     // Don't show favouriting if we don't have a conversation.
                     showfavourite: newState.id !== null,
                     userid: newOtherUser.id,
@@ -336,6 +337,7 @@ function(
                     totalmembercount: newState.totalMemberCount,
                     imageurl: newState.imageUrl,
                     isfavourite: newState.isFavourite,
+                    ismuted: newState.isMuted,
                     // Don't show favouriting if we don't have a conversation.
                     showfavourite: newState.id !== null
                 }
@@ -646,6 +648,39 @@ function(
         }
     };
 
+    /**
+     * Check if there are any changes the conversation muted state.
+     *
+     * @param  {Object} state The current state.
+     * @param  {Object} newState The new state.
+     * @return {string|null}
+     */
+    var buildIsMuted = function(state, newState) {
+        var oldIsMuted = state.isMuted;
+        var newIsMuted = newState.isMuted;
+
+        if (state.id === null && newState.id === null) {
+            // The conversation isn't yet created so don't change anything.
+            return null;
+        } else if (state.id === null && newState.id !== null) {
+            // The conversation was created so we can show the mute button.
+            return 'show-mute';
+        } else if (state.id !== null && newState.id === null) {
+            // We're changing from a created conversation to a new conversation so hide
+            // the muting functionality for now.
+            return 'hide';
+        } else if (oldIsMuted == newIsMuted) {
+            // No change.
+            return null;
+        } else if (!oldIsMuted && newIsMuted) {
+            return 'show-unmute';
+        } else if (oldIsMuted && !newIsMuted) {
+            return 'show-mute';
+        } else {
+            return null;
+        }
+    };
+
     /**
      * Check if there are any changes in the contact status of the current user
      * and other user.
@@ -1130,7 +1165,8 @@ function(
                 confirmDeleteSelectedMessages: buildConfirmDeleteSelectedMessages,
                 inEditMode: buildInEditMode,
                 selectedMessages: buildSelectedMessages,
-                isFavourite: buildIsFavourite
+                isFavourite: buildIsFavourite,
+                isMuted: buildIsMuted
             }
         };
         // These build functions are only applicable to private conversations.
index 5e5cf90..d0b67f2 100644 (file)
@@ -1248,6 +1248,39 @@ function(
         }
     };
 
+    /**
+     * Show or hide the mute / unmute option in the header dropdown menu
+     * and the muted icon in the header title.
+     *
+     * @param {Object} header The header container element.
+     * @param {Object} body The body container element.
+     * @param {Object} footer The footer container element.
+     * @param {string} state The state of the conversation as defined by the patcher.
+     */
+    var renderIsMuted = function(header, body, footer, state) {
+        var muteIcon = header.find(SELECTORS.MUTED_ICON_CONTAINER);
+        var setMuted = header.find(SELECTORS.ACTION_CONFIRM_MUTE);
+        var unsetMuted = header.find(SELECTORS.ACTION_CONFIRM_UNMUTE);
+
+        switch (state) {
+            case 'hide':
+                muteIcon.addClass('hidden');
+                setMuted.addClass('hidden');
+                unsetMuted.addClass('hidden');
+                break;
+            case 'show-mute':
+                muteIcon.addClass('hidden');
+                setMuted.removeClass('hidden');
+                unsetMuted.addClass('hidden');
+                break;
+            case 'show-unmute':
+                muteIcon.removeClass('hidden');
+                setMuted.addClass('hidden');
+                unsetMuted.removeClass('hidden');
+                break;
+        }
+    };
+
     /**
      * Show or hide the add / remove user as contact option in the header dropdown menu.
      *
@@ -1476,6 +1509,7 @@ function(
                 isBlocked: renderIsBlocked,
                 isContact: renderIsContact,
                 isFavourite: renderIsFavourite,
+                isMuted: renderIsMuted,
                 loadingConfirmAction: renderLoadingConfirmAction,
                 inEditMode: renderInEditMode
             },
index 0340b45..98ed99e 100644 (file)
@@ -121,6 +121,7 @@ define(['jquery'], function($) {
             totalMemberCount: null,
             imageUrl: null,
             isFavourite: null,
+            isMuted: null,
             members: {},
             messages: [],
             hasTriedToLoadMessages: false,
@@ -347,6 +348,19 @@ define(['jquery'], function($) {
         return newState;
     };
 
+    /**
+     * Set whether the conversation is a muted conversation.
+     *
+     * @param  {Object} state Current state.
+     * @param  {bool} isMuted If it's muted.
+     * @return {Object} New state.
+     */
+    var setIsMuted = function(state, isMuted) {
+        var newState = cloneState(state);
+        newState.isMuted = isMuted;
+        return newState;
+    };
+
     /**
      * Set the total member count.
      *
@@ -659,6 +673,7 @@ define(['jquery'], function($) {
         setSubname: setSubname,
         setType: setType,
         setIsFavourite: setIsFavourite,
+        setIsMuted: setIsMuted,
         setTotalMemberCount: setTotalMemberCount,
         setImageUrl: setImageUrl,
         setLoadingConfirmAction: setLoadingConfirmAction,
index 8e39af3..bf32f2a 100644 (file)
@@ -58,6 +58,7 @@ function(
         BLOCKED_ICON_CONTAINER: '[data-region="contact-icon-blocked"]',
         LAST_MESSAGE: '[data-region="last-message"]',
         LAST_MESSAGE_DATE: '[data-region="last-message-date"]',
+        MUTED_ICON_CONTAINER: '[data-region="muted-icon-container"]',
         UNREAD_COUNT: '[data-region="unread-count"]',
         SECTION_TOTAL_COUNT: '[data-region="section-total-count"]',
         SECTION_TOTAL_COUNT_CONTAINER: '[data-region="section-total-count-container"]',
@@ -169,6 +170,7 @@ function(
                 name: conversation.name,
                 subname: conversation.subname,
                 unreadcount: conversation.unreadcount,
+                ismuted: conversation.ismuted,
                 lastmessagedate: lastMessage ? lastMessage.timecreated : null,
                 sentfromcurrentuser: lastMessage ? lastMessage.useridfrom == userId : null,
                 lastmessage: lastMessage ? $(lastMessage.text).text() || lastMessage.text : null
@@ -334,6 +336,24 @@ function(
         return root.find('[data-user-id="' + userId + '"]');
     };
 
+    /**
+     * Show the conversation is muted icon.
+     *
+     * @param  {Object} conversationElement The conversation element.
+     */
+    var muteConversation = function(conversationElement) {
+        conversationElement.find(SELECTORS.MUTED_ICON_CONTAINER).removeClass('hidden');
+    };
+
+    /**
+     * Hide the conversation is muted icon.
+     *
+     * @param  {Object} conversationElement The conversation element.
+     */
+    var unmuteConversation = function(conversationElement) {
+        conversationElement.find(SELECTORS.MUTED_ICON_CONTAINER).addClass('hidden');
+    };
+
     /**
      * Show the contact is blocked icon.
      *
@@ -515,11 +535,28 @@ function(
 
         PubSub.subscribe(MessageDrawerEvents.CONTACT_UNBLOCKED, function(userId) {
             var conversationElement = getConversationElementFromUserId(root, userId);
+
             if (conversationElement.length) {
                 unblockContact(conversationElement);
             }
         });
 
+        PubSub.subscribe(MessageDrawerEvents.CONVERSATION_SET_MUTED, function(conversation) {
+            var conversationId = conversation.id;
+            var conversationElement = getConversationElement(root, conversationId);
+            if (conversationElement.length) {
+                muteConversation(conversationElement);
+            }
+        });
+
+        PubSub.subscribe(MessageDrawerEvents.CONVERSATION_UNSET_MUTED, function(conversation) {
+            var conversationId = conversation.id;
+            var conversationElement = getConversationElement(root, conversationId);
+            if (conversationElement.length) {
+                unmuteConversation(conversationElement);
+            }
+        });
+
         PubSub.subscribe(MessageDrawerEvents.CONVERSATION_NEW_LAST_MESSAGE, function(conversation) {
             if (
                 (type && conversation.type != type) ||
index a7da2f0..24b2349 100644 (file)
@@ -905,6 +905,42 @@ define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notificat
         return Ajax.call([request])[0];
     };
 
+    /**
+     * Set a list of conversations to set as muted for the given user.
+     *
+     * @param {int} userId The user id
+     * @param {array} conversationIds List of conversation ids to set as favourite
+     * @return {object} jQuery promise
+     */
+    var setMutedConversations = function(userId, conversationIds) {
+        var request = {
+            methodname: 'core_message_mute_conversations',
+            args: {
+                userid: userId,
+                conversationids: conversationIds
+            }
+        };
+        return Ajax.call([request])[0];
+    };
+
+    /**
+     * Set a list of conversations to unset as muted for the given user.
+     *
+     * @param {int} userId The user id
+     * @param {array} conversationIds List of conversation ids to unset as favourite
+     * @return {object} jQuery promise
+     */
+    var unsetMutedConversations = function(userId, conversationIds) {
+        var request = {
+            methodname: 'core_message_unmute_conversations',
+            args: {
+                userid: userId,
+                conversationids: conversationIds
+            }
+        };
+        return Ajax.call([request])[0];
+    };
+
     /**
      * Get a list of user's member info.
      *
@@ -1060,7 +1096,9 @@ define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notificat
         getConversations: getConversations,
         getConversationMembers: getConversationMembers,
         setFavouriteConversations: setFavouriteConversations,
+        setMutedConversations: setMutedConversations,
         unsetFavouriteConversations: unsetFavouriteConversations,
+        unsetMutedConversations: unsetMutedConversations,
         getMemberInfo: getMemberInfo,
         markAllConversationMessagesAsRead: markAllConversationMessagesAsRead,
         getUserMessagePreferences: getUserMessagePreferences,
index b15a97e..ebbb9f0 100644 (file)
@@ -48,6 +48,11 @@ class api {
      */
     const MESSAGE_ACTION_DELETED = 2;
 
+    /**
+     * The action for reading a message.
+     */
+    const CONVERSATION_ACTION_MUTED = 1;
+
     /**
      * The privacy setting for being messaged by anyone within courses user is member of.
      */
@@ -545,7 +550,7 @@ class api {
 
         $sql = "SELECT m.id as messageid, mc.id as id, mc.name as conversationname, mc.type as conversationtype, m.useridfrom,
                        m.smallmessage, m.fullmessage, m.fullmessageformat, m.fullmessagehtml, m.timecreated, mc.component,
-                       mc.itemtype, mc.itemid, mc.contextid
+                       mc.itemtype, mc.itemid, mc.contextid, mca.action as ismuted
                   FROM {message_conversations} mc
             INNER JOIN {message_conversation_members} mcm
                     ON (mcm.conversationid = mc.id AND mcm.userid = :userid3)
@@ -569,12 +574,15 @@ class api {
                     ON lastmessage.conversationid = mc.id
             LEFT JOIN {messages} m
                    ON m.id = lastmessage.messageid
+            LEFT JOIN {message_conversation_actions} mca
+                   ON (mca.conversationid = mc.id AND mca.userid = :userid4 AND mca.action = :convaction)
                 WHERE mc.id IS NOT NULL
                   AND mc.enabled = 1 $typesql $favouritesql
               ORDER BY (CASE WHEN m.timecreated IS NULL THEN 0 ELSE 1 END) DESC, m.timecreated DESC, id DESC";
 
         $params = array_merge($favouriteparams, ['userid' => $userid, 'action' => self::MESSAGE_ACTION_DELETED,
-            'userid2' => $userid, 'userid3' => $userid, 'convtype' => $type]);
+            'userid2' => $userid, 'userid3' => $userid, 'userid4' => $userid, 'convaction' => self::CONVERSATION_ACTION_MUTED,
+            'convtype' => $type]);
         $conversationset = $DB->get_recordset_sql($sql, $params, $limitfrom, $limitnum);
 
         $conversations = [];
@@ -790,6 +798,7 @@ class api {
             $conv->isfavourite = in_array($conv->id, $favouriteconversationids);
             $conv->isread = isset($unreadcounts[$conv->id]) ? false : true;
             $conv->unreadcount = isset($unreadcounts[$conv->id]) ? $unreadcounts[$conv->id]->unreadcount : null;
+            $conv->ismuted = $conversation->ismuted ? true : false;
             $conv->members = $members[$conv->id];
 
             // Add the most recent message information.
@@ -950,6 +959,12 @@ class api {
 
         $membercount = $DB->count_records('message_conversation_members', ['conversationid' => $conversationid]);
 
+        $ismuted = false;
+        if ($DB->record_exists('message_conversation_actions', ['userid' => $userid,
+                'conversationid' => $conversationid, 'action' => self::CONVERSATION_ACTION_MUTED])) {
+            $ismuted = true;
+        }
+
         return (object) [
             'id' => $conversation->id,
             'name' => $conversation->name,
@@ -960,6 +975,7 @@ class api {
             'isfavourite' => $isfavourite,
             'isread' => empty($unreadcount),
             'unreadcount' => $unreadcount,
+            'ismuted' => $ismuted,
             'members' => $members,
             'messages' => $messages['messages']
         ];
@@ -3133,4 +3149,83 @@ class api {
 
         return $counts;
     }
+
+    /**
+     * Handles muting a conversation.
+     *
+     * @param int $userid The id of the user
+     * @param int $conversationid The id of the conversation
+     */
+    public static function mute_conversation(int $userid, int $conversationid) : void {
+        global $DB;
+
+        $mutedconversation = new \stdClass();
+        $mutedconversation->userid = $userid;
+        $mutedconversation->conversationid = $conversationid;
+        $mutedconversation->action = self::CONVERSATION_ACTION_MUTED;
+        $mutedconversation->timecreated = time();
+
+        $DB->insert_record('message_conversation_actions', $mutedconversation);
+    }
+
+    /**
+     * Handles unmuting a conversation.
+     *
+     * @param int $userid The id of the user
+     * @param int $conversationid The id of the conversation
+     */
+    public static function unmute_conversation(int $userid, int $conversationid) : void {
+        global $DB;
+
+        $DB->delete_records('message_conversation_actions',
+            [
+                'userid' => $userid,
+                'conversationid' => $conversationid,
+                'action' => self::CONVERSATION_ACTION_MUTED
+            ]
+        );
+    }
+
+    /**
+     * Checks whether a conversation is muted or not.
+     *
+     * @param int $userid The id of the user
+     * @param int $conversationid The id of the conversation
+     * @return bool Whether or not the conversation is muted or not
+     */
+    public static function is_conversation_muted(int $userid, int $conversationid) : bool {
+        global $DB;
+
+        return $DB->record_exists('message_conversation_actions',
+            [
+                'userid' => $userid,
+                'conversationid' => $conversationid,
+                'action' => self::CONVERSATION_ACTION_MUTED
+            ]
+        );
+    }
+
+    /**
+     * Completely removes all related data in the DB for a given conversation.
+     *
+     * @param int $conversationid The id of the conversation
+     */
+    public static function delete_all_conversation_data(int $conversationid) {
+        global $DB;
+
+        $DB->delete_records('message_conversations', ['id' => $conversationid]);
+        $DB->delete_records('message_conversation_members', ['conversationid' => $conversationid]);
+        $DB->delete_records('message_conversation_actions', ['conversationid' => $conversationid]);
+
+        // Now, go through and delete any messages and related message actions for the conversation.
+        if ($messages = $DB->get_records('messages', ['conversationid' => $conversationid])) {
+            $messageids = array_keys($messages);
+
+            list($insql, $inparams) = $DB->get_in_or_equal($messageids);
+            $DB->delete_records_select('message_user_actions', "messageid $insql", $inparams);
+
+            // Delete the messages now.
+            $DB->delete_records('messages', ['conversationid' => $conversationid]);
+        }
+    }
 }
index 8e5441b..ee90f5c 100644 (file)
@@ -99,6 +99,16 @@ class provider implements
             'privacy:metadata:message_conversation_members'
         );
 
+        $items->add_database_table(
+            'message_conversation_actions',
+            [
+                'conversationid' => 'privacy:metadata:message_conversation_actions:conversationid',
+                'userid' => 'privacy:metadata:message_conversation_actions:userid',
+                'timecreated' => 'privacy:metadata:message_conversation_actions:timecreated',
+            ],
+            'privacy:metadata:message_conversation_actions'
+        );
+
         $items->add_database_table(
             'message_contacts',
             [
@@ -205,6 +215,8 @@ class provider implements
         // It is enough to add the user's context as soon as we come to the conclusion that the user has some data.
         // Also, the order of checking is sorted by the probability of occurrence (just by guess).
         // There is no need to check the message_user_actions table, as there needs to be a message in order to be a message action.
+        // There is no need to check the message_conversation_actions table, as there needs to be a conversation in order to
+        // be a conversation action.
         // So, checking messages table would suffice.
 
         $hasdata = false;
@@ -258,6 +270,8 @@ class provider implements
         // It is enough to add the user's context as soon as we come to the conclusion that the user has some data.
         // Also, the order of checking is sorted by the probability of occurrence (just by guess).
         // There is no need to check the message_user_actions table, as there needs to be a message in order to be a message action.
+        // There is no need to check the message_conversation_actions table, as there needs to be a conversation in order to
+        // be a conversation action.
         // So, checking messages table would suffice.
 
         $hasdata = false;
@@ -568,6 +582,7 @@ class provider implements
 
             // Delete members and conversations.
             $DB->delete_records_list('message_conversation_members', 'conversationid', $conversationids);
+            $DB->delete_records_list('message_conversation_actions', 'conversationid', $conversationids);
             $DB->delete_records_list('message_conversations', 'id', $conversationids);
         }
     }
@@ -673,6 +688,9 @@ class provider implements
             // Reuse the $params var because it contains the useridparams and the conversationids.
             $DB->delete_records_select('message_conversation_members', $sql, $params);
 
+            // Delete any conversation actions.
+            $DB->delete_records_select('message_conversation_actions', $sql, $params);
+
             // Delete the favourite conversations.
             $userlist = new \core_privacy\local\request\approved_userlist($context, 'core_message', $userids);
             \core_favourites\privacy\provider::delete_favourites_for_userlist(
@@ -763,6 +781,9 @@ class provider implements
             // Reuse the $params var because it contains the userid and the conversationids.
             $DB->delete_records_select('message_conversation_members', $sql, $params);
 
+            // Delete any conversation actions.
+            $DB->delete_records_select('message_conversation_actions', $sql, $params);
+
             // Delete the favourite conversations.
             if (empty($contextids) && empty($component) && empty($itemtype) && empty($itemid)) {
                 // Favourites for individual conversations are stored into the user context.
@@ -974,6 +995,20 @@ class provider implements
                 // If the conversation has been favorited by the user, include it in the export.
                 writer::with_context($context)->export_related_data($subcontext, 'starred', (object) $conversationfavourite);
             }
+
+            // Check if the conversation was muted.
+            $params = [
+                'userid' => $userid,
+                'conversationid' => $conversation->id,
+                'action' => \core_message\api::CONVERSATION_ACTION_MUTED
+            ];
+            if ($mca = $DB->get_record('message_conversation_actions', $params)) {
+                $mcatostore = [
+                    'muted' => transform::yesno(true),
+                    'timecreated' => transform::datetime($mca->timecreated),
+                ];
+                writer::with_context($context)->export_related_data($subcontext, 'muted', (object) $mcatostore);
+            }
         }
     }
 
index 994d6eb..1dfff8d 100644 (file)
@@ -20,6 +20,9 @@
  * @package   core_message
  * @copyright 2011 Lancaster University Network Services Limited
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ *
+ * @deprecated since Moodle 3.7 MDL-64495. Please use /admin/message.php instead.
+ * @todo       MDL-64866 This will be deleted in Moodle 4.1.
  */
 require_once(__DIR__ . '/../config.php');
 require_once($CFG->dirroot . '/message/lib.php');
index a779b98..9d10760 100644 (file)
@@ -410,6 +410,125 @@ class core_message_external extends external_api {
         return null;
     }
 
+    /**
+     * Mute conversations parameters description.
+     *
+     * @return external_function_parameters
+     */
+    public static function mute_conversations_parameters() {
+        return new external_function_parameters(
+            [
+                'userid' => new external_value(PARAM_INT, 'The id of the user who is blocking'),
+                'conversationids' => new external_multiple_structure(
+                    new external_value(PARAM_INT, 'id of the conversation', VALUE_REQUIRED)
+                ),
+            ]
+        );
+    }
+
+    /**
+     * Mutes conversations.
+     *
+     * @param int $userid The id of the user who is blocking
+     * @param array $conversationids The list of conversations being muted
+     * @return external_description
+     */
+    public static function mute_conversations(int $userid, array $conversationids) {
+        global $CFG, $USER;
+
+        // Check if messaging is enabled.
+        if (empty($CFG->messaging)) {
+            throw new moodle_exception('disabled', 'message');
+        }
+
+        // Validate context.
+        $context = context_system::instance();
+        self::validate_context($context);
+
+        $params = ['userid' => $userid, 'conversationids' => $conversationids];
+        $params = self::validate_parameters(self::mute_conversations_parameters(), $params);
+
+        $capability = 'moodle/site:manageallmessaging';
+        if (($USER->id != $params['userid']) && !has_capability($capability, $context)) {
+            throw new required_capability_exception($context, $capability, 'nopermissions', '');
+        }
+
+        foreach ($params['conversationids'] as $conversationid) {
+            if (!\core_message\api::is_conversation_muted($params['userid'], $conversationid)) {
+                \core_message\api::mute_conversation($params['userid'], $conversationid);
+            }
+        }
+
+        return [];
+    }
+
+    /**
+     * Mute conversations return description.
+     *
+     * @return external_description
+     */
+    public static function mute_conversations_returns() {
+        return new external_warnings();
+    }
+
+    /**
+     * Unmute conversations parameters description.
+     *
+     * @return external_function_parameters
+     */
+    public static function unmute_conversations_parameters() {
+        return new external_function_parameters(
+            [
+                'userid' => new external_value(PARAM_INT, 'The id of the user who is unblocking'),
+                'conversationids' => new external_multiple_structure(
+                    new external_value(PARAM_INT, 'id of the conversation', VALUE_REQUIRED)
+                ),
+            ]
+        );
+    }
+
+    /**
+     * Unmute conversations.
+     *
+     * @param int $userid The id of the user who is unblocking
+     * @param array $conversationids The list of conversations being muted
+     */
+    public static function unmute_conversations(int $userid, array $conversationids) {
+        global $CFG, $USER;
+
+        // Check if messaging is enabled.
+        if (empty($CFG->messaging)) {
+            throw new moodle_exception('disabled', 'message');
+        }
+
+        // Validate context.
+        $context = context_system::instance();
+        self::validate_context($context);
+
+        $params = ['userid' => $userid, 'conversationids' => $conversationids];
+        $params = self::validate_parameters(self::unmute_conversations_parameters(), $params);
+
+        $capability = 'moodle/site:manageallmessaging';
+        if (($USER->id != $params['userid']) && !has_capability($capability, $context)) {
+            throw new required_capability_exception($context, $capability, 'nopermissions', '');
+        }
+
+        foreach ($params['conversationids'] as $conversationid) {
+            \core_message\api::unmute_conversation($params['userid'], $conversationid);
+        }
+
+        return [];
+    }
+
+    /**
+     * Unmute conversations return description.
+     *
+     * @return external_description
+     */
+    public static function unmute_conversations_returns() {
+        return new external_warnings();
+    }
+
     /**
      * Block user parameters description.
      *
@@ -1123,7 +1242,8 @@ class core_message_external extends external_api {
                 'imageurl' => new external_value(PARAM_URL, 'A link to the conversation picture, if set', VALUE_DEFAULT, null),
                 'type' => new external_value(PARAM_INT, 'The type of the conversation (1=individual,2=group)'),
                 'membercount' => new external_value(PARAM_INT, 'Total number of conversation members'),
-                'isfavourite' => new external_value(PARAM_BOOL, 'If the user marked conversation this conversation as a favourite'),
+                'ismuted' => new external_value(PARAM_BOOL, 'If the user muted this conversation'),
+                'isfavourite' => new external_value(PARAM_BOOL, 'If the user marked this conversation as a favourite'),
                 'isread' => new external_value(PARAM_BOOL, 'If the user has read all messages in the conversation'),
                 'unreadcount' => new external_value(PARAM_INT, 'The number of unread messages in this conversation',
                     VALUE_DEFAULT, null),
index 17af06d..9af97a2 100644 (file)
@@ -36,6 +36,37 @@ defined('MOODLE_INTERNAL') || die();
  */
 class core_message_renderer extends plugin_renderer_base {
 
+    /**
+     * Display the interface to manage both message outputs and default message outputs
+     *
+     * @param  array $allprocessors  array of objects containing all message processors
+     * @param  array $processors  array of objects containing active message processors
+     * @param  array $providers   array of objects containing message providers
+     * @param  array $preferences array of objects containing current preferences
+     * @return string The text to render
+     */
+    public function manage_messageoutput_settings($allprocessors, $processors, $providers, $preferences) {
+        $output = html_writer::start_tag('form', array('id' => 'defaultmessageoutputs', 'method' => 'post'));
+        $output .= html_writer::empty_tag('input', array('type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey()));
+
+        // Add message output processors enabled/disabled and settings.
+        $output .= $this->heading(get_string('messageoutputs', 'message'));
+        $output .= $this->manage_messageoutputs($allprocessors);
+
+        // Add active message output processors settings.
+        $output .= $this->heading(get_string('managemessageoutputs', 'message'));
+        $output .= $this->manage_defaultmessageoutputs($processors, $providers, $preferences);
+
+        $output .= html_writer::start_tag('div', array('class' => 'form-buttons'));
+        $output .= html_writer::empty_tag('input',
+            array('type' => 'submit', 'value' => get_string('savechanges', 'admin'), 'class' => 'form-submit btn btn-primary')
+        );
+        $output .= html_writer::end_tag('div');
+        $output .= html_writer::end_tag('form');
+
+        return $output;
+    }
+
     /**
      * Display the interface to manage message outputs
      *
@@ -43,7 +74,6 @@ class core_message_renderer extends plugin_renderer_base {
      * @return string The text to render
      */
     public function manage_messageoutputs($processors) {
-        global $CFG;
         // Display the current workflows
         $table = new html_table();
         $table->attributes['class'] = 'admintable generaltable';
@@ -61,28 +91,21 @@ class core_message_renderer extends plugin_renderer_base {
             $row = new html_table_row();
             $row->attributes['class'] = 'messageoutputs';
 
-            // Name
             $name = new html_table_cell(get_string('pluginname', 'message_'.$processor->name));
-
-            // Enable
             $enable = new html_table_cell();
-            $enable->attributes['class'] = 'mdl-align';
             if (!$processor->available) {
-                $enable->text = html_writer::nonempty_tag('span', get_string('outputnotavailable', 'message'), array('class' => 'error'));
-            } else if (!$processor->configured) {
-                $enable->text = html_writer::nonempty_tag('span', get_string('outputnotconfigured', 'message'), array('class' => 'error'));
-            } else if ($processor->enabled) {
-                $url = new moodle_url('/admin/message.php', array('disable' => $processor->id, 'sesskey' => sesskey()));
-                $enable->text = html_writer::link($url, $this->output->pix_icon('t/hide', get_string('outputenabled', 'message')));
+                $enable->text = html_writer::nonempty_tag('span', get_string('outputnotavailable', 'message'),
+                    array('class' => 'error')
+                );
             } else {
-                $row->attributes['class'] = 'dimmed_text';
-                $url = new moodle_url('/admin/message.php', array('enable' => $processor->id, 'sesskey' => sesskey()));
-                $enable->text = html_writer::link($url, $this->output->pix_icon('t/show', get_string('outputdisabled', 'message')));
+                $enable->text = html_writer::checkbox($processor->name, $processor->id, $processor->enabled, '',
+                    array('id' => $processor->name)
+                );
             }
             // Settings
             $settings = new html_table_cell();
             if ($processor->available && $processor->hassettings) {
-                $settingsurl = new moodle_url('settings.php', array('section' => 'messagesetting'.$processor->name));
+                $settingsurl = new moodle_url('/admin/settings.php', array('section' => 'messagesetting'.$processor->name));
                 $settings->text = html_writer::link($settingsurl, get_string('settings', 'message'));
             }
 
@@ -101,17 +124,12 @@ class core_message_renderer extends plugin_renderer_base {
      * @return string The text to render
      */
     public function manage_defaultmessageoutputs($processors, $providers, $preferences) {
-        global $CFG;
-
         // Prepare list of options for dropdown menu
         $options = array();
         foreach (array('disallowed', 'permitted', 'forced') as $setting) {
             $options[$setting] = get_string($setting, 'message');
         }
 
-        $output = html_writer::start_tag('form', array('id'=>'defaultmessageoutputs', 'method'=>'post'));
-        $output .= html_writer::empty_tag('input', array('type'=>'hidden', 'name'=>'sesskey', 'value'=>sesskey()));
-
         // Display users outputs table
         $table = new html_table();
         $table->attributes['class'] = 'generaltable';
@@ -195,11 +213,7 @@ class core_message_renderer extends plugin_renderer_base {
             $table->data[] = $row;
         }
 
-        $output .= html_writer::table($table);
-        $output .= html_writer::start_tag('div', array('class' => 'form-buttons'));
-        $output .= html_writer::empty_tag('input', array('type' => 'submit', 'value' => get_string('savechanges','admin'), 'class' => 'form-submit'));
-        $output .= html_writer::end_tag('div');
-        $output .= html_writer::end_tag('form');
+        $output = html_writer::table($table);
         return $output;
     }
 
index 00ea59a..df496af 100644 (file)
@@ -61,6 +61,9 @@
                 <span class="{{^isblocked}}hidden{{/isblocked}}" data-region="contact-icon-blocked">
                     {{#pix}} t/block, core, {{#str}} contactblocked, message {{/str}} {{/pix}}
                 </span>
+                <span class="{{^ismuted}}hidden{{/ismuted}}" data-region="muted-icon-container">
+                    {{#pix}} i/muted, core {{/pix}}
+                </span>
             </div>
             {{#subname}}
                 <p class="m-0 text-truncate">{{.}}</p>
index df7b4db..3d748b6 100644 (file)
@@ -60,6 +60,9 @@
                     <span class="{{^isfavourite}}hidden{{/isfavourite}} ml-1 text-primary" data-region="favourite-icon-container">
                         {{#pix}} i/star-rating, core {{/pix}}
                     </span>
+                    <span class="{{^ismuted}}hidden{{/ismuted}} ml-1 text-primary" data-region="muted-icon-container">
+                        {{#pix}} i/muted, core {{/pix}}
+                    </span>
                 </div>
                 {{#showonlinestatus}}
                     <p class="m-0 font-weight-light text-truncate">
             <a class="dropdown-item {{^isblocked}}hidden{{/isblocked}}" href="#" data-action="request-unblock">
                 {{#str}} unblockuser, core_message {{/str}}
             </a>
+            <a class="dropdown-item {{#ismuted}}hidden{{/ismuted}}" href="#" data-action="confirm-mute">
+                {{#str}} muteconversation, core_message {{/str}}
+            </a>
+            <a class="dropdown-item {{^ismuted}}hidden{{/ismuted}}" href="#" data-action="confirm-unmute">
+                {{#str}} unmuteconversation, core_message {{/str}}
+            </a>
             <a class="dropdown-item" href="#" data-action="request-delete-conversation">
                 {{#str}} deleteconversation, core_message {{/str}}
             </a>
index e2390e5..4422fcd 100644 (file)
@@ -59,6 +59,9 @@
                 <span class="{{^isfavourite}}hidden{{/isfavourite}} ml-1 text-primary" data-region="favourite-icon-container">
                     {{#pix}} i/star-rating, core {{/pix}}
                 </span>
+                <span class="{{^ismuted}}hidden{{/ismuted}} ml-1 text-primary" data-region="muted-icon-container">
+                    {{#pix}} i/muted, core {{/pix}}
+                </span>
             </div>
             {{#showonlinestatus}}
                 <p class="m-0 font-weight-light text-truncate">
index 71a68fa..152a30d 100644 (file)
@@ -59,6 +59,9 @@
                         <span class="{{^isfavourite}}hidden{{/isfavourite}} ml-1 text-primary" data-region="favourite-icon-container">
                             {{#pix}} i/star-rating, core {{/pix}}
                         </span>
+                        <span class="{{^ismuted}}hidden{{/ismuted}} ml-1 text-primary" data-region="muted-icon-container">
+                            {{#pix}} i/muted, core {{/pix}}
+                        </span>
                     </div>
                     <p class="m-0 text-truncate">{{subname}}</p>
                 </div>
                 <a class="dropdown-item {{^isfavourite}}hidden{{/isfavourite}}" href="#" data-action="confirm-unfavourite">
                     {{#str}} removefromfavourites, core_message {{/str}}
                 </a>
+                <a class="dropdown-item {{#ismuted}}hidden{{/ismuted}}" href="#" data-action="confirm-mute">
+                    {{#str}} muteconversation, core_message {{/str}}
+                </a>
+                <a class="dropdown-item {{^ismuted}}hidden{{/ismuted}}" href="#" data-action="confirm-unmute">
+                    {{#str}} unmuteconversation, core_message {{/str}}
+                </a>
             </div>
         </div>
     </div>
index f3f534b..7c7cb7e 100644 (file)
@@ -1302,6 +1302,50 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->assertEquals($expectedmessagetext, $messages[0]->text);
     }
 
+    /**
+     * Test verifying get_conversations identifies if a conversation is muted or not.
+     */
+    public function test_get_conversations_some_muted() {
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+
+        $conversation1 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user2->id]);
+        testhelper::send_fake_message_to_conversation($user1, $conversation1->id, 'Message 1');
+        testhelper::send_fake_message_to_conversation($user2, $conversation1->id, 'Message 2');
+        \core_message\api::mute_conversation($user1->id, $conversation1->id);
+
+        $conversation2 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user3->id]);
+        testhelper::send_fake_message_to_conversation($user1, $conversation2->id, 'Message 1');
+        testhelper::send_fake_message_to_conversation($user2, $conversation2->id, 'Message 2');
+
+        $conversation3 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user1->id, $user2->id]);
+        \core_message\api::mute_conversation($user1->id, $conversation3->id);
+
+        $conversation4 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user1->id, $user3->id]);
+
+        $conversations = \core_message\api::get_conversations($user1->id);
+
+        usort($conversations, function($first, $second){
+            return $first->id > $second->id;
+        });
+
+        $conv1 = array_shift($conversations);
+        $conv2 = array_shift($conversations);
+        $conv3 = array_shift($conversations);
+        $conv4 = array_shift($conversations);
+
+        $this->assertTrue($conv1->ismuted);
+        $this->assertFalse($conv2->ismuted);
+        $this->assertTrue($conv3->ismuted);
+        $this->assertFalse($conv4->ismuted);
+    }
+
     /**
      * Tests retrieving conversations with a limit and offset to ensure pagination works correctly.
      */
@@ -4912,6 +4956,84 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->assertEquals(0, $DB->count_records('message_users_blocked'));
     }
 
+    /**
+     * Test muting a conversation.
+     */
+    public function test_mute_conversation() {
+        global $DB;
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        $conversation = \core_message\api::create_conversation(
+            \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [
+                $user1->id,
+                $user2->id
+            ]
+        );
+        $conversationid = $conversation->id;
+
+        \core_message\api::mute_conversation($user1->id, $conversationid);
+
+        $mutedconversation = $DB->get_records('message_conversation_actions');
+
+        $this->assertCount(1, $mutedconversation);
+
+        $mutedconversation = reset($mutedconversation);
+
+        $this->assertEquals($user1->id, $mutedconversation->userid);
+        $this->assertEquals($conversationid, $mutedconversation->conversationid);
+        $this->assertEquals(\core_message\api::CONVERSATION_ACTION_MUTED, $mutedconversation->action);
+    }
+
+    /**
+     * Test unmuting a conversation.
+     */
+    public function test_unmute_conversation() {
+        global $DB;
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        $conversation = \core_message\api::create_conversation(
+            \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [
+                $user1->id,
+                $user2->id
+            ]
+        );
+        $conversationid = $conversation->id;
+
+        \core_message\api::mute_conversation($user1->id, $conversationid);
+        \core_message\api::unmute_conversation($user1->id, $conversationid);
+
+        $this->assertEquals(0, $DB->count_records('message_conversation_actions'));
+    }
+
+    /**
+     * Test if a conversation is muted.
+     */
+    public function test_is_conversation_muted() {
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        $conversation = \core_message\api::create_conversation(
+            \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [
+                $user1->id,
+                $user2->id
+            ]
+        );
+        $conversationid = $conversation->id;
+
+        $this->assertFalse(\core_message\api::is_conversation_muted($user1->id, $conversationid));
+
+        \core_message\api::mute_conversation($user1->id, $conversationid);
+
+        $this->assertTrue(\core_message\api::is_conversation_muted($user1->id, $conversationid));
+    }
+
     /**
      * Test is contact check.
      */
@@ -5772,6 +5894,32 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         \core_message\api::send_message_to_conversation($user2->id, $ic1->id, 'test', FORMAT_MOODLE);
     }
 
+    /**
+     * Test the get_conversation() function with a muted conversation.
+     */
+    public function test_get_conversation_with_muted_conversation() {
+        $this->resetAfterTest();
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        $this->setUser($user1);
+
+        $conversation = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user2->id]);
+
+        $conversation = \core_message\api::get_conversation($user1->id, $conversation->id);
+
+        $this->assertFalse($conversation->ismuted);
+
+        // Now, mute the conversation.
+        \core_message\api::mute_conversation($user1->id, $conversation->id);
+
+        $conversation = \core_message\api::get_conversation($user1->id, $conversation->id);
+
+        $this->assertTrue($conversation->ismuted);
+    }
+
     /**
      * Data provider for test_get_conversation_counts().
      */
@@ -6291,6 +6439,103 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
             $counts['types'][\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP]);
     }
 
+    public function test_delete_all_conversation_data() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $this->setAdminUser();
+
+        $course1 = $this->getDataGenerator()->create_course();
+        $coursecontext1 = context_course::instance($course1->id);
+
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id);
+
+        $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course1->id, 'enablemessaging' => 1));
+        $group2 = $this->getDataGenerator()->create_group(array('courseid' => $course1->id, 'enablemessaging' => 1));
+
+        // Add users to both groups.
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1->id, 'userid' => $user2->id));
+
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2->id, 'userid' => $user2->id));
+
+        $groupconversation1 = \core_message\api::get_conversation_by_area(
+            'core_group',
+            'groups',
+            $group1->id,
+            $coursecontext1->id
+        );
+
+        $groupconversation2 = \core_message\api::get_conversation_by_area(
+            'core_group',
+            'groups',
+            $group2->id,
+            $coursecontext1->id
+        );
+
+        // Send a few messages.
+        $g1m1 = \core_message\tests\helper::send_fake_message_to_conversation($user1, $groupconversation1->id);
+        $g1m2 = \core_message\tests\helper::send_fake_message_to_conversation($user2, $groupconversation1->id);
+        $g1m3 = \core_message\tests\helper::send_fake_message_to_conversation($user1, $groupconversation1->id);
+        $g1m4 = \core_message\tests\helper::send_fake_message_to_conversation($user2, $groupconversation1->id);
+
+        $g2m1 = \core_message\tests\helper::send_fake_message_to_conversation($user1, $groupconversation2->id);
+        $g2m2 = \core_message\tests\helper::send_fake_message_to_conversation($user2, $groupconversation2->id);
+        $g2m3 = \core_message\tests\helper::send_fake_message_to_conversation($user1, $groupconversation2->id);
+        $g2m4 = \core_message\tests\helper::send_fake_message_to_conversation($user2, $groupconversation2->id);
+
+        // Delete a few messages.
+        \core_message\api::delete_message($user1->id, $g1m1);
+        \core_message\api::delete_message($user1->id, $g1m2);
+        \core_message\api::delete_message($user1->id, $g2m1);
+        \core_message\api::delete_message($user1->id, $g2m2);
+
+        // Mute the conversations.
+        \core_message\api::mute_conversation($user1->id, $groupconversation1->id);
+        \core_message\api::mute_conversation($user1->id, $groupconversation2->id);
+
+        // Now, delete all the data for the group 1 conversation.
+        \core_message\api::delete_all_conversation_data($groupconversation1->id);
+
+        // Confirm group conversation was deleted just for the group 1 conversation.
+        $this->assertEquals(0, $DB->count_records('message_conversations', ['id' => $groupconversation1->id]));
+        $this->assertEquals(1, $DB->count_records('message_conversations', ['id' => $groupconversation2->id]));
+
+        // Confirm conversation members were deleted just for the group 1 conversation.
+        $this->assertEquals(0, $DB->count_records('message_conversation_members', ['conversationid' => $groupconversation1->id]));
+        $this->assertEquals(2, $DB->count_records('message_conversation_members', ['conversationid' => $groupconversation2->id]));
+
+        // Confirm message conversation actions were deleted just for the group 1 conversation.
+        $this->assertEquals(0, $DB->count_records('message_conversation_actions', ['conversationid' => $groupconversation1->id]));
+        $this->assertEquals(1, $DB->count_records('message_conversation_actions', ['conversationid' => $groupconversation2->id]));
+
+        // Confirm message user actions were deleted just for the group 1 conversation.
+        $this->assertEquals(0, $DB->count_records('message_user_actions', ['messageid' => $g1m1]));
+        $this->assertEquals(0, $DB->count_records('message_user_actions', ['messageid' => $g1m2]));
+        $this->assertEquals(0, $DB->count_records('message_user_actions', ['messageid' => $g1m3]));
+        $this->assertEquals(0, $DB->count_records('message_user_actions', ['messageid' => $g1m4]));
+        $this->assertEquals(1, $DB->count_records('message_user_actions', ['messageid' => $g2m1]));
+        $this->assertEquals(1, $DB->count_records('message_user_actions', ['messageid' => $g2m2]));
+        $this->assertEquals(0, $DB->count_records('message_user_actions', ['messageid' => $g2m3]));
+        $this->assertEquals(0, $DB->count_records('message_user_actions', ['messageid' => $g2m4]));
+
+        // Confirm messages were deleted just for the group 1 conversation.
+        $this->assertEquals(0, $DB->count_records('messages', ['id' => $g1m1]));
+        $this->assertEquals(0, $DB->count_records('messages', ['id' => $g1m2]));
+        $this->assertEquals(0, $DB->count_records('messages', ['id' => $g1m3]));
+        $this->assertEquals(0, $DB->count_records('messages', ['id' => $g1m4]));
+        $this->assertEquals(1, $DB->count_records('messages', ['id' => $g2m1]));
+        $this->assertEquals(1, $DB->count_records('messages', ['id' => $g2m2]));
+        $this->assertEquals(1, $DB->count_records('messages', ['id' => $g2m3]));
+        $this->assertEquals(1, $DB->count_records('messages', ['id' => $g2m4]));
+    }
+
     /**
      * Comparison function for sorting contacts.
      *
index 8916e1e..41e1722 100644 (file)
@@ -37,6 +37,26 @@ require_once(__DIR__ . '/../../../lib/behat/behat_base.php');
  */
 class behat_message extends behat_base {
 
+    /**
+     * Open the messaging UI.
+     *
+     * @Given /^I open messaging$/
+     */
+    public function i_open_messaging() {
+        // Visit home page and follow messages.
+        $this->execute("behat_general::i_am_on_homepage");
+        $this->execute("behat_general::i_click_on", [get_string('togglemessagemenu', 'core_message'), 'link']);
+    }
+
+    /**
+     * Open the messaging UI.
+     *
+     * @Given /^I open messaging information$/
+     */
+    public function i_open_messaging_information() {
+        $this->execute('behat_general::i_click_on', ["[data-action='view-group-info']", 'css_element']);
+    }
+
     /**
      * View the contact information of a user in the messages ui.
      *
@@ -67,11 +87,7 @@ class behat_message extends behat_base {
      * @param string $userfullname
      */
     public function i_select_user_in_messaging($userfullname) {
-
-        // Visit home page and follow messages.
-        $this->execute("behat_general::i_am_on_homepage");
-
-        $this->execute("behat_general::i_click_on", [get_string('togglemessagemenu', 'core_message'), 'link']);
+        $this->i_open_messaging();
 
         $this->execute('behat_general::i_click_on', [get_string('search', 'core'), 'field']);
 
@@ -100,7 +116,6 @@ class behat_message extends behat_base {
         $this->execute('behat_general::wait_until_the_page_is_ready');
     }
 
-
     /**
      * Sends a message to the specified user from the logged user. The user full name should contain the first and last names.
      *
@@ -140,4 +155,36 @@ class behat_message extends behat_base {
 
         $this->execute("behat_forms::press_button", get_string('send', 'message'));
     }
+
+    /**
+     * Navigate back in the messages ui drawer.
+     *
+     * @Given /^I go back in "(?P<parent_element_string>(?:[^"]|\\")*)" message drawer$/
+     * @param string $parentelement
+     */
+    public function i_go_back_in_message_drawer($parentelement) {
+        $this->execute('behat_general::i_click_on_in_the',
+            array(
+                'a[data-route-back]',
+                'css_element',
+                '[data-region="'.$this->escape($parentelement).'"]',
+                'css_element',
+            )
+        );
+    }
+
+    /**
+     * Select a user in the messaging UI.
+     *
+     * @Given /^I select "(?P<conversation_name_string>(?:[^"]|\\")*)" conversation in messaging$/
+     * @param string $conversationname
+     */
+    public function i_select_conversation_in_messaging($conversationname) {
+        $this->execute('behat_general::i_click_on',
+            array(
+                $this->escape($conversationname),
+                'group_message',
+            )
+        );
+    }
 }
diff --git a/message/tests/behat/group_conversation.feature b/message/tests/behat/group_conversation.feature
new file mode 100644 (file)
index 0000000..c6fce93
--- /dev/null
@@ -0,0 +1,54 @@
+@core @core_message @javascript
+Feature: Create conversations for course's groups
+  In order to manage a course group in a course
+  As a user
+  I need to be able to ensure group conversations reflect the memberships of course groups
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1        | 0        | 1         |
+    And the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student0 | Student   | 0        | student0@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+      | student2 | Student   | 2        | student2@example.com |
+      | student3 | Student   | 3        | student3@example.com |
+      | student4 | Student   | 4        | student4@example.com |
+    And the following "course enrolments" exist:
+      | user     | course | role |
+      | teacher1 | C1     | editingteacher |
+      | student0 | C1     | student |
+      | student1 | C1     | student |
+      | student2 | C1     | student |
+      | student3 | C1     | student |
+      | student4 | C1     | student |
+    And the following "groups" exist:
+      | name    | course | idnumber | enablemessaging |
+      | Group 1 | C1     | G1       | 1               |
+      | Group 2 | C1     | G2       | 1               |
+      | Group 3 | C1     | G3       | 0               |
+    And the following "group members" exist:
+      | user     | group |
+      | teacher1 | G1 |
+      | student0 | G1 |
+      | student1 | G1 |
+      | student2 | G1 |
+      | student3 | G1 |
+      | teacher1 | G2 |
+      | teacher1 | G3 |
+      | student0 | G3 |
+
+  Scenario: Group conversations are restricted to members
+    Given I log in as "teacher1"
+    Then I open messaging
+    And "Group 1" "group_message" should exist
+    And "Group 2" "group_message" should exist
+    And "Group 3" "group_message" should not exist
+    And I log out
+    And I log in as "student1"
+    And I open messaging
+    And "Group 1" "group_message" should exist
+    And "Group 2" "group_message" should not exist
+    And "Group 3" "group_message" should not exist
index 92c2698..d76e1ce 100644 (file)
@@ -1022,6 +1022,168 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         core_message_external::decline_contact_request($user1->id, $user2->id);
     }
 
+    /**
+     * Test muting conversations.
+     */
+    public function test_mute_conversations() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        $conversation = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user2->id]);
+
+        $this->setUser($user1);
+
+        // Muting a conversation.
+        $return = core_message_external::mute_conversations($user1->id, [$conversation->id]);
+        $return = external_api::clean_returnvalue(core_message_external::mute_conversations_returns(), $return);
+        $this->assertEquals(array(), $return);
+
+        // Get list of muted conversations.
+        $mca = $DB->get_record('message_conversation_actions', []);
+
+        $this->assertEquals($user1->id, $mca->userid);
+        $this->assertEquals($conversation->id, $mca->conversationid);
+        $this->assertEquals(\core_message\api::CONVERSATION_ACTION_MUTED, $mca->action);
+
+        // Muting a conversation that is already muted.
+        $return = core_message_external::mute_conversations($user1->id, [$conversation->id]);
+        $return = external_api::clean_returnvalue(core_message_external::mute_conversations_returns(), $return);
+        $this->assertEquals(array(), $return);
+
+        $this->assertEquals(1, $DB->count_records('message_conversation_actions'));
+    }
+
+    /**
+     * Test muting a conversation with messaging disabled.
+     */
+    public function test_mute_conversations_messaging_disabled() {
+        global $CFG;
+
+        $this->resetAfterTest();
+
+        // Create some skeleton data just so we can call the WS.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        $conversation = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user2->id]);
+
+        $this->setUser($user1);
+
+        // Disable messaging.
+        $CFG->messaging = 0;
+
+        // Ensure an exception is thrown.
+        $this->expectException('moodle_exception');
+        core_message_external::mute_conversations($user1->id, [$conversation->id]);
+    }
+
+    /**
+     * Test muting a conversation with no permission.
+     */
+    public function test_mute_conversations_no_permission() {
+        $this->resetAfterTest();
+
+        // Create some skeleton data just so we can call the WS.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+
+        $conversation = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user2->id]);
+
+        $this->setUser($user3);
+
+        // Ensure an exception is thrown.
+        $this->expectException('required_capability_exception');
+        core_message_external::mute_conversations($user1->id, [$conversation->id]);
+    }
+
+    /**
+     * Test unmuting conversations.
+     */
+    public function test_unmute_conversations() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        $conversation = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user2->id]);
+
+        $this->setUser($user1);
+
+        // Mute the conversation.
+        \core_message\api::mute_conversation($user1->id, $conversation->id);
+
+        // Unmuting a conversation.
+        $return = core_message_external::unmute_conversations($user1->id, [$conversation->id]);
+        $return = external_api::clean_returnvalue(core_message_external::unmute_conversations_returns(), $return);
+        $this->assertEquals(array(), $return);
+
+        $this->assertEquals(0, $DB->count_records('message_conversation_actions'));
+
+        // Unmuting a conversation which is already unmuted.
+        $return = core_message_external::unmute_conversations($user1->id, [$conversation->id]);
+        $return = external_api::clean_returnvalue(core_message_external::unmute_conversations_returns(), $return);
+        $this->assertEquals(array(), $return);
+
+        $this->assertEquals(0, $DB->count_records('message_conversation_actions'));
+    }
+
+    /**
+     * Test unmuting a conversation with messaging disabled.
+     */
+    public function test_unmute_conversation_messaging_disabled() {
+        global $CFG;
+
+        $this->resetAfterTest();
+
+        // Create some skeleton data just so we can call the WS.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        $conversation = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user2->id]);
+
+        $this->setUser($user1);
+
+        // Disable messaging.
+        $CFG->messaging = 0;
+
+        // Ensure an exception is thrown.
+        $this->expectException('moodle_exception');
+        core_message_external::unmute_conversations($user1->id, [$user2->id]);
+    }
+
+    /**
+     * Test unmuting a conversation with no permission.
+     */
+    public function test_unmute_conversation_no_permission() {
+        $this->resetAfterTest();
+
+        // Create some skeleton data just so we can call the WS.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+
+        $conversation = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user2->id]);
+
+        $this->setUser($user3);
+
+        // Ensure an exception is thrown.
+        $this->expectException('required_capability_exception');
+        core_message_external::unmute_conversations($user1->id, [$conversation->id]);
+    }
+
     /**
      * Test blocking a user.
      */
@@ -5654,6 +5816,55 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         $this->assertNotEmpty($individualmember['contactrequests']);
     }
 
+    /**
+     * Test verifying get_conversations identifies if a conversation is muted or not.
+     */
+    public function test_get_conversations_some_muted() {
+        $this->resetAfterTest();
+
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+
+        $conversation1 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user2->id]);
+        testhelper::send_fake_message_to_conversation($user1, $conversation1->id, 'Message 1');
+        testhelper::send_fake_message_to_conversation($user2, $conversation1->id, 'Message 2');
+        \core_message\api::mute_conversation($user1->id, $conversation1->id);
+
+        $conversation2 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user3->id]);
+        testhelper::send_fake_message_to_conversation($user1, $conversation2->id, 'Message 1');
+        testhelper::send_fake_message_to_conversation($user2, $conversation2->id, 'Message 2');
+
+        $conversation3 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user1->id, $user2->id]);
+        \core_message\api::mute_conversation($user1->id, $conversation3->id);
+
+        $conversation4 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user1->id, $user3->id]);
+
+        $this->setUser($user1);
+        $result = core_message_external::get_conversations($user1->id);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+
+        usort($conversations, function($first, $second){
+            return $first['id'] > $second['id'];
+        });
+
+        $conv1 = array_shift($conversations);
+        $conv2 = array_shift($conversations);
+        $conv3 = array_shift($conversations);
+        $conv4 = array_shift($conversations);
+
+        $this->assertTrue($conv1['ismuted']);
+        $this->assertFalse($conv2['ismuted']);
+        $this->assertTrue($conv3['ismuted']);
+        $this->assertFalse($conv4['ismuted']);
+    }
+
     /**
      * Test returning members in a conversation with no contact requests.
      */
index 425c425..6fc2436 100644 (file)
@@ -47,7 +47,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $collection = new collection('core_message');
         $newcollection = provider::get_metadata($collection);
         $itemcollection = $newcollection->get_collection();
-        $this->assertCount(9, $itemcollection);
+        $this->assertCount(10, $itemcollection);
 
         $messagestable = array_shift($itemcollection);
         $this->assertEquals('messages', $messagestable->get_name());
@@ -58,6 +58,9 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $messageconversationmemberstable = array_shift($itemcollection);
         $this->assertEquals('message_conversation_members', $messageconversationmemberstable->get_name());
 
+        $messageconversationactions = array_shift($itemcollection);
+        $this->assertEquals('message_conversation_actions', $messageconversationactions->get_name());
+
         $messagecontacts = array_shift($itemcollection);
         $this->assertEquals('message_contacts', $messagecontacts->get_name());
 
@@ -1584,6 +1587,10 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         \core_message\api::set_favourite_conversation($conversation1->id, $user1->id);
         \core_message\api::set_favourite_conversation($iconversation1id, $user2->id);
 
+        // Mute some conversations.
+        \core_message\api::mute_conversation($user1->id, $conversation1->id);
+        \core_message\api::mute_conversation($user2->id, $iconversation1id);
+
         // Send some messages to the conversation.
         $m1 = testhelper::send_fake_message_to_conversation($user1, $conversation1->id, 'Message 1', $now + 1);
         $m2 = testhelper::send_fake_message_to_conversation($user1, $conversation1->id, 'Message 2', $now + 2);
@@ -1646,6 +1653,15 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertEquals(transform::datetime($now + 5), $m3->timeread);
         $this->assertArrayNotHasKey('timedeleted', (array) $m3);
 
+        // Confirm the muted group conversation is correct.
+        $mutedconversations = (array) $writer->get_related_data([
+            get_string('messages', 'core_message'),
+            get_string($conversation1->itemtype, $conversation1->component),
+            get_string('privacy:export:conversationprefix', 'core_message') . $conversation1->name
+        ], 'muted');
+        $this->assertCount(2, $mutedconversations);
+        $this->assertEquals(get_string('yes'), $mutedconversations['muted']);
+
         // Confirm the favourite group conversation is correct.
         $favourite = (array) $writer->get_related_data([
             get_string('messages', 'core_message'),
@@ -1707,6 +1723,14 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertEquals('-', $m3->timeread);
         $this->assertArrayNotHasKey('timedeleted', (array) $m3);
 
+        // Confirm the muted group conversation is correct.
+        $mutedconversations = (array) $writer->get_related_data([
+            get_string('messages', 'core_message'),
+            get_string($conversation1->itemtype, $conversation1->component),
+            $conversation1->name
+        ], 'muted');
+        $this->assertCount(0, $mutedconversations);
+
         // Confirm there are no favourite group conversation for user2.
         $favourite = (array) $writer->get_related_data([
             get_string('messages', 'core_message'),
@@ -1822,6 +1846,11 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // Mark as read one of the conversation messages.
         \core_message\api::mark_message_as_read($user1->id, $dbgm3, $now + 5);
 
+        // Mark some conversations as muted by two users.
+        \core_message\api::mute_conversation($user1->id, $iconversation1id);
+        \core_message\api::mute_conversation($user1->id, $conversation1->id);
+        \core_message\api::mute_conversation($user2->id, $conversation1->id);
+
         // There should be 2 contacts.
         $this->assertEquals(2, $DB->count_records('message_contacts'));
 
@@ -1837,6 +1866,9 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be 4 user actions - 3 for reading the message, 1 for deleting.
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
 
+        // There should be 3 muted conversations.
+        $this->assertEquals(3, $DB->count_records('message_conversation_actions'));
+
         // There should be 4 conversations - 2 individual + 2 group.
         $this->assertEquals(4, $DB->count_records('message_conversations'));
 
@@ -1861,7 +1893,10 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be still 2 blocked users.
         $this->assertEquals(2, $DB->count_records('message_users_blocked'));
 
-        // There should be 5 notifications.
+        // There should be 1 muted conversation.
+        $this->assertEquals(1, $DB->count_records('message_conversation_actions'));
+
+        // There should be 3 notifications.
         $this->assertEquals(5, $DB->count_records('notifications'));
 
         // There should be 5 messages - 3 individual - 2 group (course2).
@@ -1992,6 +2027,11 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
 
         $dbgm3 = $DB->get_record('messages', ['id' => $gm3]);
 
+        // Mark some conversations as muted by two users.
+        \core_message\api::mute_conversation($user1->id, $iconversation1id);
+        \core_message\api::mute_conversation($user1->id, $conversation1->id);
+        \core_message\api::mute_conversation($user2->id, $conversation1->id);
+
         // Mark as read one of the conversation messages.
         \core_message\api::mark_message_as_read($user1->id, $dbgm3, $now + 5);
 
@@ -2010,6 +2050,9 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be 4 user actions - 3 for reading the message, 1 for deleting.
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
 
+        // There should be 3 muted conversations.
+        $this->assertEquals(3, $DB->count_records('message_conversation_actions'));
+
         // There should be 4 conversations - 2 individual + 2 group.
         $this->assertEquals(4, $DB->count_records('message_conversations'));
 
@@ -2031,6 +2074,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertEquals(2, $DB->count_records('message_users_blocked'));
         $this->assertEquals(8, $DB->count_records('messages'));
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
+        $this->assertEquals(3, $DB->count_records('message_conversation_actions'));
         $this->assertEquals(4, $DB->count_records('message_conversations'));
         $this->assertEquals(9, $DB->count_records('message_conversation_members'));
         $this->assertEquals(5, $DB->count_records('notifications'));
@@ -2045,6 +2089,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertEquals(2, $DB->count_records('message_users_blocked'));
         $this->assertEquals(8, $DB->count_records('messages'));
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
+        $this->assertEquals(3, $DB->count_records('message_conversation_actions'));
         $this->assertEquals(4, $DB->count_records('message_conversations'));
         $this->assertEquals(9, $DB->count_records('message_conversation_members'));
         $this->assertEquals(5, $DB->count_records('notifications'));
@@ -2154,6 +2199,11 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // Mark as read one of the conversation messages.
         \core_message\api::mark_message_as_read($user1->id, $dbgm3, $now + 5);
 
+        // Mark some of the conversations as muted by two users.
+        \core_message\api::mute_conversation($user1->id, $iconversation1id);
+        \core_message\api::mute_conversation($user1->id, $conversation1->id);
+        \core_message\api::mute_conversation($user2->id, $conversation1->id);
+
         // There should be 2 contacts.
         $this->assertEquals(2, $DB->count_records('message_contacts'));
 
@@ -2169,6 +2219,9 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be 4 user actions - 3 for reading the message, 1 for deleting.
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
 
+        // There should be 3 muted conversations.
+        $this->assertEquals(3, $DB->count_records('message_conversation_actions'));
+
         // There should be 4 conversations - 2 individual + 2 group.
         $this->assertEquals(4, $DB->count_records('message_conversations'));
 
@@ -2190,6 +2243,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertEquals(2, $DB->count_records('message_users_blocked'));
         $this->assertEquals(8, $DB->count_records('messages'));
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
+        $this->assertEquals(3, $DB->count_records('message_conversation_actions'));
         $this->assertEquals(4, $DB->count_records('message_conversations'));
         $this->assertEquals(9, $DB->count_records('message_conversation_members'));
         $this->assertEquals(5, $DB->count_records('notifications'));
@@ -2204,6 +2258,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertEquals(2, $DB->count_records('message_users_blocked'));
         $this->assertEquals(8, $DB->count_records('messages'));
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
+        $this->assertEquals(3, $DB->count_records('message_conversation_actions'));
         $this->assertEquals(4, $DB->count_records('message_conversations'));
         $this->assertEquals(9, $DB->count_records('message_conversation_members'));
         $this->assertEquals(5, $DB->count_records('notifications'));
@@ -2301,6 +2356,11 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // Mark as read one of the conversation messages.
         \core_message\api::mark_message_as_read($user1->id, $dbm3, $now + 5);
 
+        // Mark some of the conversations as muted by two users.
+        \core_message\api::mute_conversation($user1->id, $iconversation1id);
+        \core_message\api::mute_conversation($user1->id, $conversation1->id);
+        \core_message\api::mute_conversation($user2->id, $conversation1->id);
+
         // There should be 2 contacts.
         $this->assertEquals(2, $DB->count_records('message_contacts'));
 
@@ -2319,6 +2379,9 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be 4 user actions - 3 for reading the message, one for deleting.
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
 
+        // There should be 3 users muting a conversation.
+        $this->assertEquals(3, $DB->count_records('message_conversation_actions'));
+
         // There should be 3 conversations - 2 private + 1 group.
         $this->assertEquals(3, $DB->count_records('message_conversations'));
 
@@ -2347,7 +2410,10 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be still 2 blocked users.
         $this->assertEquals(2, $DB->count_records('message_users_blocked'));
 
-        // There should be 5 notifications.
+        // There should be 2 muted conversation.
+        $this->assertEquals(2, $DB->count_records('message_conversation_actions'));
+
+        // There should be 3 notifications.
         $this->assertEquals(5, $DB->count_records('notifications'));
 
         // There should be 4 messages - 3 private + 1 group sent by user2.
@@ -2484,6 +2550,11 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // Mark as read one of the conversation messages.
         \core_message\api::mark_message_as_read($user1->id, $dbm3, $now + 5);
 
+        // Mark some of the conversations as muted by two users.
+        \core_message\api::mute_conversation($user1->id, $iconversation1id);
+        \core_message\api::mute_conversation($user1->id, $conversation1->id);
+        \core_message\api::mute_conversation($user2->id, $conversation1->id);
+
         // There should be 2 contacts.
         $this->assertEquals(2, $DB->count_records('message_contacts'));
 
@@ -2502,6 +2573,9 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         // There should be 4 user actions - 3 for reading the message, one for deleting.
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
 
+        // There should be 3 muted conversation.
+        $this->assertEquals(3, $DB->count_records('message_conversation_actions'));
+
         // There should be 3 conversations - 2 private + 2 group.
         $this->assertEquals(4, $DB->count_records('message_conversations'));
 
@@ -2529,6 +2603,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertEquals(5, $DB->count_records('notifications'));
         $this->assertEquals(6, $DB->count_records('messages'));
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
+        $this->assertEquals(3, $DB->count_records('message_conversation_actions'));
         $this->assertEquals(4, $DB->count_records('message_conversations'));
         $this->assertEquals(8, $DB->count_records('message_conversation_members'));
         $this->assertEquals(3, $DB->count_records('favourite'));
@@ -2545,6 +2620,7 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         $this->assertEquals(5, $DB->count_records('notifications'));
         $this->assertEquals(6, $DB->count_records('messages'));
         $this->assertEquals(4, $DB->count_records('message_user_actions'));
+        $this->assertEquals(3, $DB->count_records('message_conversation_actions'));
         $this->assertEquals(4, $DB->count_records('message_conversations'));
         $this->assertEquals(3, $DB->count_records('favourite'));
         // There should be 7 conversation members - (2 + 2) private + 3 group.
@@ -2583,6 +2659,9 @@ class core_message_privacy_provider_testcase extends \core_privacy\tests\provide
         }, $useractions);
         $this->assertNotContains($gm3, $useractions);
 
+        // There should be 1 muted conversation.
+        $this->assertEquals(1, $DB->count_records('message_conversation_actions'));
+
         // There should be still 4 conversations - 2 private + 2 group.
         $this->assertEquals(4, $DB->count_records('message_conversations'));
 
index 502b003..ce30779 100644 (file)
@@ -105,4 +105,12 @@ $functions = array(
         'type' => 'read',
         'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE)
     ),
+
+    'mod_forum_get_forum_access_information' => array(
+        'classname'     => 'mod_forum_external',
+        'methodname'    => 'get_forum_access_information',
+        'description'   => 'Return capabilities information for a given forum.',
+        'type'          => 'read',
+        'services'      => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
 );
index a92d3bd..db31cef 100644 (file)
@@ -1189,4 +1189,73 @@ class mod_forum_external extends external_api {
         );
     }
 
+    /**
+     * Describes the parameters for get_forum_access_information.
+     *
+     * @return external_external_function_parameters
+     * @since Moodle 3.7
+     */
+    public static function get_forum_access_information_parameters() {
+        return new external_function_parameters (
+            array(
+                'forumid' => new external_value(PARAM_INT, 'Forum instance id.')
+            )
+        );
+    }
+
+    /**
+     * Return access information for a given forum.
+     *
+     * @param int $forumid forum instance id
+     * @return array of warnings and the access information
+     * @since Moodle 3.7
+     * @throws  moodle_exception
+     */
+    public static function get_forum_access_information($forumid) {
+        global $DB;
+
+        $params = self::validate_parameters(self::get_forum_access_information_parameters(), array('forumid' => $forumid));
+
+        // Request and permission validation.
+        $forum = $DB->get_record('forum', array('id' => $params['forumid']), '*', MUST_EXIST);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+
+        $context = context_module::instance($cm->id);
+        self::validate_context($context);
+
+        $result = array();
+        // Return all the available capabilities.
+        $capabilities = load_capability_def('mod_forum');
+        foreach ($capabilities as $capname => $capdata) {
+            // Get fields like cansubmit so it is consistent with the access_information function implemented in other modules.
+            $field = 'can' . str_replace('mod/forum:', '', $capname);
+            $result[$field] = has_capability($capname, $context);
+        }
+
+        $result['warnings'] = array();
+        return $result;
+    }
+
+    /**
+     * Describes the get_forum_access_information return value.
+     *
+     * @return external_single_structure
+     * @since Moodle 3.7
+     */
+    public static function get_forum_access_information_returns() {
+
+        $structure = array(
+            'warnings' => new external_warnings()
+        );
+
+        $capabilities = load_capability_def('mod_forum');
+        foreach ($capabilities as $capname => $capdata) {
+            // Get fields like cansubmit so it is consistent with the access_information function implemented in other modules.
+            $field = 'can' . str_replace('mod/forum:', '', $capname);
+            $structure[$field] = new external_value(PARAM_BOOL, 'Whether the user has the capability ' . $capname . ' allowed.',
+                VALUE_OPTIONAL);
+        }
+
+        return new external_single_structure($structure);
+    }
 }
index c095db7..7056d79 100644 (file)
@@ -1248,4 +1248,57 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
         $this->assertEquals(2, $posts['ratinginfo']['ratings'][0]['count']);
         $this->assertEquals(($rating1->rating + $rating2->rating) / 2, $posts['ratinginfo']['ratings'][0]['aggregate']);
     }
+
+    /**
+     * Test mod_forum_get_forum_access_information.
+     */
+    public function test_mod_forum_get_forum_access_information() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $student = self::getDataGenerator()->create_user();
+        $course = self::getDataGenerator()->create_course();
+        // Create the forum.
+        $record = new stdClass();
+        $record->course = $course->id;
+        $forum = self::getDataGenerator()->create_module('forum', $record);
+
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+        $this->getDataGenerator()->enrol_user($student->id, $course->id, $studentrole->id, 'manual');
+
+        self::setUser($student);
+        $result = mod_forum_external::get_forum_access_information($forum->id);
+        $result = external_api::clean_returnvalue(mod_forum_external::get_forum_access_information_returns(), $result);
+
+        // Check default values for capabilities.
+        $enabledcaps = array('canviewdiscussion', 'canstartdiscussion', 'canreplypost', 'canviewrating', 'cancreateattachment',
+            'canexportownpost', 'candeleteownpost', 'canallowforcesubscribe');
+
+        unset($result['warnings']);
+        foreach ($result as $capname => $capvalue) {
+            if (in_array($capname, $enabledcaps)) {
+                $this->assertTrue($capvalue);
+            } else {
+                $this->assertFalse($capvalue);
+            }
+        }
+        // Now, unassign some capabilities.
+        unassign_capability('mod/forum:deleteownpost', $studentrole->id);
+        unassign_capability('mod/forum:allowforcesubscribe', $studentrole->id);
+        array_pop($enabledcaps);
+        array_pop($enabledcaps);
+        accesslib_clear_all_caches_for_unit_testing();
+
+        $result = mod_forum_external::get_forum_access_information($forum->id);
+        $result = external_api::clean_returnvalue(mod_forum_external::get_forum_access_information_returns(), $result);
+        unset($result['warnings']);
+        foreach ($result as $capname => $capvalue) {
+            if (in_array($capname, $enabledcaps)) {
+                $this->assertTrue($capvalue);
+            } else {
+                $this->assertFalse($capvalue);
+            }
+        }
+    }
 }
index f915687..73b44fb 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2018120300;       // The current module version (Date: YYYYMMDDXX)
+$plugin->version   = 2018120301;       // The current module version (Date: YYYYMMDDXX)
 $plugin->requires  = 2018112800;       // Requires this Moodle version
 $plugin->component = 'mod_forum';      // Full name of the plugin (used for diagnostics)
index e04459e..9a3baa5 100644 (file)
@@ -2135,37 +2135,43 @@ function mod_quiz_get_fontawesome_icon_map() {
  *
  * @param calendar_event $event
  * @param \core_calendar\action_factory $factory
+ * @param int $userid User id to use for all capability checks, etc. Set to 0 for current user (default).
  * @return \core_calendar\local\event\entities\action_interface|null
  */
 function mod_quiz_core_calendar_provide_event_action(calendar_event $event,
-                                                     \core_calendar\action_factory $factory) {
+                                                     \core_calendar\action_factory $factory,
+                                                     int $userid = 0) {
     global $CFG, $USER;
 
     require_once($CFG->dirroot . '/mod/quiz/locallib.php');
 
-    $cm = get_fast_modinfo($event->courseid)->instances['quiz'][$event->instance];
-    $quizobj = quiz::create($cm->instance, $USER->id);
+    if (empty($userid)) {
+        $userid = $USER->id;
+    }
+
+    $cm = get_fast_modinfo($event->courseid, $userid)->instances['quiz'][$event->instance];
+    $quizobj = quiz::create($cm->instance, $userid);
     $quiz = $quizobj->get_quiz();
 
     // Check they have capabilities allowing them to view the quiz.
-    if (!has_any_capability(array('mod/quiz:reviewmyattempts', 'mod/quiz:attempt'), $quizobj->get_context())) {
+    if (!has_any_capability(['mod/quiz:reviewmyattempts', 'mod/quiz:attempt'], $quizobj->get_context(), $userid)) {
         return null;
     }
 
-    quiz_update_effective_access($quiz, $USER->id);
+    quiz_update_effective_access($quiz, $userid);
 
     // Check if quiz is closed, if so don't display it.
     if (!empty($quiz->timeclose) && $quiz->timeclose <= time()) {
         return null;
     }
 
-    if (!$quizobj->is_participant($USER->id)) {
+    if (!$quizobj->is_participant($userid)) {
         // If the user is not a participant then they have
         // no action to take. This will filter out the events for teachers.
         return null;
     }
 
-    $attempts = quiz_get_user_attempts($quizobj->get_quizid(), $USER->id);
+    $attempts = quiz_get_user_attempts($quizobj->get_quizid(), $userid);
     if (!empty($attempts)) {
         // The student's last attempt is finished.
         return null;
index 32eadeb..bf82227 100644 (file)
@@ -505,7 +505,7 @@ class mod_quiz_lib_testcase extends advanced_testcase {
 
         // Create a course.
         $course = $this->getDataGenerator()->create_course();
-        // Create a teacher and enrol into the course.
+        // Create a student and enrol into the course.
         $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
         // Create a quiz.
         $quiz = $this->getDataGenerator()->create_module('quiz', array('course' => $course->id,
@@ -513,7 +513,7 @@ class mod_quiz_lib_testcase extends advanced_testcase {
 
         // Create a calendar event.
         $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_OPEN);
-        // Now, log in as teacher.
+        // Now, log in as student.
         $this->setUser($student);
         // Create an action factory.
         $factory = new \core_calendar\action_factory();
@@ -529,6 +529,36 @@ class mod_quiz_lib_testcase extends advanced_testcase {
         $this->assertTrue($actionevent->is_actionable());
     }
 
+    public function test_quiz_core_calendar_provide_event_action_open_for_user() {
+        $this->resetAfterTest();
+
+        $this->setAdminUser();
+
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course();
+        // Create a student and enrol into the course.
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        // Create a quiz.
+        $quiz = $this->getDataGenerator()->create_module('quiz', array('course' => $course->id,
+            'timeopen' => time() - DAYSECS, 'timeclose' => time() + DAYSECS));
+
+        // Create a calendar event.
+        $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_OPEN);
+
+        // Create an action factory.
+        $factory = new \core_calendar\action_factory();
+
+        // Decorate action event for the student.
+        $actionevent = mod_quiz_core_calendar_provide_event_action($event, $factory, $student->id);
+
+        // Confirm the event was decorated.
+        $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent);
+        $this->assertEquals(get_string('attemptquiznow', 'quiz'), $actionevent->get_name());
+        $this->assertInstanceOf('moodle_url', $actionevent->get_url());
+        $this->assertEquals(1, $actionevent->get_item_count());
+        $this->assertTrue($actionevent->is_actionable());
+    }
+
     public function test_quiz_core_calendar_provide_event_action_closed() {
         $this->resetAfterTest();
 
@@ -551,6 +581,31 @@ class mod_quiz_lib_testcase extends advanced_testcase {
         $this->assertNull(mod_quiz_core_calendar_provide_event_action($event, $factory));
     }
 
+    public function test_quiz_core_calendar_provide_event_action_closed_for_user() {
+        $this->resetAfterTest();
+
+        $this->setAdminUser();
+
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course();
+
+        // Create a student.
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        // Create a quiz.
+        $quiz = $this->getDataGenerator()->create_module('quiz', array('course' => $course->id,
+            'timeclose' => time() - DAYSECS));
+
+        // Create a calendar event.
+        $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_CLOSE);
+
+        // Create an action factory.
+        $factory = new \core_calendar\action_factory();
+
+        // Confirm the result was null.
+        $this->assertNull(mod_quiz_core_calendar_provide_event_action($event, $factory, $student->id));
+    }
+
     public function test_quiz_core_calendar_provide_event_action_open_in_future() {
         $this->resetAfterTest();
 
@@ -558,7 +613,7 @@ class mod_quiz_lib_testcase extends advanced_testcase {
 
         // Create a course.
         $course = $this->getDataGenerator()->create_course();
-        // Create a teacher and enrol into the course.
+        // Create a student and enrol into the course.
         $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
         // Create a quiz.
         $quiz = $this->getDataGenerator()->create_module('quiz', array('course' => $course->id,
@@ -566,7 +621,7 @@ class mod_quiz_lib_testcase extends advanced_testcase {
 
         // Create a calendar event.
         $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_CLOSE);
-        // Now, log in as teacher.
+        // Now, log in as student.
         $this->setUser($student);
         // Create an action factory.
         $factory = new \core_calendar\action_factory();
@@ -582,6 +637,36 @@ class mod_quiz_lib_testcase extends advanced_testcase {
         $this->assertFalse($actionevent->is_actionable());
     }
 
+    public function test_quiz_core_calendar_provide_event_action_open_in_future_for_user() {
+        $this->resetAfterTest();
+
+        $this->setAdminUser();
+
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course();
+        // Create a student and enrol into the course.
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        // Create a quiz.
+        $quiz = $this->getDataGenerator()->create_module('quiz', array('course' => $course->id,
+            'timeopen' => time() + DAYSECS));
+
+        // Create a calendar event.
+        $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_CLOSE);
+
+        // Create an action factory.
+        $factory = new \core_calendar\action_factory();
+
+        // Decorate action event for the student.
+        $actionevent = mod_quiz_core_calendar_provide_event_action($event, $factory, $student->id);
+
+        // Confirm the event was decorated.
+        $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent);
+        $this->assertEquals(get_string('attemptquiznow', 'quiz'), $actionevent->get_name());
+        $this->assertInstanceOf('moodle_url', $actionevent->get_url());
+        $this->assertEquals(1, $actionevent->get_item_count());
+        $this->assertFalse($actionevent->is_actionable());
+    }
+
     public function test_quiz_core_calendar_provide_event_action_no_capability() {
         global $DB;
 
@@ -619,6 +704,40 @@ class mod_quiz_lib_testcase extends advanced_testcase {
         $this->assertNull(mod_quiz_core_calendar_provide_event_action($event, $factory));
     }
 
+    public function test_quiz_core_calendar_provide_event_action_no_capability_for_user() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course();
+
+        // Create a student.
+        $student = $this->getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+
+        // Enrol student.
+        $this->assertTrue($this->getDataGenerator()->enrol_user($student->id, $course->id, $studentrole->id));
+
+        // Create a quiz.
+        $quiz = $this->getDataGenerator()->create_module('quiz', array('course' => $course->id));
+
+        // Remove the permission to attempt or review the quiz for the student role.
+        $coursecontext = context_course::instance($course->id);
+        assign_capability('mod/quiz:reviewmyattempts', CAP_PROHIBIT, $studentrole->id, $coursecontext);
+        assign_capability('mod/quiz:attempt', CAP_PROHIBIT, $studentrole->id, $coursecontext);
+
+        // Create a calendar event.
+        $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_OPEN);
+
+        // Create an action factory.
+        $factory = new \core_calendar\action_factory();
+
+        // Confirm null is returned.
+        $this->assertNull(mod_quiz_core_calendar_provide_event_action($event, $factory, $student->id));
+    }
+
     public function test_quiz_core_calendar_provide_event_action_already_finished() {
         global $DB;
 
@@ -674,6 +793,58 @@ class mod_quiz_lib_testcase extends advanced_testcase {
         $this->assertNull(mod_quiz_core_calendar_provide_event_action($event, $factory));
     }
 
+    public function test_quiz_core_calendar_provide_event_action_already_finished_for_user() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $this->setAdminUser();
+
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course();
+
+        // Create a student.
+        $student = $this->getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+
+        // Enrol student.
+        $this->assertTrue($this->getDataGenerator()->enrol_user($student->id, $course->id, $studentrole->id));
+
+        // Create a quiz.
+        $quiz = $this->getDataGenerator()->create_module('quiz', array('course' => $course->id,
+            'sumgrades' => 1));
+
+        // Add a question to the quiz.
+        $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $cat = $questiongenerator->create_question_category();
+        $question = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
+        quiz_add_quiz_question($question->id, $quiz);
+
+        // Get the quiz object.
+        $quizobj = quiz::create($quiz->id, $student->id);
+
+        // Create an attempt for the student in the quiz.
+        $timenow = time();
+        $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $student->id);
+        $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
+        $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
+        quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
+        quiz_attempt_save_started($quizobj, $quba, $attempt);
+
+        // Finish the attempt.
+        $attemptobj = quiz_attempt::create($attempt->id);
+        $attemptobj->process_finish($timenow, false);
+
+        // Create a calendar event.
+        $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_OPEN);
+
+        // Create an action factory.
+        $factory = new \core_calendar\action_factory();
+
+        // Confirm null is returned.
+        $this->assertNull(mod_quiz_core_calendar_provide_event_action($event, $factory, $student->id));
+    }
+
     /**
      * Creates an action event.
      *
index b954724..b4438fc 100644 (file)
@@ -1804,6 +1804,179 @@ function mod_workshop_core_calendar_provide_event_action(calendar_event $event,
     );
 }
 
+/**
+ * This function calculates the minimum and maximum cutoff values for the timestart of
+ * the given event.
+ *
+ * It will return an array with two values, the first being the minimum cutoff value and
+ * the second being the maximum cutoff value. Either or both values can be null, which
+ * indicates there is no minimum or maximum, respectively.
+ *
+ * If a cutoff is required then the function must return an array containing the cutoff
+ * timestamp and error string to display to the user if the cutoff value is violated.
+ *
+ * A minimum and maximum cutoff return value will look like:
+ * [
+ *     [1505704373, 'The date must be after this date'],
+ *     [1506741172, 'The date must be before this date']
+ * ]
+ *
+ * @param calendar_event $event The calendar event to get the time range for
+ * @param stdClass $workshop The module instance to get the range from
+ * @return array Returns an array with min and max date.
+ */
+function mod_workshop_core_calendar_get_valid_event_timestart_range(\calendar_event $event, \stdClass $workshop) : array {
+    $mindate = null;
+    $maxdate = null;
+
+    $phasesubmissionend = max($workshop->submissionstart, $workshop->submissionend);
+    $phaseassessmentstart = min($workshop->assessmentstart, $workshop->assessmentend);
+    if ($phaseassessmentstart == 0) {
+        $phaseassessmentstart = max($workshop->assessmentstart, $workshop->assessmentend);
+    }
+
+    switch ($event->eventtype) {
+        case WORKSHOP_EVENT_TYPE_SUBMISSION_OPEN:
+            if (!empty($workshop->submissionend)) {
+                $maxdate = [
+                    $workshop->submissionend - 1,   // The submissionstart and submissionend cannot be exactly the same.
+                    get_string('submissionendbeforestart', 'mod_workshop')
+                ];
+            } else if ($phaseassessmentstart) {
+                $maxdate = [
+                    $phaseassessmentstart,
+                    get_string('phasesoverlap', 'mod_workshop')
+                ];
+            }
+            break;
+        case WORKSHOP_EVENT_TYPE_SUBMISSION_CLOSE:
+            if (!empty($workshop->submissionstart)) {
+                $mindate = [
+                    $workshop->submissionstart + 1, // The submissionstart and submissionend cannot be exactly the same.
+                    get_string('submissionendbeforestart', 'mod_workshop')
+                ];
+            }
+            if ($phaseassessmentstart) {
+                $maxdate = [
+                    $phaseassessmentstart,
+                    get_string('phasesoverlap', 'mod_workshop')
+                ];
+            }
+            break;
+        case WORKSHOP_EVENT_TYPE_ASSESSMENT_OPEN:
+            if ($phasesubmissionend) {
+                $mindate = [
+                    $phasesubmissionend,
+                    get_string('phasesoverlap', 'mod_workshop')
+                ];
+            }
+            if (!empty($workshop->assessmentend)) {
+                $maxdate = [
+                    $workshop->assessmentend - 1,   // The assessmentstart and assessmentend cannot be exactly the same.
+                    get_string('assessmentendbeforestart', 'mod_workshop')
+                ];
+            }
+            break;
+        case WORKSHOP_EVENT_TYPE_ASSESSMENT_CLOSE:
+            if (!empty($workshop->assessmentstart)) {
+                $mindate = [
+                    $workshop->assessmentstart + 1, // The assessmentstart and assessmentend cannot be exactly the same.
+                    get_string('assessmentendbeforestart', 'mod_workshop')
+                ];
+            } else if ($phasesubmissionend) {
+                $mindate = [
+                    $phasesubmissionend,
+                    get_string('phasesoverlap', 'mod_workshop')
+                ];
+            }
+