Merge branch 'MDL-67504-Master' of https://github.com/tuanngocnguyen/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Thu, 5 Mar 2020 02:46:37 +0000 (10:46 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Thu, 5 Mar 2020 02:46:37 +0000 (10:46 +0800)
68 files changed:
admin/registration/confirmregistration.php
admin/registration/renewregistration.php
admin/settings/server.php
admin/tool/capability/renderer.php
admin/tool/capability/tests/behat/show_contexts.feature [new file with mode: 0644]
admin/tool/cohortroles/db/upgrade.php [new file with mode: 0644]
admin/tool/cohortroles/version.php
admin/tool/dataprivacy/createdatarequest.php
admin/tool/dataprivacy/mydatarequests.php
admin/tool/dataprivacy/resubmitrequest.php
admin/tool/dataprivacy/summary.php
admin/tool/mobile/launch.php
admin/tool/mobile/settings.php
admin/tool/policy/user.php
admin/tool/unsuproles/index.php
auth/email/classes/external.php
badges/classes/assertion.php
badges/classes/badge.php
badges/issuer_json.php [new file with mode: 0644]
course/amd/build/actions.min.js
course/amd/build/actions.min.js.map
course/amd/src/actions.js
course/classes/local/exporters/course_content_item_exporter.php
course/tests/exporters_content_item_test.php
enrol/self/lib.php
files/renderer.php
grade/report/history/classes/helper.php
grade/report/history/classes/output/tablelog.php
grade/report/history/tests/report_test.php
h5p/tests/helper_test.php
lang/en/admin.php
lang/en/repository.php
lib/badgeslib.php
lib/behat/behat_field_manager.php
lib/classes/ip_utils.php
lib/db/upgrade.php
lib/externallib.php
lib/form/filemanager.js
lib/moodlelib.php
lib/outputrenderers.php
lib/plagiarismlib.php
lib/templates/block.mustache
lib/templates/filemanager_page_generallayout.mustache
lib/tests/behat/app_behat_runtime.js
lib/tests/behat/behat_app.php
lib/tests/behat/behat_navigation.php
lib/tests/ip_utils_test.php
mod/forum/classes/task/refresh_forum_post_counts.php
mod/lti/certs.php
repository/draftfiles_ajax.php
repository/filepicker.js
repository/lib.php
repository/tests/behat/delete_files.feature
repository/tests/repositorylib_test.php
theme/boost/amd/build/drawer.min.js
theme/boost/amd/build/drawer.min.js.map
theme/boost/amd/src/drawer.js
theme/boost/scss/moodle/drawer.scss
theme/boost/scss/moodle/filemanager.scss
theme/boost/style/moodle.css
theme/boost/templates/columns2.mustache
theme/classic/classes/output/icon_system_fontawesome.php [new file with mode: 0644]
theme/classic/config.php
theme/classic/style/moodle.css
theme/classic/tests/behat/behat_theme_classic_behat_navigation.php
user/classes/participants_table.php
user/tests/behat/view_participants.feature
webservice/lib.php

index ff99680..6a1ed49 100644 (file)
@@ -45,7 +45,7 @@ $error = optional_param('error', '', PARAM_ALPHANUM);
 
 admin_externalpage_setup('registrationmoodleorg');
 
-if ($url !== HUB_MOODLEORGHUBURL) {
+if (parse_url($url, PHP_URL_HOST) !== parse_url(HUB_MOODLEORGHUBURL, PHP_URL_HOST)) {
     // Allow other plugins to confirm registration on custom hubs. Plugins implementing this
     // callback need to redirect or exit. See https://docs.moodle.org/en/Hub_registration .
     $callbacks = get_plugins_with_function('hub_registration');
index bc05da6..99a2764 100644 (file)
@@ -39,7 +39,7 @@ $token = optional_param('token', '', PARAM_TEXT);
 
 admin_externalpage_setup('registrationmoodleorg');
 
-if ($url !== HUB_MOODLEORGHUBURL) {
+if (parse_url($url, PHP_URL_HOST) !== parse_url(HUB_MOODLEORGHUBURL, PHP_URL_HOST)) {
     // Allow other plugins to renew registration on custom hubs. Plugins implementing this
     // callback need to redirect or exit. See https://docs.moodle.org/en/Hub_registration .
     $callbacks = get_plugins_with_function('hub_registration');
index 0d55217..d846bc6 100644 (file)
@@ -92,7 +92,10 @@ $options = array(
     GETREMOTEADDR_SKIP_HTTP_CLIENT_IP => 'HTTP_X_FORWARDED_FOR, REMOTE_ADDR',
     GETREMOTEADDR_SKIP_HTTP_X_FORWARDED_FOR => 'HTTP_CLIENT, REMOTE_ADDR',
     GETREMOTEADDR_SKIP_HTTP_X_FORWARDED_FOR|GETREMOTEADDR_SKIP_HTTP_CLIENT_IP => 'REMOTE_ADDR');
-$temp->add(new admin_setting_configselect('getremoteaddrconf', new lang_string('getremoteaddrconf', 'admin'), new lang_string('configgetremoteaddrconf', 'admin'), 0, $options));
+$temp->add(new admin_setting_configselect('getremoteaddrconf', new lang_string('getremoteaddrconf', 'admin'),
+    new lang_string('configgetremoteaddrconf', 'admin'),
+    GETREMOTEADDR_SKIP_HTTP_X_FORWARDED_FOR|GETREMOTEADDR_SKIP_HTTP_CLIENT_IP, $options));
+$temp->add(new admin_setting_configtext('reverseproxyignore', new lang_string('reverseproxyignore', 'admin'), new lang_string('configreverseproxyignore', 'admin'), ''));
 
 $temp->add(new admin_setting_heading('webproxy', new lang_string('webproxy', 'admin'), new lang_string('webproxyinfo', 'admin')));
 $temp->add(new admin_setting_configtext('proxyhost', new lang_string('proxyhost', 'admin'), new lang_string('configproxyhost', 'admin'), '', PARAM_HOST));
index 64f8762..59e0f66 100644 (file)
@@ -94,6 +94,7 @@ class tool_capability_renderer extends plugin_renderer_base {
         }
         $table->data = array();
 
+        $childcontextsids = [];
         foreach ($capabilities as $capability) {
             if (empty($capabilitycontexts[$capability])) {
                 $capabilitycontexts[$capability] = tool_capability_calculate_role_data($capability, $roles);
@@ -122,6 +123,10 @@ class tool_capability_renderer extends plugin_renderer_base {
             if (!$onlydiff || count($permissiontypes) > 1) {
                 $table->data[] = $row;
             }
+            if (!empty($contexts[$contextid]->children)) {
+                $childcontextsids = array_merge($childcontextsids, $contexts[$contextid]->children);
+                $childcontextsids = array_unique($childcontextsids);
+            }
         }
 
         // Start the list item, and print the context name as a link to the place to make changes.
@@ -142,8 +147,8 @@ class tool_capability_renderer extends plugin_renderer_base {
             $html .= html_writer::tag('p', get_string('nodifferences', 'tool_capability'));
         }
         // If there are any child contexts, print them recursively.
-        if (!empty($contexts[$contextid]->children)) {
-            foreach ($contexts[$contextid]->children as $childcontextid) {
+        if (!empty($childcontextsids)) {
+            foreach ($childcontextsids as $childcontextid) {
                 $html .= $this->capability_comparison_table($capabilities, $childcontextid, $roles, $onlydiff);
             }
         }
diff --git a/admin/tool/capability/tests/behat/show_contexts.feature b/admin/tool/capability/tests/behat/show_contexts.feature
new file mode 100644 (file)
index 0000000..f61ca9e
--- /dev/null
@@ -0,0 +1,133 @@
+@tool @tool_capability
+Feature: Show capabilities for multiple contexts
+  In order to check roles capabilities
+  As an admin
+  I need to be able to see capability overrides on several contexts
+
+  Background:
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+      | Course 2 | C2        | 0        |
+    And the following "permission overrides" exist:
+      | capability                    | permission | role       | contextlevel | reference |
+      | enrol/category:config         | Allow      | student    | Course       | C1        |
+      | enrol/cohort:unenrol          | Allow      | student    | Course       | C2        |
+    And I log in as "admin"
+    And I navigate to "Users > Permissions > Capability overview" in site administration
+
+  Scenario: Show capabilities table with one capability with overrides
+    When I set the following fields to these values:
+      | Capability: | enrol/category:config |
+      | Roles:      | Student               |
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should see "Permissions in Category: Miscellaneous"
+    And I should see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with one capability without overrides
+    When I set the following fields to these values:
+      | Capability: | enrol/cohort:config |
+      | Roles:      | Student               |
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should not see "Permissions in Category: Miscellaneous"
+    And I should not see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with two capabilities, 1st without overrides and 2nd with
+    When I set the following fields to these values:
+      | Capability: | enrol/category:synchronised, enrol/category:config |
+      | Roles:      | Student                                            |
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should see "Permissions in Category: Miscellaneous"
+    And I should see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with two capabilities, 1st with overrides and 2nd without
+    When I set the following fields to these values:
+      | Capability: | enrol/category:config, enrol/cohort:config |
+      | Roles:      | Student                                    |
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should see "Permissions in Category: Miscellaneous"
+    And I should see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with two capabilities, none with overrides
+    When I set the following fields to these values:
+      | Capability: | enrol/category:synchronised, enrol/cohort:config |
+      | Roles:      | Student                                          |
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should not see "Permissions in Category: Miscellaneous"
+    And I should not see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with capability with override and no role selected
+    When I set the following fields to these values:
+      | Capability: | enrol/category:config |
+      | Roles:      |                       |
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should see "Permissions in Category: Miscellaneous"
+    And I should see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with capability without override and no role selected
+    When I set the following fields to these values:
+      | Capability: | enrol/cohort:config |
+      | Roles:      |                     |
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should not see "Permissions in Category: Miscellaneous"
+    And I should not see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with capability with two overrides on different contexts
+    When I set the following fields to these values:
+      | Capability: | enrol/category:config, enrol/cohort:unenrol |
+      | Roles:      |                                           |
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should see "Permissions in Category: Miscellaneous"
+    And I should see "Permissions in Course: Course 1"
+    And I should see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with capability with override and only diff
+    When I set the following fields to these values:
+      | Capability: | enrol/category:config |
+      | Roles:      | Student, Teacher      |
+    And I set the field "Show differences only" to "1"
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should see "Permissions in Category: Miscellaneous"
+    And I should see "There are no differences to show between selected roles in this context"
+    And I should see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with capability without override and only diff and same capability
+    When I set the following fields to these values:
+      | Capability: | enrol/category:synchronised |
+      | Roles:      | Student, Teacher            |
+    And I set the field "Show differences only" to "1"
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should see "There are no differences to show between selected roles in this context"
+    And I should not see "Permissions in Category: Miscellaneous"
+    And I should not see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
+
+  Scenario: Show capabilities table with two capabilities only one override and only diff checked
+    When I set the following fields to these values:
+      | Capability: | enrol/category:config, enrol/cohort:config |
+      | Roles:      | Student, Teacher                           |
+    And I set the field "Show differences only" to "1"
+    And I click on "Get the overview" "button"
+    Then I should see "Permissions in System"
+    And I should see "Permissions in Category: Miscellaneous"
+    And I should see "There are no differences to show between selected roles in this context"
+    And I should see "Permissions in Course: Course 1"
+    And I should not see "Permissions in Course: Course 2"
diff --git a/admin/tool/cohortroles/db/upgrade.php b/admin/tool/cohortroles/db/upgrade.php
new file mode 100644 (file)
index 0000000..fd228cc
--- /dev/null
@@ -0,0 +1,45 @@
+<?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/>.
+
+/**
+ * Plugin upgrade code
+ *
+ * @package    tool_cohortroles
+ * @copyright  2020 Paul Holden <paulh@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Function to upgrade tool_cohortroles.
+ *
+ * @param int $oldversion the version we are upgrading from
+ * @return bool result
+ */
+function xmldb_tool_cohortroles_upgrade($oldversion) {
+    global $DB;
+
+    if ($oldversion < 2020020600) {
+        // Delete any tool_cohortroles mappings for roles which no longer exist.
+        $DB->delete_records_select('tool_cohortroles', 'roleid NOT IN (SELECT id FROM {role})');
+
+        // Cohortroles savepoint reached.
+        upgrade_plugin_savepoint(true, 2020020600, 'tool', 'cohortroles');
+    }
+
+    return true;
+}
index c8f4c09..c4944b3 100644 (file)
@@ -25,7 +25,7 @@
 defined('MOODLE_INTERNAL') || die();
 
 
-$plugin->version   = 2019111800; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2020020600; // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2019111200; // Requires this Moodle version.
 $plugin->component = 'tool_cohortroles'; // Full name of the plugin (used for diagnostics).
 
index 2124400..c29f197 100644 (file)
@@ -45,13 +45,22 @@ if ($manage) {
     $context = context_system::instance();
     // Make sure the user has the proper capability.
     require_capability('tool/dataprivacy:managedatarequests', $context);
+    navigation_node::override_active_url($returnurl);
 } else {
     // For the case where a user makes request for themselves (or for their children if they are the parent).
     $returnurl = new moodle_url($CFG->wwwroot . '/admin/tool/dataprivacy/mydatarequests.php');
     $context = context_user::instance($USER->id);
 }
+
 $PAGE->set_context($context);
 
+if (!$manage && $profilenode = $PAGE->settingsnav->find('myprofile', null)) {
+    $profilenode->make_active();
+}
+
+$title = get_string('createnewdatarequest', 'tool_dataprivacy');
+$PAGE->navbar->add($title);
+
 // If contactdataprotectionofficer is disabled, send the user back to the profile page, or the privacy policy page.
 // That is, unless you have sufficient capabilities to perform this on behalf of a user.
 if (!$manage && !\tool_dataprivacy\api::can_contact_dpo()) {
@@ -104,7 +113,6 @@ if ($data = $mform->get_data()) {
     redirect($returnurl, $redirectmessage);
 }
 
-$title = get_string('createnewdatarequest', 'tool_dataprivacy');
 $PAGE->set_heading($SITE->fullname);
 $PAGE->set_title($title);
 echo $OUTPUT->header();
index 0a4070a..037e244 100644 (file)
@@ -42,6 +42,13 @@ if (isguestuser()) {
 $usercontext = context_user::instance($USER->id);
 $PAGE->set_context($usercontext);
 
+if ($profilenode = $PAGE->settingsnav->find('myprofile', null)) {
+    $profilenode->make_active();
+}
+
+$title = get_string('datarequests', 'tool_dataprivacy');
+$PAGE->navbar->add($title);
+
 // Return URL.
 $params = ['id' => $USER->id];
 if ($courseid) {
@@ -49,7 +56,6 @@ if ($courseid) {
 }
 $returnurl = new moodle_url('/user/profile.php', $params);
 
-$title = get_string('datarequests', 'tool_dataprivacy');
 $PAGE->set_heading($title);
 $PAGE->set_title($title);
 echo $OUTPUT->header();
index 4f3ef55..721052c 100644 (file)
@@ -27,12 +27,10 @@ require_once('../../../config.php');
 $requestid = required_param('requestid', PARAM_INT);
 $confirm = optional_param('confirm', null, PARAM_INT);
 
-$PAGE->set_url(new moodle_url('/admin/tool/dataprivacy/resubmitrequest.php', ['requestid' => $requestid]));
+$url = new moodle_url('/admin/tool/dataprivacy/resubmitrequest.php', ['requestid' => $requestid]);
+$title = get_string('resubmitrequestasnew', 'tool_dataprivacy');
 
-require_login();
-
-$PAGE->set_context(\context_system::instance());
-require_capability('tool/dataprivacy:managedatarequests', $PAGE->context);
+\tool_dataprivacy\page_helper::setup($url, $title, 'datarequests', 'tool/dataprivacy:managedatarequests');
 
 $manageurl = new moodle_url('/admin/tool/dataprivacy/datarequests.php');
 
@@ -53,10 +51,6 @@ if (null !== $confirm && confirm_sesskey()) {
     redirect($manageurl, get_string('resubmittedrequest', 'tool_dataprivacy', $stringparams));
 }
 
-$heading = get_string('resubmitrequest', 'tool_dataprivacy', $stringparams);
-$PAGE->set_title($heading);
-$PAGE->set_heading($heading);
-
 echo $OUTPUT->header();
 
 $confirmstring = get_string('confirmrequestresubmit', 'tool_dataprivacy', $stringparams);
index 3b99ee3..611b5c7 100644 (file)
@@ -34,6 +34,13 @@ $PAGE->set_context($context);
 $PAGE->set_title($title);
 $PAGE->set_heading($SITE->fullname);
 
+// If user is logged in, then use profile navigation in breadcrumbs.
+if ($profilenode = $PAGE->settingsnav->find('myprofile', null)) {
+    $profilenode->make_active();
+}
+
+$PAGE->navbar->add($title);
+
 $output = $PAGE->get_renderer('tool_dataprivacy');
 echo $output->header();
 $summarypage = new \tool_dataprivacy\output\summary_page();
index 2c5fc18..64d3ee8 100644 (file)
@@ -30,10 +30,16 @@ require_once($CFG->libdir . '/externallib.php');
 
 $serviceshortname  = required_param('service',  PARAM_ALPHANUMEXT);
 $passport          = required_param('passport',  PARAM_RAW);    // Passport send from the app to validate the response URL.
-$urlscheme         = optional_param('urlscheme', 'moodlemobile', PARAM_ALPHANUM); // The URL scheme the app supports.
+$urlscheme         = optional_param('urlscheme', 'moodlemobile', PARAM_NOTAGS); // The URL scheme the app supports.
 $confirmed         = optional_param('confirmed', false, PARAM_BOOL);  // If we are being redirected after user confirmation.
 $oauthsso          = optional_param('oauthsso', 0, PARAM_INT); // Id of the OpenID issuer (for OAuth direct SSO).
 
+// Validate that the urlscheme is valid.
+if (!preg_match('/^[a-zA-Z][a-zA-Z0-9-\+\.]*$/', $urlscheme)) {
+    throw new moodle_exception('Invalid parameter: the value of urlscheme isn\'t valid. ' .
+            'It should start with a letter and can only contain letters, numbers and the characters "." "+" "-".');
+}
+
 // Check web services enabled.
 if (!$CFG->enablewebservices) {
     throw new moodle_exception('enablewsdescription', 'webservice');
index 2121e84..a9cdbcf 100644 (file)
@@ -63,7 +63,7 @@ if ($hassiteconfig) {
 
         $temp->add(new admin_setting_configtext('tool_mobile/forcedurlscheme',
                     new lang_string('forcedurlscheme_key', 'tool_mobile'),
-                    new lang_string('forcedurlscheme', 'tool_mobile'), 'moodlemobile', PARAM_ALPHANUM));
+                    new lang_string('forcedurlscheme', 'tool_mobile'), 'moodlemobile', PARAM_NOTAGS));
 
         $temp->add(new admin_setting_configtext('tool_mobile/minimumversion',
                     new lang_string('minimumversion_key', 'tool_mobile'),
index 56b5acb..104d259 100644 (file)
@@ -39,15 +39,29 @@ if ($userid != $USER->id) {
     if (!has_capability('tool/policy:acceptbehalf', $context)) {
         require_capability('tool/policy:viewacceptances', $context);
     }
+
+    $user = core_user::get_user($userid);
+    $PAGE->navigation->extend_for_user($user);
 }
 
+$title = get_string('policiesagreements', 'tool_policy');
+
 $PAGE->set_context($context);
 $PAGE->set_pagelayout('standard');
 $PAGE->set_url(new moodle_url('/admin/tool/policy/user.php', ['userid' => $userid]));
+$PAGE->set_title($title);
+
+if ($userid == $USER->id &&
+        ($profilenode = $PAGE->settingsnav->find('myprofile', null))) {
+
+    $profilenode->make_active();
+}
+
+$PAGE->navbar->add($title);
 
 $output = $PAGE->get_renderer('tool_policy');
 echo $output->header();
-echo $output->heading(get_string('policiesagreements', 'tool_policy'));
+echo $output->heading($title);
 $acceptances = new \tool_policy\output\acceptances($userid, $returnurl);
 echo $output->render($acceptances);
 $PAGE->requires->js_call_amd('tool_policy/acceptmodal', 'getInstance', [context_system::instance()->id]);
index e2522ba..3d6c5e8 100644 (file)
@@ -100,9 +100,9 @@ if (!$problems) {
         $count = $problem->racount;
         $edit = array();
         $aurl = new moodle_url('/admin/roles/define.php', array('roleid'=>$problem->roleid, 'action'=>'edit'));
-        $edit[] = html_writer::link($aurl, $OUTPUT->pix_icon('t/edit', 'core', get_string('edit')));
+        $edit[] = html_writer::link($aurl, $OUTPUT->pix_icon('t/edit', get_string('edit')));
         $aurl = new moodle_url($PAGE->url, array('roleid'=>$problem->roleid, 'contextlevel'=>$problem->contextlevel, 'action'=>'delete'));
-        $edit[] = html_writer::link($aurl, $OUTPUT->pix_icon('t/delete', 'core', get_string('delete')));
+        $edit[] = html_writer::link($aurl, $OUTPUT->pix_icon('t/delete', get_string('delete')));
         $data[] = array($levelname, $rolename, $count, implode('&nbsp;', $edit));
     }
     $table = new html_table();
index 34e9540..782f033 100644 (file)
@@ -146,7 +146,7 @@ class auth_email_external extends external_api {
                     new external_single_structure(
                         array(
                             'id' => new external_value(PARAM_INT, 'Profile field id', VALUE_OPTIONAL),
-                            'shortname' => new external_value(PARAM_ALPHANUM, 'Password policy', VALUE_OPTIONAL),
+                            'shortname' => new external_value(PARAM_ALPHANUMEXT, 'Profile field shortname', VALUE_OPTIONAL),
                             'name' => new external_value(PARAM_TEXT, 'Profield field name', VALUE_OPTIONAL),
                             'datatype' => new external_value(PARAM_ALPHANUMEXT, 'Profield field datatype', VALUE_OPTIONAL),
                             'description' => new external_value(PARAM_RAW, 'Profield field description', VALUE_OPTIONAL),
index 872495e..8920393 100644 (file)
@@ -199,7 +199,7 @@ class core_badges_assertion {
             $class['criteria'] = $this->_url->out(false); // Currently issued badge URL.
             if ($issued) {
                 if ($this->_obversion == OPEN_BADGES_V2) {
-                    $issuerurl = new moodle_url('/badges/badge_json.php', array('id' => $this->get_badge_id(), 'action' => 0));
+                    $issuerurl = new moodle_url('/badges/issuer_json.php', array('id' => $this->get_badge_id()));
                 } else {
                     $issuerurl = new moodle_url('/badges/assertion.php', array('b' => $this->_data->uniquehash, 'action' => 0));
                 }
@@ -233,7 +233,8 @@ class core_badges_assertion {
                     $issuer['email'] = $CFG->badges_defaultissuercontact;
                 }
             } else {
-                $issuer = badges_get_default_issuer();
+                $badge = new badge($this->get_badge_id());
+                $issuer = $badge->get_badge_issuer();
             }
         }
         $this->embed_data_badge_version2($issuer, OPEN_BADGES_V2_TYPE_ISSUER);
@@ -341,8 +342,7 @@ class core_badges_assertion {
                 '/badges/badge_json.php',
                 array('id' => $this->get_badge_id())
             );
-            $issuerurl = new moodle_url('/badges/badge_json.php', array('id' => $this->get_badge_id(), 'action' => 0,
-                'obversion' => $this->_obversion));
+            $issuerurl = new moodle_url('/badges/issuer_json.php', ['id' => $this->get_badge_id()]);
             // For assertion.
             if ($type == OPEN_BADGES_V2_TYPE_ASSERTION) {
                 $json['@context'] = OPEN_BADGES_V2_CONTEXT;
index 7334681..a3dfdba 100644 (file)
@@ -929,12 +929,12 @@ class badge {
      */
     public function get_badge_issuer() {
         $issuer = array();
-        $issuerurl = new moodle_url('/badges/badge_json.php', array('id' => $this->id, 'action' => 0));
         $issuer['name'] = $this->issuername;
         $issuer['url'] = $this->issuerurl;
         $issuer['email'] = $this->issuercontact;
         $issuer['@context'] = OPEN_BADGES_V2_CONTEXT;
-        $issuer['id'] = $this->issuerurl;
+        $issueridurl = new moodle_url('/badges/issuer_json.php', array('id' => $this->id));
+        $issuer['id'] = $issueridurl->out(false);
         $issuer['type'] = OPEN_BADGES_V2_TYPE_ISSUER;
         return $issuer;
     }
diff --git a/badges/issuer_json.php b/badges/issuer_json.php
new file mode 100644 (file)
index 0000000..ebbf74f
--- /dev/null
@@ -0,0 +1,52 @@
+<?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/>.
+
+/**
+ * Serve Issuer JSON for related badge or default Issuer if no badge is defined.
+ *
+ * @package    core
+ * @subpackage badges
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define('AJAX_SCRIPT', true);
+define('NO_MOODLE_COOKIES', true); // No need for a session here.
+
+require_once(__DIR__ . '/../config.php');
+require_once($CFG->libdir . '/badgeslib.php');
+
+
+$id = optional_param('id', null, PARAM_INT);
+
+if (empty($id)) {
+    // Get the default issuer for this site.
+    $json = badges_get_default_issuer();
+} else {
+    // Get the issuer for this badge.
+    $badge = new badge($id);
+    if ($badge->status != BADGE_STATUS_INACTIVE) {
+        $json = $badge->get_badge_issuer();
+    } else {
+        // The badge doen't exist or not accessible for the users.
+        header("HTTP/1.0 410 Gone");
+        $badgeurl = new moodle_url('/badges/issuer_json.php', array('id' => $id));
+        $json = ['id' => $badgeurl->out()];
+        $json['error'] = get_string('error:relatedbadgedoesntexist', 'badges');
+    }
+}
+
+echo $OUTPUT->header();
+echo json_encode($json);
index 1afa46c..fa3aa00 100644 (file)
Binary files a/course/amd/build/actions.min.js and b/course/amd/build/actions.min.js differ
index d16a62f..d15fd1a 100644 (file)
Binary files a/course/amd/build/actions.min.js.map and b/course/amd/build/actions.min.js.map differ
index 1caedcb..c1446ef 100644 (file)
@@ -23,8 +23,8 @@
  * @since      3.3
  */
 define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str', 'core/url', 'core/yui',
-        'core/modal_factory', 'core/modal_events', 'core/key_codes'],
-    function($, ajax, templates, notification, str, url, Y, ModalFactory, ModalEvents, KeyCodes) {
+        'core/modal_factory', 'core/modal_events', 'core/key_codes', 'core/log'],
+    function($, ajax, templates, notification, str, url, Y, ModalFactory, ModalEvents, KeyCodes, log) {
         var CSS = {
             EDITINPROGRESS: 'editinprogress',
             SECTIONDRAGGABLE: 'sectiondraggable',
@@ -341,13 +341,11 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
          * @param {String} image new image name ("i/show", "i/hide", etc.)
          * @param {String} stringname new string for the action menu item
          * @param {String} stringcomponent
-         * @param {String} titlestr not used
-         * @param {String} titlecomponent not used
          * @param {String} newaction new value for data-action attribute of the link
          * @return {Promise} promise which is resolved when the replacement has completed
          */
         var replaceActionItem = function(actionitem, image, stringname,
-                                           stringcomponent, titlestr, titlecomponent, newaction) {
+                                           stringcomponent, newaction) {
 
             var stringRequests = [{key: stringname, component: stringcomponent}];
             // Do not provide an icon with duplicate, different text to the menu item.
@@ -387,11 +385,11 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
                 if (action === 'hide') {
                     sectionElement.addClass('hidden');
                     replaceActionItem(actionItem, 'i/show',
-                        'showfromothers', 'format_' + courseformat, null, null, 'show');
+                        'showfromothers', 'format_' + courseformat, 'show');
                 } else {
                     sectionElement.removeClass('hidden');
                     replaceActionItem(actionItem, 'i/hide',
-                        'hidefromothers', 'format_' + courseformat, null, null, 'hide');
+                        'hidefromothers', 'format_' + courseformat, 'hide');
                 }
                 // Replace the modules with new html (that indicates that they are now hidden or not hidden).
                 if (data.modules !== undefined) {
@@ -408,14 +406,14 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
                     oldActionItem = oldmarker.find(SELECTOR.SECTIONACTIONMENU + ' ' + 'a[data-action=removemarker]');
                 oldmarker.removeClass('current');
                 replaceActionItem(oldActionItem, 'i/marker',
-                    'highlight', 'core', 'markthistopic', 'core', 'setmarker');
+                    'highlight', 'core', 'setmarker');
                 sectionElement.addClass('current');
                 replaceActionItem(actionItem, 'i/marked',
-                    'highlightoff', 'core', 'markedthistopic', 'core', 'removemarker');
+                    'highlightoff', 'core', 'removemarker');
             } else if (action === 'removemarker') {
                 sectionElement.removeClass('current');
                 replaceActionItem(actionItem, 'i/marker',
-                    'highlight', 'core', 'markthistopic', 'core', 'setmarker');
+                    'highlight', 'core', 'setmarker');
             }
         };
 
@@ -610,19 +608,19 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
              *
              * This method can be used by course formats in their listener to the coursesectionedited event
              *
+             * @deprecated since Moodle 3.9
              * @param {JQuery} sectionelement
              * @param {String} selector CSS selector inside the section element, for example "a[data-action=show]"
              * @param {String} image new image name ("i/show", "i/hide", etc.)
              * @param {String} stringname new string for the action menu item
              * @param {String} stringcomponent
-             * @param {String} titlestr string for "title" attribute (if different from stringname)
-             * @param {String} titlecomponent
              * @param {String} newaction new value for data-action attribute of the link
              */
             replaceSectionActionItem: function(sectionelement, selector, image, stringname,
-                                                    stringcomponent, titlestr, titlecomponent, newaction) {
+                                                    stringcomponent, newaction) {
+                log.debug('replaceSectionActionItem() is deprecated and will be removed.');
                 var actionitem = sectionelement.find(SELECTOR.SECTIONACTIONMENU + ' ' + selector);
-                replaceActionItem(actionitem, image, stringname, stringcomponent, titlestr, titlecomponent, newaction);
+                replaceActionItem(actionitem, image, stringname, stringcomponent, newaction);
             }
         };
     });
index 2386446..fb88964 100644 (file)
@@ -131,7 +131,7 @@ class course_content_item_exporter extends exporter {
             'title' => $this->contentitem->get_title()->get_value(),
             'link' => $this->contentitem->get_link()->out(false),
             'icon' => $this->contentitem->get_icon(),
-            'help' => $this->contentitem->get_help(),
+            'help' => format_text($this->contentitem->get_help(), FORMAT_MARKDOWN),
             'archetype' => $this->contentitem->get_archetype(),
             'componentname' => $this->contentitem->get_component_name(),
             'favourite' => $favourite,
index 872ece6..1aa2860 100644 (file)
@@ -65,7 +65,7 @@ class exporters_course_content_item_testcase extends \advanced_testcase {
         $this->assertObjectHasAttribute('icon', $exporteditem);
         $this->assertEquals($exporteditem->icon, $contentitem->get_icon());
         $this->assertObjectHasAttribute('help', $exporteditem);
-        $this->assertEquals($exporteditem->help, $contentitem->get_help());
+        $this->assertEquals($exporteditem->help, format_text($contentitem->get_help(), FORMAT_MARKDOWN));
         $this->assertObjectHasAttribute('archetype', $exporteditem);
         $this->assertEquals($exporteditem->archetype, $contentitem->get_archetype());
         $this->assertObjectHasAttribute('componentname', $exporteditem);
@@ -89,7 +89,8 @@ class exporters_course_content_item_testcase extends \advanced_testcase {
             new \core_course\local\entity\string_title('test_title'),
             new \moodle_url(''),
             '',
-            '',
+            '* First point
+            * Another point',
             MOD_ARCHETYPE_OTHER,
             'core_test'
         );
@@ -109,7 +110,7 @@ class exporters_course_content_item_testcase extends \advanced_testcase {
         $this->assertObjectHasAttribute('icon', $exporteditem);
         $this->assertEquals($exporteditem->icon, $contentitem->get_icon());
         $this->assertObjectHasAttribute('help', $exporteditem);
-        $this->assertEquals($exporteditem->help, $contentitem->get_help());
+        $this->assertEquals($exporteditem->help, format_text($contentitem->get_help(), FORMAT_MARKDOWN));
         $this->assertObjectHasAttribute('archetype', $exporteditem);
         $this->assertEquals($exporteditem->archetype, $contentitem->get_archetype());
         $this->assertObjectHasAttribute('componentname', $exporteditem);
index 65b1440..f5beff8 100644 (file)
@@ -716,7 +716,7 @@ class enrol_self_plugin extends enrol_plugin {
      * @return bool
      */
     public function edit_instance_form($instance, MoodleQuickForm $mform, $context) {
-        global $CFG;
+        global $CFG, $DB;
 
         // Merge these two settings to one value for the single selection element.
         if ($instance->notifyall and $instance->expirynotify) {
index f48feb7..8b6a15e 100644 (file)
@@ -113,13 +113,16 @@ class core_files_renderer extends plugin_renderer_base {
                 array('invalidjson', 'repository'), array('popupblockeddownload', 'repository'),
                 array('unknownoriginal', 'repository'), array('confirmdeletefolder', 'repository'),
                 array('confirmdeletefilewithhref', 'repository'), array('confirmrenamefolder', 'repository'),
-                array('confirmrenamefile', 'repository'), array('newfolder', 'repository'), array('edit', 'moodle')
+                array('confirmrenamefile', 'repository'), array('newfolder', 'repository'), array('edit', 'moodle'),
+                ['nofilesselected', 'repository'], ['confirmdeleteselectedfile', 'repository'],
+                ['selectall', 'moodle'], ['deselectall', 'moodle'], ['selectallornone', 'form'],
             )
         );
         if ($this->page->requires->should_create_one_time_item_now('core_file_managertemplate')) {
             $this->page->requires->js_init_call('M.form_filemanager.set_templates',
                     array($this->filemanager_js_templates()), true, $module);
         }
+        $this->page->requires->js_call_amd('core/checkbox-toggleall', 'init');
         $this->page->requires->js_init_call('M.form_filemanager.init', array($fm->options), true, $module);
 
         // non javascript file manager
index b0a3ff9..00f4c6d 100644 (file)
@@ -128,6 +128,7 @@ class helper {
      * @return array sql and params list
      */
     protected static function get_users_sql_and_params($context, $search = '', $count = false) {
+        global $DB, $USER;
 
         // Fields we need from the user table.
         $extrafields = get_extra_user_fields($context);
@@ -147,13 +148,33 @@ class helper {
             $select = "SELECT DISTINCT $ufields ";
             $orderby = " ORDER BY u.lastname ASC, u.firstname ASC";
         }
+
+        $groupjoinsql = '';
+        $groupwheresql = '';
+        $courseid = $context->instanceid;
+        $groupmode = groups_get_course_groupmode(get_course($courseid));
+
+        // We're only interested in separate groups mode because it's the only group mode that requires the user to be a member of
+        // specific group(s), except when they have the 'moodle/site:accessallgroups' capability.
+        if ($groupmode == SEPARATEGROUPS && !has_capability('moodle/site:accessallgroups', $context)) {
+            // Fetch the groups that the user can see.
+            $groups = groups_get_all_groups($courseid, $USER->id, 0, 'g.id');
+
+            // Add join condition to include users that only belong to the same group as the user.
+            list($insql, $inparams) = $DB->get_in_or_equal(array_keys($groups), SQL_PARAMS_NAMED, 'gid', true, 0);
+            $groupjoinsql = " JOIN {groups_members} gm ON gm.userid = u.id ";
+            $groupwheresql = " AND gm.groupid $insql ";
+            $params = array_merge($params, $inparams);
+        }
+
         $sql = "$select
                  FROM {user} u
                  JOIN {grade_grades_history} ggh ON u.id = ggh.userid
                  JOIN {grade_items} gi ON gi.id = ggh.itemid
-                WHERE $filtersql gi.courseid = :courseid";
+                 $groupjoinsql
+                WHERE $filtersql gi.courseid = :courseid $groupwheresql";
         $sql .= $orderby;
-        $params['courseid'] = $context->instanceid;
+        $params['courseid'] = $courseid;
 
         return array($sql, $params);
     }
@@ -166,18 +187,31 @@ class helper {
      * @return array list of graders.
      */
     public static function get_graders($courseid) {
-        global $DB;
+        global $DB, $USER;
+
+        $groupjoinsql = $groupwheresql = '';
+        $inparams = [];
+        $groupmode = groups_get_course_groupmode(get_course($courseid));
+        if ($groupmode == SEPARATEGROUPS && !has_capability('moodle/site:accessallgroups', \context_course::instance($courseid))) {
+            // Fetch the groups that the user can see.
+            $groups = groups_get_all_groups($courseid, $USER->id, 0, 'g.id');
+            // Add join condition to include users that only belong to the same group as the user.
+            list($insql, $inparams) = $DB->get_in_or_equal(array_keys($groups), SQL_PARAMS_NAMED, 'gid', true, 0);
+            $groupjoinsql = " JOIN {groups_members} gm ON gm.userid = u.id ";
+            $groupwheresql = " AND gm.groupid $insql ";
+        }
 
         $ufields = get_all_user_name_fields(true, 'u');
         $sql = "SELECT u.id, $ufields
                   FROM {user} u
                   JOIN {grade_grades_history} ggh ON ggh.usermodified = u.id
                   JOIN {grade_items} gi ON gi.id = ggh.itemid
-                 WHERE gi.courseid = :courseid
+                 $groupjoinsql
+                 WHERE gi.courseid = :courseid $groupwheresql
               GROUP BY u.id, $ufields
               ORDER BY u.lastname ASC, u.firstname ASC";
 
-        $graders = $DB->get_records_sql($sql, array('courseid' => $courseid));
+        $graders = $DB->get_records_sql($sql, array('courseid' => $courseid) + $inparams);
         $return = array(0 => get_string('allgraders', 'gradereport_history'));
         foreach ($graders as $grader) {
             $return[$grader->id] = fullname($grader);
index 4d9c9fc..5cc89df 100644 (file)
@@ -337,7 +337,7 @@ class tablelog extends \table_sql implements \renderable {
      * @return array containing sql to use and an array of params.
      */
     protected function get_filters_sql_and_params() {
-        global $DB;
+        global $DB, $USER;
 
         $coursecontext = $this->context;
         $filter = 'gi.courseid = :courseid';
@@ -368,6 +368,16 @@ class tablelog extends \table_sql implements \renderable {
             $params += array('grader' => $this->filters->grader);
         }
 
+        // If the course is separate group mode and the current user is not allowed to see all groups make sure
+        // that we display only users from the same groups as current user.
+        $groupmode = get_course($coursecontext->instanceid)->groupmode;
+        if ($groupmode == SEPARATEGROUPS && !has_capability('moodle/site:accessallgroups', $coursecontext)) {
+            $groupids = array_column(groups_get_all_groups($coursecontext->instanceid, $USER->id, 0, 'g.id'), 'id');
+            list($gsql, $gparams) = $DB->get_in_or_equal($groupids, SQL_PARAMS_NAMED, 'gmuparam', true, 0);
+            $filter .= " AND EXISTS (SELECT 1 FROM {groups_members} gmu WHERE gmu.userid=ggh.userid AND gmu.groupid $gsql)";
+            $params += $gparams;
+        }
+
         return array($filter, $params);
     }
 
index 7a3959d..7a58ae1 100644 (file)
@@ -53,6 +53,12 @@ class gradereport_history_report_testcase extends advanced_testcase {
         $u5 = $this->getDataGenerator()->create_user();
         $grader1 = $this->getDataGenerator()->create_user();
         $grader2 = $this->getDataGenerator()->create_user();
+        self::getDataGenerator()->enrol_user($grader1->id, $c1->id, 'teacher');
+        self::getDataGenerator()->enrol_user($grader2->id, $c1->id, 'teacher');
+        self::getDataGenerator()->enrol_user($u2->id, $c1->id, 'student');
+        self::getDataGenerator()->enrol_user($u3->id, $c1->id, 'student');
+        self::getDataGenerator()->enrol_user($u4->id, $c1->id, 'student');
+        self::getDataGenerator()->enrol_user($u5->id, $c1->id, 'student');
 
         // Modules.
         $c1m1 = $this->getDataGenerator()->create_module('assign', array('course' => $c1));
@@ -65,6 +71,8 @@ class gradereport_history_report_testcase extends advanced_testcase {
         $giparams = array('itemtype' => 'mod', 'itemmodule' => 'assign');
         $grades = array();
 
+        $this->setUser($grader1);
+
         $gi = grade_item::fetch($giparams + array('iteminstance' => $c1m1->id));
         $grades['c1m1u1'] = $this->create_grade_history(array('itemid' => $gi->id, 'userid' => $u1->id,
                 'timemodified' => time() - 3600));
@@ -163,6 +171,20 @@ class gradereport_history_report_testcase extends advanced_testcase {
         $this->assertEquals(null, $results[$grades['c2m2u1a']->id]->prevgrade);
         $this->assertEquals($grades['c2m2u1a']->finalgrade, $results[$grades['c2m2u1c']->id]->prevgrade);
         $this->assertEquals($grades['c2m2u1c']->finalgrade, $results[$grades['c2m2u1e']->id]->prevgrade);
+
+        // Put course in separate groups mode, add grader1 and two students to the same group.
+        $c1->groupmode = SEPARATEGROUPS;
+        update_course($c1);
+        $this->assertFalse(has_capability('moodle/site:accessallgroups', \context_course::instance($c1->id)));
+        $g1 = self::getDataGenerator()->create_group(['courseid' => $c1->id, 'name' => 'g1']);
+        self::getDataGenerator()->create_group_member(['groupid' => $g1->id, 'userid' => $grader1->id]);
+        self::getDataGenerator()->create_group_member(['groupid' => $g1->id, 'userid' => $u1->id]);
+        self::getDataGenerator()->create_group_member(['groupid' => $g1->id, 'userid' => $u2->id]);
+        $this->assertEquals(2, $this->get_tablelog_results($c1ctx, array(), true));
+
+        // Grader2 is not in any groups.
+        $this->setUser($grader2);
+        $this->assertEquals(0, $this->get_tablelog_results($c1ctx, array(), true));
     }
 
     /**
@@ -219,6 +241,118 @@ class gradereport_history_report_testcase extends advanced_testcase {
         $this->assertEquals(1, \gradereport_history\helper::get_users_count($c1ctx, 'c'));
     }
 
+    /**
+     * Data provider method for \gradereport_history_report_testcase::test_get_users_with_groups()
+     */
+    public function get_users_provider() {
+        return [
+            'Visible groups, non-editing teacher, not in any group' => [
+                VISIBLEGROUPS, 'teacher', ['g1', 'g2'], ['s1', 's2', 's3', 's4', 's5']
+            ],
+            'Visible groups, non-editing teacher' => [
+                VISIBLEGROUPS, 'teacher', [], ['s1', 's2', 's3', 's4', 's5']
+            ],
+            'Visible groups, editing teacher' => [
+                VISIBLEGROUPS, 'editingteacher', ['g1', 'g2'], ['s1', 's2', 's3', 's4', 's5']
+            ],
+            'Separate groups, non-editing teacher' => [
+                SEPARATEGROUPS, 'teacher', ['g1', 'g2'], ['s1', 's2']
+            ],
+            'Separate groups, non-editing teacher, not in any group' => [
+                SEPARATEGROUPS, 'teacher', [], []
+            ],
+            'Separate groups, non-editing teacher and student share two groups' => [
+                SEPARATEGROUPS, 'teacher', ['g4', 'g5'], ['s5']
+            ],
+            'Separate groups, editing teacher' => [
+                SEPARATEGROUPS, 'editingteacher', ['g1', 'g2'], ['s1', 's2', 's3', 's4', 's5']
+            ],
+        ];
+    }
+
+    /**
+     * Test for helper::get_users() with course group mode set.
+     *
+     * @dataProvider get_users_provider
+     * @param $groupmode
+     * @param $teacherrole
+     * @param $teachergroups
+     * @param $expectedusers
+     */
+    public function test_get_users_with_groups($groupmode, $teacherrole, $teachergroups, $expectedusers) {
+        global $DB;
+        $this->resetAfterTest();
+
+        $generator = $this->getDataGenerator();
+
+        // Create a test course.
+        $course = $generator->create_course(['groupmode' => $groupmode]);
+
+        // Create an assignment module.
+        $assign = $generator->create_module('assign', ['course' => $course]);
+
+        // Fetch roles.
+        $role = $DB->get_record('role', ['shortname' => $teacherrole], '*', MUST_EXIST);
+        $studentrole =  $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+
+        // Create users.
+        $t1 = $generator->create_user(['username' => 't1', 'email' => 't1@example.com']);
+        $s1 = $generator->create_user(['username' => 's1', 'email' => 's1@example.com']);
+        $s2 = $generator->create_user(['username' => 's2', 'email' => 's2@example.com']);
+        $s3 = $generator->create_user(['username' => 's3', 'email' => 's3@example.com']);
+        $s4 = $generator->create_user(['username' => 's4', 'email' => 's4@example.com']);
+        $s5 = $generator->create_user(['username' => 's5', 'email' => 's5@example.com']);
+
+        // Enrol users.
+        $generator->enrol_user($t1->id, $course->id, $role->id);
+        $generator->enrol_user($s1->id, $course->id, $studentrole->id);
+        $generator->enrol_user($s2->id, $course->id, $studentrole->id);
+        $generator->enrol_user($s3->id, $course->id, $studentrole->id);
+        $generator->enrol_user($s4->id, $course->id, $studentrole->id);
+        $generator->enrol_user($s5->id, $course->id, $studentrole->id);
+
+        // Create groups.
+        $groups = [];
+        $groups['g1'] = $generator->create_group(['courseid' => $course->id, 'name' => 'g1']);
+        $groups['g2'] = $generator->create_group(['courseid' => $course->id, 'name' => 'g2']);
+        $groups['g3'] = $generator->create_group(['courseid' => $course->id, 'name' => 'g3']);
+        $groups['g4'] = $generator->create_group(['courseid' => $course->id, 'name' => 'g4']);
+        $groups['g5'] = $generator->create_group(['courseid' => $course->id, 'name' => 'g5']);
+
+        // Add teacher to the assigned groups.
+        foreach ($teachergroups as $groupname) {
+            $group = $groups[$groupname];
+            $generator->create_group_member(['groupid' => $group->id, 'userid' => $t1->id]);
+        }
+
+        // Add students to groups.
+        $generator->create_group_member(['groupid' => $groups['g1']->id, 'userid' => $s1->id]);
+        $generator->create_group_member(['groupid' => $groups['g2']->id, 'userid' => $s2->id]);
+        $generator->create_group_member(['groupid' => $groups['g3']->id, 'userid' => $s3->id]);
+        $generator->create_group_member(['groupid' => $groups['g4']->id, 'userid' => $s5->id]);
+        $generator->create_group_member(['groupid' => $groups['g5']->id, 'userid' => $s5->id]);
+
+        // Creating grade history for the students.
+        $gi = grade_item::fetch(['iteminstance' => $assign->id, 'itemtype' => 'mod', 'itemmodule' => 'assign']);
+        $this->create_grade_history(['itemid' => $gi->id, 'userid' => $s1->id]);
+        $this->create_grade_history(['itemid' => $gi->id, 'userid' => $s2->id]);
+        $this->create_grade_history(['itemid' => $gi->id, 'userid' => $s3->id]);
+        $this->create_grade_history(['itemid' => $gi->id, 'userid' => $s4->id]);
+        $this->create_grade_history(['itemid' => $gi->id, 'userid' => $s5->id]);
+
+        // Log in as the teacher.
+        $this->setUser($t1);
+
+        // Fetch the users.
+        $users = \gradereport_history\helper::get_users(context_course::instance($course->id));
+        // Confirm that the number of users fetched is the same as the count of expected users.
+        $this->assertCount(count($expectedusers), $users);
+        foreach ($users as $user) {
+            // Confirm that each user returned is in the list of expected users.
+            $this->assertTrue(in_array($user->username, $expectedusers));
+        }
+    }
+
     /**
      * Test the get graders helper method.
      */
@@ -228,9 +362,11 @@ class gradereport_history_report_testcase extends advanced_testcase {
         // Making the setup.
         $c1 = $this->getDataGenerator()->create_course();
         $c2 = $this->getDataGenerator()->create_course();
+        $c3 = $this->getDataGenerator()->create_course(['groupmode' => SEPARATEGROUPS]);
 
         $c1m1 = $this->getDataGenerator()->create_module('assign', array('course' => $c1));
         $c2m1 = $this->getDataGenerator()->create_module('assign', array('course' => $c2));
+        $c3m1 = $this->getDataGenerator()->create_module('assign', array('course' => $c3));
 
         // Users.
         $u1 = $this->getDataGenerator()->create_user(array('firstname' => 'Eric', 'lastname' => 'Cartman'));
@@ -238,6 +374,12 @@ class gradereport_history_report_testcase extends advanced_testcase {
         $u3 = $this->getDataGenerator()->create_user(array('firstname' => 'Kyle', 'lastname' => 'Broflovski'));
         $u4 = $this->getDataGenerator()->create_user(array('firstname' => 'Kenny', 'lastname' => 'McCormick'));
 
+        foreach ([$c1, $c2, $c3] as $course) {
+            foreach ([$u1, $u2, $u3, $u4] as $user) {
+                self::getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+            }
+        }
+
         // Creating grade history for some users.
         $gi = grade_item::fetch(array('iteminstance' => $c1m1->id, 'itemtype' => 'mod', 'itemmodule' => 'assign'));
         $this->create_grade_history(array('itemid' => $gi->id, 'userid' => $u1->id, 'usermodified' => $u1->id));
@@ -247,6 +389,10 @@ class gradereport_history_report_testcase extends advanced_testcase {
         $gi = grade_item::fetch(array('iteminstance' => $c2m1->id, 'itemtype' => 'mod', 'itemmodule' => 'assign'));
         $this->create_grade_history(array('itemid' => $gi->id, 'userid' => $u1->id, 'usermodified' => $u4->id));
 
+        $gi = grade_item::fetch(array('iteminstance' => $c3m1->id, 'itemtype' => 'mod', 'itemmodule' => 'assign'));
+        $this->create_grade_history(array('itemid' => $gi->id, 'userid' => $u1->id, 'usermodified' => $u1->id));
+        $this->create_grade_history(array('itemid' => $gi->id, 'userid' => $u2->id, 'usermodified' => $u2->id));
+
         // Checking fetching some users.
         $graders = \gradereport_history\helper::get_graders($c1->id);
         $this->assertCount(4, $graders); // Including "all graders" .
@@ -256,6 +402,17 @@ class gradereport_history_report_testcase extends advanced_testcase {
         $graders = \gradereport_history\helper::get_graders($c2->id);
         $this->assertCount(2, $graders); // Including "all graders" .
         $this->assertArrayHasKey($u4->id, $graders);
+
+        // Third course is in separate groups mode. Only graders from the same group will be returned.
+        $g = self::getDataGenerator()->create_group(['courseid' => $course->id, 'name' => 'g1']);
+        self::getDataGenerator()->create_group_member(['groupid' => $g->id, 'userid' => $u1->id]);
+        self::getDataGenerator()->create_group_member(['groupid' => $g->id, 'userid' => $u2->id]);
+        $this->setUser($u1);
+        $graders = \gradereport_history\helper::get_graders($c3->id);
+        $this->assertCount(3, $graders); // Including "all graders" .
+        $this->setUser($u3);
+        $graders = \gradereport_history\helper::get_graders($c3->id);
+        $this->assertCount(1, $graders); // Including "all graders" .
     }
 
     /**
index 9323757..6158c53 100644 (file)
@@ -259,7 +259,7 @@ class helper_testcase extends \advanced_testcase {
     /**
      * Test the behaviour of can_update_library().
      */
-    public function can_update_library(): void {
+    public function test_can_update_library(): void {
         $this->resetAfterTest();
         $factory = new \core_h5p\factory();
 
index a2c3c11..629745d 100644 (file)
@@ -329,6 +329,7 @@ $string['configrequestedstudentname'] = 'Word for student used in requested cour
 $string['configrequestedstudentsname'] = 'Word for students used in requested courses';
 $string['configrequestedteachername'] = 'Word for teacher used in requested courses';
 $string['configrequestedteachersname'] = 'Word for teachers used in requested courses';
+$string['configreverseproxyignore'] = 'If your server is behind multiple reverse proxies that append to the X-Forwarded-For header then you will need to specify a comma separated list of ip addresses or subnets of the reverse proxies to be ignored in order to find the users correct IP address.';
 $string['configsectioninterface'] = 'Interface';
 $string['configsectionmail'] = 'Mail';
 $string['configsectionmaintenance'] = 'Maintenance';
@@ -1064,6 +1065,7 @@ $string['restorernewroleid'] = 'Restorers\' role in courses';
 $string['restorernewroleid_help'] = 'If the user does not already have the permission to manage the newly restored course, the user is automatically assigned this role and enrolled if necessary. Select "None" if you do not want restorers to be able to manage every restored course.';
 $string['resultfilter'] = 'Filter by result';
 $string['reverseproxy'] = 'Reverse proxy';
+$string['reverseproxyignore'] = 'Ignore reverse proxies';
 $string['riskconfig'] = 'Users could change site configuration and behaviour';
 $string['riskconfigshort'] = 'Configuration risk';
 $string['riskdataloss'] = 'Users could destroy large amounts of content or information';
index c01d589..3485ec6 100644 (file)
@@ -67,6 +67,7 @@ $string['configsyncfiletimeout'] = 'Timeout in seconds for synchronising the ext
 $string['configsyncimagetimeout'] = 'Timeout in seconds for downloading an image file from external repository during synchronisation.';
 $string['confirmdelete'] = 'Are you sure you want to delete the repository {$a}? If you choose "Continue and download", file references to external contents will be downloaded to Moodle. This could take a long time to process.';
 $string['confirmdeletefile'] = 'Are you sure you want to delete this file?';
+$string['confirmdeleteselectedfile'] = 'Are you sure you want to delete the selected {$a} file(s)?';
 $string['confirmrenamefile'] = 'Are you sure you want to rename/move this file? There are {$a} alias/shortcut files that use this file as their source. If you proceed then those aliases will be converted to true copies.';
 $string['confirmdeletefilewithhref'] = 'Are you sure you want to delete this file? There are {$a} alias/shortcut files that use this file as their source. If you proceed then those aliases will be converted to true copies.';
 $string['confirmdeletefolder'] = 'Are you sure you want to delete this folder? All files and subfolders will be deleted.';
@@ -97,6 +98,8 @@ $string['displaytree'] = 'Display folder as file tree';
 $string['download'] = 'Download';
 $string['downloadallfiles'] = 'Download all files';
 $string['downloadfolder'] = 'Download all';
+$string['downloadselected'] = 'Download selected files';
+$string['deleteselected'] = 'Delete selected';
 $string['downloadsucc'] = 'The file has been downloaded successfully';
 $string['draftareanofiles'] = 'Cannot be downloaded because there is no files attached';
 $string['editrepositoryinstance'] = 'Edit repository instance';
@@ -174,6 +177,7 @@ $string['newfoldername'] = 'New folder name';
 $string['noenter'] = 'Nothing entered';
 $string['nofilesattached'] = 'No files attached';
 $string['nofilesavailable'] = 'No files available';
+$string['nofilesselected'] = 'No files selected';
 $string['nomorefiles'] = 'No more attachments allowed';
 $string['nopathselected'] = 'No destination path select yet (double click tree node to select)';
 $string['nopermissiontoaccess'] = 'No permission to access this repository.';
index 940d567..6d58efa 100644 (file)
@@ -871,7 +871,7 @@ function badges_get_default_issuer() {
     global $CFG, $SITE;
 
     $issuer = array();
-    $issuerurl = new moodle_url('/badges/issuer.php');
+    $issuerurl = new moodle_url('/');
     $issuer['name'] = $CFG->badges_defaultissuername;
     if (empty($issuer['name'])) {
         $issuer['name'] = $SITE->fullname ? $SITE->fullname : $SITE->shortname;
@@ -879,7 +879,8 @@ function badges_get_default_issuer() {
     $issuer['url'] = $issuerurl->out(false);
     $issuer['email'] = $CFG->badges_defaultissuercontact;
     $issuer['@context'] = OPEN_BADGES_V2_CONTEXT;
-    $issuer['id'] = $issuerurl->out(false);
+    $issuerid = new moodle_url('/badges/issuer_json.php');
+    $issuer['id'] = $issuerid->out(false);
     $issuer['type'] = OPEN_BADGES_V2_TYPE_ISSUER;
     return $issuer;
 }
index b98f843..8e4e6d4 100644 (file)
@@ -221,20 +221,23 @@ class behat_field_manager {
         }
 
         // If the type is explictly set on the element pointed to by the label - use it.
-        if ($type = $fieldnode->getParent()->getAttribute('data-fieldtype')) {
-            if ($type == 'tags') {
+        $fieldtype = $fieldnode->getAttribute('data-fieldtype');
+        if ($fieldtype) {
+            if ($fieldtype == 'tags') {
                 return 'autocomplete';
             }
-            return $type;
+            return $fieldtype;
         }
 
         if (!empty($fieldnode->find('xpath', '/ancestor::*[@data-passwordunmaskid]'))) {
             return 'passwordunmask';
         }
 
-        // We look for a parent node with 'felement' class.
-        if ($class = $fieldnode->getParent()->getAttribute('class')) {
+        // Fetch the parentnode only once.
+        $parentnode = $fieldnode->getParent();
 
+        // We look for a parent node with 'felement' class.
+        if ($class = $parentnode->getAttribute('class')) {
             if (strstr($class, 'felement') != false) {
                 // Remove 'felement f' from class value.
                 return substr($class, 10);
@@ -246,7 +249,7 @@ class behat_field_manager {
             }
         }
 
-        return self::get_field_node_type($fieldnode->getParent(), $session);
+        return self::get_field_node_type($parentnode, $session);
     }
 
     /**
index 42abcb4..d2e9a3d 100644 (file)
@@ -224,4 +224,25 @@ final class ip_utils {
         }
         return false;
     }
+
+    /**
+     * Is an ip in a given list of subnets?
+     *
+     * @param string $ip - the IP to test against the list
+     * @param string $list - the list of IP subnets
+     * @param string $delim a delimiter of the list
+     * @return bool
+     */
+    public static function is_ip_in_subnet_list($ip, $list, $delim = "\n") {
+        $list = explode($delim, $list);
+        foreach ($list as $line) {
+            $tokens = explode('#', $line);
+            $subnet = trim($tokens[0]);
+            if (address_in_subnet($ip, $subnet)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
 }
index de135c8..6ce9568 100644 (file)
@@ -2106,9 +2106,6 @@ function xmldb_main_upgrade($oldversion) {
     // Put any upgrade step following this.
 
     if ($oldversion < 2019120500.01) {
-        // Delete any tool_cohortroles mappings for roles which no longer exist.
-        $DB->delete_records_select('tool_cohortroles', "roleid NOT IN (SELECT id FROM {role})");
-
         // Delete any role assignments for roles which no longer exist.
         $DB->delete_records_select('role_assignments', "roleid NOT IN (SELECT id FROM {role})");
 
index f7394d0..e6d5e99 100644 (file)
@@ -773,7 +773,8 @@ function external_generate_token($tokentype, $serviceorid, $userid, $contextorid
     if (!empty($iprestriction)) {
         $newtoken->iprestriction = $iprestriction;
     }
-    $newtoken->privatetoken = null;
+    // Generate the private token, it must be transmitted only via https.
+    $newtoken->privatetoken = random_string(64);
     $DB->insert_record('external_tokens', $newtoken);
     return $newtoken->token;
 }
index 7a9a620..d094069 100644 (file)
@@ -269,10 +269,28 @@ M.form_filemanager.init = function(Y, options) {
         is_disabled: function() {
             return this.filemanager.ancestor('.fitem.disabled') != null;
         },
+        getSelectedFiles: function() {
+            var markedFiles = this.filemanager.all('[data-togglegroup=file-selections]:checked');
+            var filenames = [];
+            markedFiles.each(function(item) {
+                var fileinfo = this.options.list.find(function(element) {
+                    return item.getData().fullname == element.fullname;
+                });
+                if (fileinfo && fileinfo != undefined) {
+                    filenames.push({
+                        filepath: fileinfo.filepath,
+                        filename: fileinfo.filename
+                    });
+                }
+            }, this);
+
+            return filenames;
+        },
         setup_buttons: function() {
             var button_download = this.filemanager.one('.fp-btn-download');
             var button_create   = this.filemanager.one('.fp-btn-mkdir');
             var button_addfile  = this.filemanager.one('.fp-btn-add');
+            var buttonDeleteFile = this.filemanager.one('.fp-btn-delete');
 
             // setup 'add file' button
             button_addfile.on('click', this.show_filepicker, this);
@@ -378,11 +396,13 @@ M.form_filemanager.init = function(Y, options) {
                     return;
                 }
                 image_downloading.setStyle('display', 'inline');
+                var filenames = this.getSelectedFiles();
 
                 // perform downloaddir ajax request
                 this.request({
-                    action: 'downloaddir',
+                    action: 'downloadselected',
                     scope: scope,
+                    params: {selected: Y.JSON.stringify(filenames)},
                     callback: function(id, obj, args) {
                         var image_downloading = scope.filemanager.one('.fp-img-downloading');
                         image_downloading.setStyle('display', 'none');
@@ -403,6 +423,43 @@ M.form_filemanager.init = function(Y, options) {
                 });
             }, this);
 
+            buttonDeleteFile.on('click', function(e) {
+                e.preventDefault();
+                var dialogOptions = {};
+                var filenames = this.getSelectedFiles();
+
+                if (!filenames.length) {
+                    this.print_msg(M.util.get_string('nofilesselected', 'repository'), 'error');
+                    return;
+                }
+
+                dialogOptions.scope = this;
+                var params = {
+                    selected: Y.JSON.stringify(filenames)
+                };
+                dialogOptions.header = M.util.get_string('confirm', 'moodle');
+                dialogOptions.message = M.util.get_string('confirmdeleteselectedfile', 'repository', filenames.length);
+                dialogOptions.callbackargs = [params];
+                dialogOptions.callback = function(params) {
+                    this.request({
+                        action: 'deleteselected',
+                        scope: this,
+                        params: params,
+                        callback: function(id, obj, args) {
+                            // Do something here
+                            args.scope.filecount -= params.length;
+                            if (obj && obj.length) {
+                                args.scope.refresh(obj[0]);
+                            }
+                            if (typeof M.core_formchangechecker != 'undefined') {
+                                M.core_formchangechecker.set_form_changed();
+                            }
+                        }
+                    });
+                };
+                this.show_confirm_dialog(dialogOptions);
+            }, this);
+
             this.filemanager.all('.fp-vb-icons,.fp-vb-tree,.fp-vb-details').
                 on('click', function(e) {
                     e.preventDefault();
@@ -569,10 +626,17 @@ M.form_filemanager.init = function(Y, options) {
                 this.viewmode = 1;
                 element_template = Y.Node.create(M.form_filemanager.templates.iconfilename);
             }
+
+            if (this.viewmode == 1 || this.viewmode == 2) {
+                this.filemanager.one('.fp-btn-delete').addClass('d-none');
+            } else {
+                this.filemanager.one('.fp-btn-delete').removeClass('d-none');
+            }
             var options = {
                 viewmode : this.viewmode,
                 appendonly : appendfiles != null,
                 filenode : element_template,
+                disablecheckboxes: false,
                 callbackcontext : this,
                 callback : function(e, node) {
                     if (e.preventDefault) { e.preventDefault(); }
@@ -783,6 +847,7 @@ M.form_filemanager.init = function(Y, options) {
                 node.one('.fp-dlg-butcancel').on('click', handle_cancel, this);
             }
             this.confirm_dlg.dlgopt = dialog_options;
+            this.confirm_dlg.set('headerContent', dialog_options.header);
             this.confirm_dlg_node.one('.fp-dlg-text').setContent(dialog_options.message);
             this.confirm_dlg.show();
         },
index 91852d0..cf2e9fd 100644 (file)
@@ -9170,24 +9170,13 @@ function cleardoubleslashes ($path) {
  * @return bool
  */
 function remoteip_in_list($list) {
-    $inlist = false;
     $clientip = getremoteaddr(null);
 
     if (!$clientip) {
         // Ensure access on cli.
         return true;
     }
-
-    $list = explode("\n", $list);
-    foreach ($list as $line) {
-        $tokens = explode('#', $line);
-        $subnet = trim($tokens[0]);
-        if (address_in_subnet($clientip, $subnet)) {
-            $inlist = true;
-            break;
-        }
-    }
-    return $inlist;
+    return \core\ip_utils::is_ip_in_subnet_list($clientip, $list);
 }
 
 /**
@@ -9215,7 +9204,15 @@ function getremoteaddr($default='0.0.0.0') {
     if (!($variablestoskip & GETREMOTEADDR_SKIP_HTTP_X_FORWARDED_FOR)) {
         if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
             $forwardedaddresses = explode(",", $_SERVER['HTTP_X_FORWARDED_FOR']);
-            $address = $forwardedaddresses[0];
+
+            $forwardedaddresses = array_filter($forwardedaddresses, function($ip) {
+                global $CFG;
+                return !\core\ip_utils::is_ip_in_subnet_list($ip, $CFG->reverseproxyignore, ',');
+            });
+
+            // Multiple proxies can append values to this header including an
+            // untrusted original request header so we must only trust the last ip.
+            $address = end($forwardedaddresses);
 
             if (substr_count($address, ":") > 1) {
                 // Remove port and brackets from IPv6.
index 037ec67..5f90694 100644 (file)
@@ -1745,7 +1745,7 @@ class core_renderer extends renderer_base {
         $id = !empty($bc->attributes['id']) ? $bc->attributes['id'] : uniqid('block-');
         $context = new stdClass();
         $context->skipid = $bc->skipid;
-        $context->blockinstanceid = $bc->blockinstanceid;
+        $context->blockinstanceid = $bc->blockinstanceid ?: uniqid('fakeid-');
         $context->dockable = $bc->dockable;
         $context->id = $id;
         $context->hidden = $bc->collapsible == block_contents::HIDDEN;
index 2632250..b711ac9 100644 (file)
@@ -1,5 +1,4 @@
 <?php
-
 // This file is part of Moodle - http://moodle.org/
 //
 // Moodle is free software: you can redistribute it and/or modify
  * plagiarismlib.php - Contains core Plagiarism related functions.
  *
  * @since Moodle 2.0
- * @package    moodlecore
+ * @package    core
  * @subpackage plagiarism
  * @copyright  2010 Dan Marsden http://danmarsden.com
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-///// GENERIC PLAGIARISM FUNCTIONS ////////////////////////////////////////////////////
-
 if (!defined('MOODLE_INTERNAL')) {
-    die('Direct access to this script is forbidden.');    ///  It must be included from a Moodle page
+    die('Direct access to this script is forbidden.');
 }
 
 /**
@@ -44,7 +41,7 @@ function plagiarism_get_links($linkarray) {
     }
     $plagiarismplugins = plagiarism_load_available_plugins();
     $output = '';
-    foreach($plagiarismplugins as $plugin => $dir) {
+    foreach ($plagiarismplugins as $plugin => $dir) {
         require_once($dir.'/lib.php');
         $plagiarismclass = "plagiarism_plugin_$plugin";
         $plagiarismplugin = new $plagiarismclass;
@@ -69,7 +66,7 @@ function plagiarism_get_file_results($cmid, $userid, $file) {
         return $allresults;
     }
     $plagiarismplugins = plagiarism_load_available_plugins();
-    foreach($plagiarismplugins as $plugin => $dir) {
+    foreach ($plagiarismplugins as $plugin => $dir) {
         require_once($dir.'/lib.php');
         $plagiarismclass = "plagiarism_plugin_$plugin";
         $plagiarismplugin = new $plagiarismclass;
@@ -91,7 +88,7 @@ function plagiarism_save_form_elements($data) {
         return '';
     }
     $plagiarismplugins = plagiarism_load_available_plugins();
-    foreach($plagiarismplugins as $plugin => $dir) {
+    foreach ($plagiarismplugins as $plugin => $dir) {
         require_once($dir.'/lib.php');
         $plagiarismclass = "plagiarism_plugin_$plugin";
         $plagiarismplugin = new $plagiarismclass;
@@ -122,7 +119,7 @@ function plagiarism_get_form_elements_module($mform, $context, $modulename = "")
         return '';
     }
     $plagiarismplugins = plagiarism_load_available_plugins();
-    foreach($plagiarismplugins as $plugin => $dir) {
+    foreach ($plagiarismplugins as $plugin => $dir) {
         require_once($dir.'/lib.php');
         $plagiarismclass = "plagiarism_plugin_$plugin";
         $plagiarismplugin = new $plagiarismclass;
@@ -151,7 +148,7 @@ function plagiarism_update_status($course, $cm) {
     }
     $plagiarismplugins = plagiarism_load_available_plugins();
     $output = '';
-    foreach($plagiarismplugins as $plugin => $dir) {
+    foreach ($plagiarismplugins as $plugin => $dir) {
         require_once($dir.'/lib.php');
         $plagiarismclass = "plagiarism_plugin_$plugin";
         $plagiarismplugin = new $plagiarismclass;
@@ -161,10 +158,10 @@ function plagiarism_update_status($course, $cm) {
 }
 
 /**
-* Function that prints the student disclosure notifying that the files will be checked for plagiarism
-* @param integer $cmid - the cmid of this module
-* @return string
-*/
+ * Function that prints the student disclosure notifying that the files will be checked for plagiarism
+ * @param integer $cmid - the cmid of this module
+ * @return string
+ */
 function plagiarism_print_disclosure($cmid) {
     global $CFG;
     if (empty($CFG->enableplagiarism)) {
@@ -172,7 +169,7 @@ function plagiarism_print_disclosure($cmid) {
     }
     $plagiarismplugins = plagiarism_load_available_plugins();
     $output = '';
-    foreach($plagiarismplugins as $plugin => $dir) {
+    foreach ($plagiarismplugins as $plugin => $dir) {
         require_once($dir.'/lib.php');
         $plagiarismclass = "plagiarism_plugin_$plugin";
         $plagiarismplugin = new $plagiarismclass;
@@ -182,19 +179,35 @@ function plagiarism_print_disclosure($cmid) {
 }
 
 /**
- * helper function - also loads lib file of plagiarism plugin
+ * Helper function - also loads lib file of plagiarism plugin
+ *
+ * @todo MDL-67872 the deprecated code in this function to be removed in Moodle 4.3
  * @return array of available plugins
  */
 function plagiarism_load_available_plugins() {
     global $CFG;
+    static $showndeprecatedmessage = array(); // Only show message once per page load.
+
     if (empty($CFG->enableplagiarism)) {
         return array();
     }
     $plagiarismplugins = core_component::get_plugin_list('plagiarism');
     $availableplugins = array();
-    foreach($plagiarismplugins as $plugin => $dir) {
-        //check this plugin is enabled and a lib file exists.
-        if (get_config('plagiarism', $plugin."_use") && file_exists($dir."/lib.php")) {
+    foreach ($plagiarismplugins as $plugin => $dir) {
+        // Check this plugin is enabled and a lib file exists.
+        if (get_config('plagiarism', $plugin."_use")) {
+            // Deprecated Since Moodle 3.9.
+            $pluginenabled = true;
+            if (empty($showndeprecatedmessage[$plugin])) {
+                $text = 'The setting plagiarism:'.$plugin.'_use is deprecated.';
+                $text .= ' Use plagiarism_' . $plugin . ':enabled instead';
+                debugging($text, DEBUG_DEVELOPER);
+                $showndeprecatedmessage[$plugin] = true;
+            }
+        } else {
+            $pluginenabled = get_config('plagiarism_'.$plugin, 'enabled');
+        }
+        if ($pluginenabled && file_exists($dir."/lib.php")) {
             require_once($dir.'/lib.php');
             $plagiarismclass = "plagiarism_plugin_$plugin";
             if (class_exists($plagiarismclass)) {
index a08aeed..c2f1481 100644 (file)
@@ -1,5 +1,5 @@
 {{!
-    @template theme_boost/block
+    @template core/block
 
     Example context (json):
     {
index 678f40b..7300170 100644 (file)
                     </a>
                 </div>
                 <div class="fp-btn-download">
-                    <a role="button" title="{{#str}}downloadfolder, repository{{/str}}" class="btn btn-secondary btn-sm" href="#">
+                    <a role="button" title="{{#str}}downloadselected, repository{{/str}}" class="btn btn-secondary btn-sm" href="#">
                         {{#pix}}a/download_all{{/pix}}
                     </a>
                 </div>
+                <div class="fp-btn-delete">
+                    <a role="button" title="{{#str}}deleteselected, repository{{/str}}" class="btn btn-secondary btn-sm" href="#">
+                        {{#pix}}i/trash{{/pix}}
+                    </a>
+                </div>
                 <span class="fp-img-downloading">
                     <span class="sr-only">{{#str}}loadinghelp{{/str}}</span>
                     {{#pix}}i/loading_small{{/pix}}
index 888a157..0112157 100644 (file)
@@ -72,7 +72,7 @@
         if (window.M.util.pending_js.length === 1) {
             runAfterEverything(function() {
                 // Check there isn't a spinner...
-                updateSpinner();
+                checkUIBlocked();
 
                 // Only remove it if the pending array is STILL empty after all that.
                 if (window.M.util.pending_js.length === 1) {
         return realOpen.apply(this, arguments);
     };
 
-    var waitingSpinner = false;
+    var waitingBlocked = false;
 
     /**
      * Checks if a loading spinner is present and visible; if so, adds it to the pending array
      * (and if not, removes it).
      */
-    var updateSpinner = function() {
-        var spinner = document.querySelector('span.core-loading-spinner');
-        if (spinner && spinner.offsetParent) {
-            if (!waitingSpinner) {
-                addPending('spinner');
-                waitingSpinner = true;
+    var checkUIBlocked = function() {
+        var blocked = document.querySelector('span.core-loading-spinner, ion-loading, .click-block-active');
+        if (blocked && blocked.offsetParent) {
+            if (!waitingBlocked) {
+                addPending('blocked');
+                waitingBlocked = true;
             }
         } else {
-            if (waitingSpinner) {
-                removePending('spinner');
-                waitingSpinner = false;
+            if (waitingBlocked) {
+                removePending('blocked');
+                waitingBlocked = false;
             }
         }
     };
             setTimeout(pollRecentMutation, 500);
         }
         // Also update the spinner presence if needed.
-        updateSpinner();
+        checkUIBlocked();
     };
 
     // Set listener using the mutation callback.
      * @param {function} process Callback function that handles each matched node
      */
     var findPossibleMatches = function(xpath, process) {
-        var matches = document.evaluate(xpath, document);
+        var select = 'ion-alert, ion-popover, ion-action-sheet, core-ion-tab.show-tab ion-page.show-page, ion-page.show-page, html';
+        var parent = document.querySelector(select);
+        var matches = document.evaluate(xpath, parent || document);
         while (true) {
             var match = matches.iterateNext();
             if (!match) {
index 4996b27..f82ce5d 100644 (file)
@@ -294,7 +294,7 @@ class behat_app extends behat_base {
                         }
                     }
                     throw new DriverException('Moodle app not found in browser');
-                }, false, 30);
+                }, false, 60);
 
         // Run the scripts to install Moodle 'pending' checks.
         $this->getSession()->executeScript(
@@ -312,7 +312,7 @@ class behat_app extends behat_base {
                         return 'mainpage';
                     }
                     throw new DriverException('Moodle app login URL prompt not found');
-                }, behat_base::get_extended_timeout(), 30);
+                }, behat_base::get_extended_timeout(), 60);
 
         // If it's the login page, we automatically fill in the URL and leave it on the user/pass
         // page. If it's the main page, we just leave it there.
index 5276079..002ab61 100644 (file)
@@ -861,7 +861,7 @@ class behat_navigation extends behat_base {
         $linkname = behat_context_helper::escape($lastnode);
         $xpath .= '//a[contains(normalize-space(.), ' . $linkname . ')]';
         if (!$node = $this->getSession()->getPage()->find('xpath', $xpath)) {
-            throw new ElementNotFoundException($this->getSession(), 'Link "' . join(' > ', $nodelist) . '"" not found on the page');
+            throw new ElementNotFoundException($this->getSession(), 'Link "' . join(' > ', $nodelist) . '"');
         }
         $node->click();
         $this->wait_for_pending_js();
@@ -876,7 +876,7 @@ class behat_navigation extends behat_base {
     protected function find_header_administration_menu($mustexist = false) {
         $menuxpath = '//header[@id=\'page-header\']//div[contains(@class,\'moodle-actionmenu\')]';
         if ($mustexist) {
-            $exception = new ElementNotFoundException($this->getSession(), 'Page header administration menu is not found');
+            $exception = new ElementNotFoundException($this->getSession(), 'Page header administration menu');
             $this->find('xpath', $menuxpath, $exception);
         } else if (!$this->getSession()->getPage()->find('xpath', $menuxpath)) {
             return null;
@@ -893,7 +893,7 @@ class behat_navigation extends behat_base {
     protected function find_page_administration_menu($mustexist = false) {
         $menuxpath = '//div[@id=\'region-main-settings-menu\']';
         if ($mustexist) {
-            $exception = new ElementNotFoundException($this->getSession(), 'Page administration menu is not found');
+            $exception = new ElementNotFoundException($this->getSession(), 'Page administration menu');
             $this->find('xpath', $menuxpath, $exception);
         } else if (!$this->getSession()->getPage()->find('xpath', $menuxpath)) {
             return null;
@@ -960,6 +960,6 @@ class behat_navigation extends behat_base {
         }
 
         throw new ElementNotFoundException($this->getSession(),
-                'Link "' . join(' > ', $nodelist) . '" not found in the current page edit menu"');
+                'Link "' . join(' > ', $nodelist) . '" in the current page edit menu"');
     }
 }
index 035f8d5..238d457 100644 (file)
@@ -376,4 +376,32 @@ class core_ip_utils_testcase extends basic_testcase {
             [false, 'trouble.com.au'] // The allowed domain (above) has a space at the front and so will return false.
         ];
     }
+
+    /**
+     * Data provider for test_is_ip_in_subnet_list.
+     *
+     * @return array
+     */
+    public function data_is_ip_in_subnet_list() {
+        return [
+            [true, '1.1.1.1', '1.1.1.1', "\n"],
+            [false, '1.1.1.1', '2.2.2.2', "\n"],
+            [true, '1.1.1.1', "1.1.1.5\n1.1.1.1", "\n"],
+            [true, '1.1.1.1', "1.1.1.5,1.1.1.1", ","],
+        ];
+    }
+
+    /**
+     * Test checking ips against a list of allowed domains.
+     *
+     * @param  bool $expected Expected result
+     * @param  string $ip IP address
+     * @param  string $list list of  IP subnets
+     * @param  string $delim delimiter of list
+     * @dataProvider data_is_ip_in_subnet_list
+     */
+    public function test_is_ip_in_subnet_list($expected, $ip, $list, $delim) {
+        $this->assertEquals($expected, \core\ip_utils::is_ip_in_subnet_list($ip, $list, $delim));
+    }
+
 }
index 7a018af..9ae7eb2 100644 (file)
@@ -54,9 +54,12 @@ class refresh_forum_post_counts extends \core\task\adhoc_task {
     protected function update_null_forum_post_counts(): bool {
         global $CFG, $DB;
 
-        // Default to chunks of 5000 records per run, unless overridden in config.php
+        // Default to chunks of 5000 records per run, unless overridden in config.php.
         $chunksize = $CFG->forumpostcountchunksize ?? 5000;
 
+        // Initialize counter.
+        $recordscount = 0;
+
         $select = 'wordcount IS NULL OR charcount IS NULL';
         $recordset = $DB->get_recordset_select('forum_posts', $select, null, 'discussion', 'id, message', 0, $chunksize);
 
@@ -68,9 +71,9 @@ class refresh_forum_post_counts extends \core\task\adhoc_task {
         foreach ($recordset as $record) {
             \mod_forum\local\entities\post::add_message_counts($record);
             $DB->update_record('forum_posts', $record);
+            $recordscount++;
         }
 
-        $recordscount = count($recordset);
         $recordset->close();
 
         return ($recordscount == $chunksize);
index 4618aa1..7afbbe6 100644 (file)
@@ -37,8 +37,8 @@ $jwk = array();
 $jwk['kty'] = 'RSA';
 $jwk['alg'] = 'RS256';
 $jwk['kid'] = get_config('mod_lti', 'kid');
-$jwk['e'] = strtr(base64_encode($details['rsa']['e']), '+/', '-_');
-$jwk['n'] = strtr(base64_encode($details['rsa']['n']), '+/', '-_');
+$jwk['e'] = rtrim(strtr(base64_encode($details['rsa']['e']), '+/', '-_'), '=');
+$jwk['n'] = rtrim(strtr(base64_encode($details['rsa']['n']), '+/', '-_'), '=');
 $jwk['use'] = 'sig';
 
 $jwks['keys'][] = $jwk;
index dbd9667..328f2d2 100644 (file)
@@ -41,7 +41,7 @@ $action  = required_param('action', PARAM_ALPHA);
 $draftid = required_param('itemid', PARAM_INT);
 $filepath = optional_param('filepath', '/', PARAM_PATH);
 
-$user_context = context_user::instance($USER->id);
+$usercontext = context_user::instance($USER->id);
 
 echo $OUTPUT->header(); // send headers
 
@@ -73,7 +73,7 @@ switch ($action) {
         $newdirname = required_param('newdirname', PARAM_FILE);
 
         $fs = get_file_storage();
-        $fs->create_directory($user_context->id, 'user', 'draft', $draftid, file_correct_filepath(file_correct_filepath($filepath).$newdirname));
+        $fs->create_directory($usercontext->id, 'user', 'draft', $draftid, file_correct_filepath(file_correct_filepath($filepath).$newdirname));
         $return = new stdClass();
         $return->filepath = $filepath;
         echo json_encode($return);
@@ -82,31 +82,28 @@ switch ($action) {
     case 'delete':
         $filename   = required_param('filename', PARAM_FILE);
         $filepath   = required_param('filepath', PARAM_PATH);
-
-        $fs = get_file_storage();
-        $filepath = file_correct_filepath($filepath);
-        $return = new stdClass();
-        if ($stored_file = $fs->get_file($user_context->id, 'user', 'draft', $draftid, $filepath, $filename)) {
-            $parent_path = $stored_file->get_parent_directory()->get_filepath();
-            if ($stored_file->is_directory()) {
-                $files = $fs->get_directory_files($user_context->id, 'user', 'draft', $draftid, $filepath, true);
-                foreach ($files as $file) {
-                    $file->delete();
-                }
-                $stored_file->delete();
-                $return->filepath = $parent_path;
-                echo json_encode($return);
-            } else {
-                if($result = $stored_file->delete()) {
-                    $return->filepath = $parent_path;
-                    echo json_encode($return);
-                } else {
-                    echo json_encode(false);
-                }
-            }
-        } else {
-            echo json_encode(false);
+        $selectedfile = (object)[
+            'filename' => $filename,
+            'filepath' => $filepath
+        ];
+        $return = repository_delete_selected_files($usercontext, 'user', 'draft', $draftid, [$selectedfile]);
+
+        if ($return) {
+            $response = new stdClass();
+            $response->filepath = array_keys($return)[0];
+            echo json_encode($response);
+            die;
         }
+
+        echo json_encode(false);
+        die;
+
+    case 'deleteselected':
+        $selected   = required_param('selected', PARAM_RAW);
+        $return = [];
+        $selectedfiles = json_decode($selected);
+        $return = repository_delete_selected_files($usercontext, 'user', 'draft', $draftid, $selectedfiles);
+        echo (json_encode($return ? array_keys($return) : false));
         die;
 
     case 'setmainfile':
@@ -115,9 +112,9 @@ switch ($action) {
 
         $filepath = file_correct_filepath($filepath);
         // reset sort order
-        file_reset_sortorder($user_context->id, 'user', 'draft', $draftid);
+        file_reset_sortorder($usercontext->id, 'user', 'draft', $draftid);
         // set main file
-        $return = file_set_sortorder($user_context->id, 'user', 'draft', $draftid, $filepath, $filename, 1);
+        $return = file_set_sortorder($usercontext->id, 'user', 'draft', $draftid, $filepath, $filename, 1);
         echo json_encode($return);
         die;
 
@@ -159,7 +156,7 @@ switch ($action) {
         $zipper = get_file_packer('application/zip');
         $fs = get_file_storage();
 
-        $file = $fs->get_file($user_context->id, 'user', 'draft', $draftid, $filepath, '.');
+        $file = $fs->get_file($usercontext->id, 'user', 'draft', $draftid, $filepath, '.');
 
         $parent_path = $file->get_parent_directory()->get_filepath();
 
@@ -167,7 +164,7 @@ switch ($action) {
         $filepath = array_pop($filepath);
         $zipfile = repository::get_unused_filename($draftid, $parent_path, $filepath . '.zip');
 
-        if ($newfile = $zipper->archive_to_storage(array($filepath => $file), $user_context->id, 'user', 'draft', $draftid, $parent_path, $zipfile, $USER->id)) {
+        if ($newfile = $zipper->archive_to_storage([$filepath => $file], $usercontext->id, 'user', 'draft', $draftid, $parent_path, $zipfile, $USER->id)) {
             $return = new stdClass();
             $return->filepath = $parent_path;
             echo json_encode($return);
@@ -175,36 +172,22 @@ switch ($action) {
             echo json_encode(false);
         }
         die;
+    case 'downloadselected':
+        $selected   = required_param('selected', PARAM_RAW);
+        $selectedfiles = json_decode($selected);
+        $return = repository_download_selected_files($usercontext, 'user', 'draft', $draftid, $selectedfiles);
+        echo (json_encode($return));
+        die;
 
     case 'downloaddir':
         $filepath = required_param('filepath', PARAM_PATH);
 
-        $zipper = get_file_packer('application/zip');
-        $fs = get_file_storage();
-        $area = file_get_draft_area_info($draftid, $filepath);
-        if ($area['filecount'] == 0 && $area['foldercount'] == 0) {
-            echo json_encode(false);
-            die;
-        }
-
-        $stored_file = $fs->get_file($user_context->id, 'user', 'draft', $draftid, $filepath, '.');
-        if ($filepath === '/') {
-            $filename = get_string('files').'.zip';
-        } else {
-            $filename = explode('/', trim($filepath, '/'));
-            $filename = array_pop($filename) . '.zip';
-        }
-
-        // archive compressed file to an unused draft area
-        $newdraftitemid = file_get_unused_draft_itemid();
-        if ($newfile = $zipper->archive_to_storage(array('/' => $stored_file), $user_context->id, 'user', 'draft', $newdraftitemid, '/', $filename, $USER->id)) {
-            $return = new stdClass();
-            $return->fileurl  = moodle_url::make_draftfile_url($newdraftitemid, '/', $filename)->out();
-            $return->filepath = $filepath;
-            echo json_encode($return);
-        } else {
-            echo json_encode(false);
-        }
+        $selectedfile = (object)[
+            'filename' => '',
+            'filepath' => $filepath
+        ];
+        $return = repository_download_selected_files($usercontext, 'user', 'draft', $draftid, [$selectedfile]);
+        echo json_encode($return);
         die;
 
     case 'unzip':
@@ -215,15 +198,15 @@ switch ($action) {
 
         $fs = get_file_storage();
 
-        $file = $fs->get_file($user_context->id, 'user', 'draft', $draftid, $filepath, $filename);
+        $file = $fs->get_file($usercontext->id, 'user', 'draft', $draftid, $filepath, $filename);
 
         // Find unused name for directory to extract the archive.
-        $temppath = $fs->get_unused_dirname($user_context->id, 'user', 'draft', $draftid, $filepath. pathinfo($filename, PATHINFO_FILENAME). '/');
+        $temppath = $fs->get_unused_dirname($usercontext->id, 'user', 'draft', $draftid, $filepath. pathinfo($filename, PATHINFO_FILENAME). '/');
         $donotremovedirs = array();
         $doremovedirs = array($temppath);
         // Extract archive and move all files from $temppath to $filepath
-        if ($file->extract_to_storage($zipper, $user_context->id, 'user', 'draft', $draftid, $temppath, $USER->id) !== false) {
-            $extractedfiles = $fs->get_directory_files($user_context->id, 'user', 'draft', $draftid, $temppath, true);
+        if ($file->extract_to_storage($zipper, $usercontext->id, 'user', 'draft', $draftid, $temppath, $USER->id) !== false) {
+            $extractedfiles = $fs->get_directory_files($usercontext->id, 'user', 'draft', $draftid, $temppath, true);
             $xtemppath = preg_quote($temppath, '|');
             foreach ($extractedfiles as $file) {
                 $realpath = preg_replace('|^'.$xtemppath.'|', $filepath, $file->get_filepath());
@@ -231,7 +214,7 @@ switch ($action) {
                     // Set the source to the extracted file to indicate that it came from archive.
                     $file->set_source(serialize((object)array('source' => $filepath)));
                 }
-                if (!$fs->file_exists($user_context->id, 'user', 'draft', $draftid, $realpath, $file->get_filename())) {
+                if (!$fs->file_exists($usercontext->id, 'user', 'draft', $draftid, $realpath, $file->get_filename())) {
                     // File or directory did not exist, just move it.
                     $file->rename($realpath, $file->get_filename());
                 } else if (!$file->is_directory()) {
@@ -250,7 +233,7 @@ switch ($action) {
         }
         // Remove remaining temporary directories.
         foreach (array_diff($doremovedirs, $donotremovedirs) as $filepath) {
-            if ($file = $fs->get_file($user_context->id, 'user', 'draft', $draftid, $filepath, '.')) {
+            if ($file = $fs->get_file($usercontext->id, 'user', 'draft', $draftid, $filepath, '.')) {
                 $file->delete();
             }
         }
@@ -261,7 +244,7 @@ switch ($action) {
         $filepath    = required_param('filepath', PARAM_PATH);
 
         $fs = get_file_storage();
-        $file = $fs->get_file($user_context->id, 'user', 'draft', $draftid, $filepath, $filename);
+        $file = $fs->get_file($usercontext->id, 'user', 'draft', $draftid, $filepath, $filename);
         if (!$file) {
             echo json_encode(false);
         } else {
@@ -275,7 +258,7 @@ switch ($action) {
         $filepath    = required_param('filepath', PARAM_PATH);
 
         $fs = get_file_storage();
-        $file = $fs->get_file($user_context->id, 'user', 'draft', $draftid, $filepath, $filename);
+        $file = $fs->get_file($usercontext->id, 'user', 'draft', $draftid, $filepath, $filename);
         if (!$file) {
             echo json_encode(false);
         } else {
index 934085f..e65a202 100644 (file)
@@ -317,6 +317,36 @@ YUI.add('moodle-core_filepicker', function(Y) {
             // TODO add tooltip with o.data['title'] (o.value) or o.data['thumbnail_title']
             return el.getContent();
         }
+
+        /**
+         * Generate slave checkboxes based on toggleall's specification
+         * @param {object} o An object reprsenting the record for the current row.
+         * @return {html} The checkbox html
+         */
+        var formatCheckbox = function(o) {
+            var el = Y.Node.create('<div/>');
+
+            var checkbox = Y.Node.create('<input/>')
+                .setAttribute('type', 'checkbox')
+                .setAttribute('data-fieldtype', 'checkbox')
+                .setAttribute('data-fullname', o.data.fullname)
+                .setAttribute('data-action', 'toggle')
+                .setAttribute('data-toggle', 'slave')
+                .setAttribute('data-togglegroup', 'file-selections')
+                .setAttribute('data-toggle-selectall', M.util.get_string('selectall', 'moodle'))
+                .setAttribute('data-toggle-deselectall', M.util.get_string('deselectall', 'moodle'));
+
+            var checkboxLabel = Y.Node.create('<label>')
+                .setHTML("Select file '" + o.data.fullname + "'")
+                .addClass('sr-only')
+                .setAttrs({
+                    for: checkbox.generateID(),
+                });
+
+            el.appendChild(checkbox);
+            el.appendChild(checkboxLabel);
+            return el.getContent();
+        };
         /** sorting function for table view */
         var sortFoldersFirst = function(a, b, desc) {
             if (a.get('isfolder') && !b.get('isfolder')) {
@@ -340,6 +370,37 @@ YUI.add('moodle-core_filepicker', function(Y) {
                 {key: "mimetype", label: M.util.get_string('type', 'repository'), allowHTML: true,
                     sortable: true, sortFn: sortFoldersFirst}
             ];
+
+            // Generate a checkbox based on toggleall's specification
+            var div = Y.Node.create('<div/>');
+            var checkbox = Y.Node.create('<input/>')
+                .setAttribute('type', 'checkbox')
+                // .setAttribute('title', M.util.get_string('selectallornone', 'form'))
+                .setAttribute('data-action', 'toggle')
+                .setAttribute('data-toggle', 'master')
+                .setAttribute('data-togglegroup', 'file-selections');
+
+            var checkboxLabel = Y.Node.create('<label>')
+                .setHTML(M.util.get_string('selectallornone', 'form'))
+                .addClass('sr-only')
+                .setAttrs({
+                    for: checkbox.generateID(),
+                });
+
+            div.appendChild(checkboxLabel);
+            div.appendChild(checkbox);
+
+
+            // Enable the selectable checkboxes
+            if (options.disablecheckboxes != undefined && !options.disablecheckboxes) {
+                cols.unshift({
+                    key: "",
+                    label: div.getContent(),
+                    allowHTML: true,
+                    formatter: formatCheckbox,
+                    sortable: false
+                });
+            }
             scope.tableview = new Y.DataTable({columns: cols, data: fileslist});
             scope.tableview.delegate('click', function (e, tableview) {
                 var record = tableview.getRecord(e.currentTarget.get('id'));
@@ -350,7 +411,8 @@ YUI.add('moodle-core_filepicker', function(Y) {
                     }
                     Y.bind(callback, this)(e, record.getAttrs());
                 }
-            }, 'tr', options.callbackcontext, scope.tableview);
+            }, 'tr td:not(:first-child)', options.callbackcontext, scope.tableview);
+
             if (options.rightclickcallback) {
                 scope.tableview.delegate('contextmenu', function (e, tableview) {
                     var record = tableview.getRecord(e.currentTarget.get('id'));
index eddb0c6..b5296d6 100644 (file)
@@ -3209,3 +3209,101 @@ function initialise_filepicker($args) {
     }
     return $return;
 }
+
+/**
+ * Convenience function to handle deletion of files.
+ *
+ * @param object $context The context where the delete is called
+ * @param string $component component
+ * @param string $filearea filearea
+ * @param int $itemid the item id
+ * @param array $files Array of files object with each item having filename/filepath as values
+ * @return array $return Array of strings matching up to the parent directory of the deleted files
+ * @throws coding_exception
+ */
+function repository_delete_selected_files($context, string $component, string $filearea, $itemid, array $files) {
+    $fs = get_file_storage();
+    $return = [];
+
+    foreach ($files as $selectedfile) {
+        $filename = clean_filename($selectedfile->filename);
+        $filepath = clean_param($selectedfile->filepath, PARAM_PATH);
+        $filepath = file_correct_filepath($filepath);
+
+        if ($storedfile = $fs->get_file($context->id, $component, $filearea, $itemid, $filepath, $filename)) {
+            $parentpath = $storedfile->get_parent_directory()->get_filepath();
+            if ($storedfile->is_directory()) {
+                $files = $fs->get_directory_files($context->id, $component, $filearea, $itemid, $filepath, true);
+                foreach ($files as $file) {
+                    $file->delete();
+                }
+                $storedfile->delete();
+                $return[$parentpath] = "";
+            } else {
+                if ($result = $storedfile->delete()) {
+                    $return[$parentpath] = "";
+                }
+            }
+        }
+    }
+
+    return $return;
+}
+
+/**
+ * Convenience function to handle deletion of files.
+ *
+ * @param object $context The context where the delete is called
+ * @param string $component component
+ * @param string $filearea filearea
+ * @param int $itemid the item id
+ * @param array $files Array of files object with each item having filename/filepath as values
+ * @return array $return Array of strings matching up to the parent directory of the deleted files
+ * @throws coding_exception
+ */
+function repository_download_selected_files($context, string $component, string $filearea, $itemid, array $files) {
+    global $USER;
+    $return = false;
+
+    $zipper = get_file_packer('application/zip');
+    $fs = get_file_storage();
+    // Archive compressed file to an unused draft area.
+    $newdraftitemid = file_get_unused_draft_itemid();
+    $filestoarchive = [];
+
+    foreach ($files as $selectedfile) {
+        $filename = clean_filename($selectedfile->filename); // Default to '.' for root.
+        $filepath = clean_param($selectedfile->filepath, PARAM_PATH); // Default to '/' for downloadall.
+        $filepath = file_correct_filepath($filepath);
+        $area = file_get_draft_area_info($itemid, $filepath);
+        if ($area['filecount'] == 0 && $area['foldercount'] == 0) {
+            continue;
+        }
+
+        $storedfile = $fs->get_file($context->id, $component, $filearea, $itemid, $filepath, $filename);
+        // If it is empty we are downloading a directory.
+        $archivefile = $storedfile->get_filename();
+        if (!$filename || $filename == '.' ) {
+            $archivefile = $filepath;
+        }
+
+        $filestoarchive[$archivefile] = $storedfile;
+    }
+    $zippedfile = get_string('files') . '.zip';
+    if ($newfile =
+        $zipper->archive_to_storage(
+            $filestoarchive,
+            $context->id,
+            $component,
+            $filearea,
+            $newdraftitemid,
+            "/",
+            $zippedfile, $USER->id)
+    ) {
+        $return = new stdClass();
+        $return->fileurl = moodle_url::make_draftfile_url($newdraftitemid, '/', $zippedfile)->out();
+        $return->filepath = $filepath;
+    }
+
+    return $return;
+}
index 7bd3db2..905a54d 100644 (file)
@@ -19,3 +19,78 @@ Feature: Delete files and folders from the file manager
     And I delete "Delete me" from "Files" filemanager
     And I press "Save changes"
     And I should not see "Delete me"
+
+  @javascript
+  Scenario: Delete a file and a folder using bulk functionality (individually)
+    Given I log in as "admin"
+    And I follow "Manage private files"
+    And I upload "lib/tests/fixtures/empty.txt" file to "Files" filemanager
+    And I create "Delete me later" folder in "Files" filemanager
+    And I press "Save changes"
+    And I follow "Manage private files"
+    And I click on "Display folder with file details" "link"
+    And I set the field "Select file 'empty.txt'" to "1"
+    When I click on "Delete selected" "link"
+    Then I should see "Are you sure you want to delete the selected 1 file(s)?"
+    When I click on "OK" "button" in the "Confirm" "dialogue"
+    Then I should not see "empty.txt"
+    But I should see "Delete me later"
+    When I press "Save changes"
+    And I follow "Manage private files"
+    Then I should not see "empty.txt"
+    But I should see "Delete me later"
+    And I set the field "Select file 'Delete me later'" to "1"
+    And I click on "Delete selected" "link"
+    And I click on "OK" "button" in the "Confirm" "dialogue"
+    Then I should not see "Delete me later"
+    When I press "Save changes"
+    Then I should not see "Delete me later"
+
+  @javascript
+  Scenario: Delete a file and a folder using bulk functionality (multiple)
+    Given I log in as "admin"
+    And I follow "Manage private files"
+    And I upload "lib/tests/fixtures/empty.txt" file to "Files" filemanager
+    And I create "Delete me" folder in "Files" filemanager
+    And I create "Do not delete me" folder in "Files" filemanager
+    And I press "Save changes"
+    And I follow "Manage private files"
+    And I click on "Display folder with file details" "link"
+    And I set the field "Select file 'empty.txt'" to "1"
+    And I set the field "Select file 'Delete me'" to "1"
+    When I click on "Delete selected" "link"
+    Then I should see "Are you sure you want to delete the selected 2 file(s)?"
+    When I click on "OK" "button" in the "Confirm" "dialogue"
+    Then I should not see "Delete me"
+    And I should not see "empty.txt"
+    But I should see "Do not delete me"
+    When I press "Save changes"
+    Then I should not see "Delete me" in the "Private files" "block"
+    And I should not see "empty.txt" in the "Private files" "block"
+    But I should see "Do not delete me" in the "Private files" "block"
+
+  @javascript
+  Scenario: Delete files using the select all checkbox
+    Given I log in as "admin"
+    And I follow "Manage private files"
+    And I upload "lib/tests/fixtures/empty.txt" file to "Files" filemanager
+    And I create "Delete me" folder in "Files" filemanager
+    And I create "Delete me too" folder in "Files" filemanager
+    And I press "Save changes"
+    And I follow "Manage private files"
+    And I click on "Display folder with file details" "link"
+    When I set the field "Select all/none" to "1"
+    Then the following fields match these values:
+      | Select file 'empty.txt' | 1 |
+      | Select file 'Delete me' | 1 |
+      | Select file 'Delete me too' | 1 |
+    When I click on "Delete selected" "link"
+    Then I should see "Are you sure you want to delete the selected 3 file(s)?"
+    When I click on "OK" "button" in the "Confirm" "dialogue"
+    Then I should not see "Delete me"
+    And I should not see "empty.txt"
+    And I should not see "Delete me too"
+    When I press "Save changes"
+    Then I should not see "Delete me" in the "Private files" "block"
+    And I should not see "empty.txt" in the "Private files" "block"
+    And I should not see "Delete me too" in the "Private files" "block"
index a65c5bb..d8885c8 100644 (file)
@@ -154,6 +154,60 @@ class core_repositorylib_testcase extends advanced_testcase {
         }
     }
 
+    public function test_delete_selected_files() {
+        global $USER;
+
+        $this->resetAfterTest(true);
+
+        $this->setAdminUser();
+        $fs = get_file_storage();
+
+        $draftitemid = file_get_unused_draft_itemid();
+        $context = context_user::instance($USER->id);
+
+        $dummy = [
+            'contextid' => $context->id,
+            'component' => 'user',
+            'filearea' => 'draft',
+            'itemid' => $draftitemid,
+            'filepath' => '/',
+            'filename' => ''
+        ];
+
+        // Create some files.
+        $existingfiles = [
+            'The Matrix.movie',
+            'Astalavista.txt',
+            'foobar',
+        ];
+
+        $selectedfiles = [
+            'The Matrix.movie' => [],
+            'Astalavista.txt' => []
+        ];
+        foreach ($existingfiles as $filename) {
+            $dummy['filename'] = $filename;
+            $file = $fs->create_file_from_string($dummy, 'Content of ' . $filename);
+            if (array_key_exists($filename, $selectedfiles)) {
+                $selectedfiles[$filename] = (object)[
+                    'filename' => $filename,
+                    'filepath' => $file->get_filepath()
+                ];
+            }
+        }
+
+        // Get area files with default options.
+        $areafiles = $fs->get_area_files($context->id, 'user', 'draft', $draftitemid);
+        // Should be the 3 files we added plus the folder.
+        $this->assertEquals(4, count($areafiles));
+
+        repository_delete_selected_files($context, 'user', 'draft', $draftitemid, $selectedfiles);
+
+        $areafiles = $fs->get_area_files($context->id, 'user', 'draft', $draftitemid);
+        // Should be the 1 file left plus the folder.
+        $this->assertEquals(2, count($areafiles));
+    }
+
     public function test_can_be_edited_by_user() {
         $this->resetAfterTest(true);
 
index a2faa9d..f7469f2 100644 (file)
Binary files a/theme/boost/amd/build/drawer.min.js and b/theme/boost/amd/build/drawer.min.js differ
index 52c8504..287b576 100644 (file)
Binary files a/theme/boost/amd/build/drawer.min.js.map and b/theme/boost/amd/build/drawer.min.js.map differ
index 55b1db1..45b0391 100644 (file)
@@ -128,8 +128,9 @@ define(['jquery', 'core/custom_interaction_events', 'core/log', 'core/pubsub'],
             // Close.
             body.removeClass('drawer-open-' + side);
             trigger.attr('aria-expanded', 'false');
-            drawer.attr('aria-hidden', 'true');
-            drawer.addClass('closed');
+            drawer.addClass('closed').delay(500).queue(function() {
+                $(this).attr('aria-hidden', 'true').dequeue();
+            });
             if (!small) {
                 M.util.set_user_preference(preference, 'false');
             }
index c261e05..c75703d 100644 (file)
@@ -24,6 +24,10 @@ $drawer-offscreen-gutter: 20px !default;
     left: -($drawer-width + $drawer-offscreen-gutter);
 }
 
+#nav-drawer[aria-hidden=true] .list-group-item {
+    display: none;
+}
+
 /* Use a variable for the drawer background colors. */
 $drawer-bg: darken($body-bg, 5%) !default;
 
index 664fa79..c1f03d9 100644 (file)
@@ -553,6 +553,7 @@ a.ygtvspacer:hover {
 .filemanager.fm-maxfiles .fp-btn-add,
 .filemanager.fm-maxfiles .dndupload-message,
 .filemanager.fm-noitems .fp-btn-download,
+.filemanager.fm-noitems .fp-btn-delete,
 .filemanager .fm-empty-container,
 .filemanager.fm-noitems .filemanager-container .fp-content {
     display: none;
index 91f87b5..e8a7fce 100644 (file)
@@ -13389,6 +13389,9 @@ span.editinstructions {
 #nav-drawer.closed {
   left: -305px; }
 
+#nav-drawer[aria-hidden=true] .list-group-item {
+  display: none; }
+
 /* Use a variable for the drawer background colors. */
 [data-region="drawer"] {
   position: fixed;
@@ -13930,6 +13933,7 @@ a.ygtvspacer:hover {
 .filemanager.fm-maxfiles .fp-btn-add,
 .filemanager.fm-maxfiles .dndupload-message,
 .filemanager.fm-noitems .fp-btn-download,
+.filemanager.fm-noitems .fp-btn-delete,
 .filemanager .fm-empty-container,
 .filemanager.fm-noitems .filemanager-container .fp-content {
   display: none; }
index 28a50ad..b109c93 100644 (file)
@@ -58,6 +58,7 @@
     {{{ output.standard_top_of_body_html }}}
 
     {{> theme_boost/navbar }}
+    {{> theme_boost/nav-drawer }}
 
     <div id="page" class="container-fluid d-print-block">
         {{{ output.full_header }}}
@@ -89,7 +90,6 @@
         </div>
     </div>
     {{{ output.standard_after_main_region_html }}}
-    {{> theme_boost/nav-drawer }}
     {{> theme_boost/footer }}
 </div>
 
diff --git a/theme/classic/classes/output/icon_system_fontawesome.php b/theme/classic/classes/output/icon_system_fontawesome.php
new file mode 100644 (file)
index 0000000..1861498
--- /dev/null
@@ -0,0 +1,50 @@
+<?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/>.
+
+/**
+ * Overridden fontawesome icons.
+ *
+ * @package     theme_classic
+ * @copyright   2019 Moodle
+ * @author      Bas Brands <bas@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace theme_classic\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class overriding some of the Moodle default FontAwesome icons.
+ *
+ * @package    theme_classic
+ * @copyright  2019 Moodle
+ * @author     Bas Brands <bas@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class icon_system_fontawesome extends \core\output\icon_system_fontawesome {
+    /**
+     * Change the core icon map.
+     *
+     * @return Array replaced icons.
+     */
+    public function get_core_icon_map() {
+        $iconmap = parent::get_core_icon_map();
+
+        $iconmap['core:i/navigationitem'] = 'fa-square';
+
+        return $iconmap;
+    }
+}
index dc6c1b8..6955eb3 100644 (file)
@@ -156,3 +156,4 @@ $THEME->scss = function($theme) {
     return theme_classic_get_main_scss_content($theme);
 };
 $THEME->usefallback = true;
+$THEME->iconsystem = '\\theme_classic\\output\\icon_system_fontawesome';
index 9a252da..63d2177 100644 (file)
@@ -13603,6 +13603,9 @@ span.editinstructions {
 #nav-drawer.closed {
   left: -305px; }
 
+#nav-drawer[aria-hidden=true] .list-group-item {
+  display: none; }
+
 /* Use a variable for the drawer background colors. */
 [data-region="drawer"] {
   position: fixed;
@@ -14145,6 +14148,7 @@ a.ygtvspacer:hover {
 .filemanager.fm-maxfiles .fp-btn-add,
 .filemanager.fm-maxfiles .dndupload-message,
 .filemanager.fm-noitems .fp-btn-download,
+.filemanager.fm-noitems .fp-btn-delete,
 .filemanager .fm-empty-container,
 .filemanager.fm-noitems .filemanager-container .fp-content {
   display: none; }
index 4183bba..d975b8b 100644 (file)
@@ -217,7 +217,7 @@ class behat_theme_classic_behat_navigation extends behat_navigation {
         $menuxpath = "//section[contains(@class,'block_settings')]//div[@id='settingsnav']/ul[1]/li[1]";
 
         if ($mustexist) {
-            $exception = new ElementNotFoundException($this->getSession(), 'Page administration menu is not found');
+            $exception = new ElementNotFoundException($this->getSession(), 'Page administration menu');
             $this->find('xpath', $menuxpath, $exception);
 
         } else if (!$this->getSession()->getPage()->find('xpath', $menuxpath)) {
index 2ad7497..0224f52 100644 (file)
@@ -220,8 +220,8 @@ class participants_table extends \table_sql {
         $this->define_columns($columns);
         $this->define_headers($headers);
 
-        // Make this table sorted by first name by default.
-        $this->sortable(true, 'firstname');
+        // Make this table sorted by last name by default.
+        $this->sortable(true, 'lastname');
 
         $this->no_sorting('select');
         $this->no_sorting('roles');
index 6f433b1..20838ca 100644 (file)
@@ -113,12 +113,12 @@ Feature: View course participants
     And I am on "Course 1" course homepage
     And I navigate to course participants
     And I follow "Email address"
-    When I follow "2"
+    When I click on "2" "link" in the "//nav[@aria-label='Page']" "xpath_element"
     Then I should not see "student0x@example.com"
     And I should not see "student19x@example.com"
     And I should see "teacher1x@example.com"
     And I follow "Email address"
-    And I follow "2"
+    And I click on "2" "link" in the "//nav[@aria-label='Page']" "xpath_element"
     And I should not see "teacher1x@example.com"
     And I should not see "student19x@example.com"
     And I should not see "student1x@example.com"
@@ -132,7 +132,6 @@ Feature: View course participants
     When I log in as "teacher1x"
     And I am on "Course 1" course homepage
     And I navigate to course participants
-    And I follow "Surname"
     And I click on "Select all" "checkbox"
     Then I should not see "Student 9x"
     And the field "Select 'Teacher 1x'" matches value "1"
index abb2ed1..fe6c061 100644 (file)
@@ -370,7 +370,8 @@ class webservice {
                     $newtoken->contextid = context_system::instance()->id;
                     $newtoken->creatorid = $userid;
                     $newtoken->timecreated = time();
-                    $newtoken->privatetoken = null;
+                    // Generate the private token, it must be transmitted only via https.
+                    $newtoken->privatetoken = random_string(64);
 
                     $DB->insert_record('external_tokens', $newtoken);
                 }