Merge branch 'install_master' of https://git.in.moodle.com/amosbot/moodle-install
authorabgreeve <adrian@moodle.com>
Fri, 11 Jun 2021 05:52:08 +0000 (13:52 +0800)
committerabgreeve <adrian@moodle.com>
Fri, 11 Jun 2021 05:52:08 +0000 (13:52 +0800)
79 files changed:
admin/category.php
admin/classes/local/settings/setting_scheduled_task_status.php [new file with mode: 0644]
admin/settings/language.php
admin/tests/behat/browse_users.feature
admin/tool/oauth2/issuers.php
admin/tool/oauth2/lang/en/tool_oauth2.php
admin/tool/task/renderer.php
admin/tool/task/styles.css
admin/user.php
blocks/navigation/amd/build/ajax_response_renderer.min.js
blocks/navigation/amd/build/ajax_response_renderer.min.js.map
blocks/navigation/amd/src/ajax_response_renderer.js
blocks/navigation/renderer.php
blocks/navigation/styles.css
blocks/settings/renderer.php
blocks/settings/styles.css
course/classes/course_format.php
course/classes/external/get_state.php [new file with mode: 0644]
course/classes/output/cm_format/state.php [new file with mode: 0644]
course/classes/output/course_format/legacy_format_renderer.php [new file with mode: 0644]
course/classes/output/course_format/state.php [new file with mode: 0644]
course/classes/output/section_format.php
course/classes/output/section_format/state.php [new file with mode: 0644]
course/classes/task/course_delete_modules.php
course/renderer.php
course/tests/behat/sectionzero_title.feature [new file with mode: 0644]
course/tests/course_delete_modules_test.php [new file with mode: 0644]
course/tests/external/get_state_test.php [new file with mode: 0644]
course/tests/fixtures/format_theunittest_output_course_format_state.php [new file with mode: 0644]
course/upgrade.txt
enrol/locallib.php
enrol/tests/course_enrolment_manager_test.php
grade/report/grader/lib.php
grade/report/grader/styles.css
lang/en/admin.php
lib/amd/build/tree.min.js
lib/amd/build/tree.min.js.map
lib/amd/src/tree.js
lib/classes/check/environment/publicpaths.php
lib/classes/check/manager.php
lib/classes/check/performance/dbschema.php [new file with mode: 0644]
lib/classes/check/table.php
lib/classes/oauth2/api.php
lib/classes/oauth2/client.php
lib/classes/oauth2/client/linkedin.php [new file with mode: 0644]
lib/classes/oauth2/service/linkedin.php [new file with mode: 0644]
lib/classes/task/scheduled_task.php
lib/classes/task/task_base.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/form/modgrade.php
lib/form/tests/behat/graderescale_for_database_pointscale.feature [new file with mode: 0644]
lib/form/tests/behat/graderescale_for_forum_pointscale.feature [new file with mode: 0644]
lib/form/tests/behat/graderescale_for_glossary_pointscale.feature [new file with mode: 0644]
lib/form/tests/behat/graderescale_for_lesson_pointscale.feature [new file with mode: 0644]
lib/form/tests/behat/modgrade_validation.feature
lib/tests/behat/behat_navigation.php
lib/tests/client_test.php [new file with mode: 0644]
mod/assign/feedback/editpdf/ajax.php
mod/assign/feedback/editpdf/db/upgrade.php
mod/assign/feedback/editpdf/version.php
mod/quiz/report/overview/overview_table.php
report/performance/lang/en/report_performance.php
report/security/lang/en/report_security.php
repository/filepicker.js
repository/nextcloud/lang/en/repository_nextcloud.php
repository/nextcloud/lib.php
repository/nextcloud/tests/lib_test.php
theme/boost/scss/moodle/blocks.scss
theme/boost/style/moodle.css
theme/classic/scss/preset/default.scss
theme/classic/style/moodle.css
user/classes/fields.php
user/externallib.php
user/tests/externallib_test.php
user/tests/fields_test.php
user/upgrade.txt
version.php

index 9bfa2bb..9cd3aba 100644 (file)
@@ -158,4 +158,9 @@ echo $outputhtml;
 echo html_writer::end_tag('fieldset');
 echo html_writer::end_tag('form');
 
+$PAGE->requires->yui_module('moodle-core-formchangechecker', 'M.core_formchangechecker.init', [[
+    'formid' => 'adminsettings'
+]]);
+$PAGE->requires->string_for_js('changesmadereallygoaway', 'moodle');
+
 echo $OUTPUT->footer();
diff --git a/admin/classes/local/settings/setting_scheduled_task_status.php b/admin/classes/local/settings/setting_scheduled_task_status.php
new file mode 100644 (file)
index 0000000..b878036
--- /dev/null
@@ -0,0 +1,121 @@
+<?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/>.
+
+/**
+ * Admin setting to show current scheduled task's status.
+ *
+ * @package core
+ * @copyright 2021 Universitat Rovira i Virgili
+ * @author Jordi Pujol-Ahulló <jpahullo@gmail.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_admin\local\settings;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/adminlib.php');
+require_once($CFG->libdir . '/moodlelib.php');
+
+use admin_setting_description;
+use core\task\manager;
+use core\task\scheduled_task;
+use html_writer;
+use lang_string;
+use moodle_url;
+use stdClass;
+
+/**
+ * This admin setting tells whether a given scheduled task is enabled, providing a link to its configuration page.
+ *
+ * The goal of this setting is to help contextualizing the configuration settings with related scheduled task status,
+ * providing the big picture of that part of the system.
+ *
+ * @package core
+ * @copyright 2021 Universitat Rovira i Virgili
+ * @author Jordi Pujol-Ahulló <jpahullo@gmail.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class setting_scheduled_task_status extends admin_setting_description {
+    /**
+     * @var string fully qualified class name of a scheduled task.
+     */
+    private $classname;
+    /**
+     * @var string additional text to append to the description.
+     */
+    private $extradescription;
+
+    /**
+     * setting_scheduled_task_status constructor.
+     * @param string $name unique setting name.
+     * @param string $scheduledtaskclassname full classpath class name of the scheduled task.
+     * @param string $extradescription extra detail to append to the scheduled task status to add context in the setting
+     * page.
+     */
+    public function __construct(string $name, string $scheduledtaskclassname, string $extradescription = '') {
+        $visiblename = new lang_string('task_status', 'admin');
+        $this->classname = $scheduledtaskclassname;
+        $this->extradescription = $extradescription;
+
+        parent::__construct($name, $visiblename, '');
+    }
+
+    /**
+     * Calculates lazily the content of the description.
+     * @param mixed $data nothing expected in this case.
+     * @param string $query nothing expected in this case.
+     * @return string the HTML content to print for this setting.
+     */
+    public function output_html($data, $query = ''): string {
+        if (empty($this->description)) {
+            $this->description = $this->get_task_description();
+        }
+
+        return parent::output_html($data, $query);
+    }
+
+    /**
+     * Returns the HTML to print as the description.
+     * @return string description to be printed.
+     */
+    private function get_task_description(): string {
+        $task = manager::get_scheduled_task($this->classname);
+        if ($task->is_enabled()) {
+            $taskenabled = get_string('enabled', 'admin');
+        } else {
+            $taskenabled = get_string('disabled', 'admin');
+        }
+        $taskenabled = strtolower($taskenabled);
+        $gotourl = new moodle_url(
+            '/admin/tool/task/scheduledtasks.php',
+            [],
+            scheduled_task::get_html_id($this->classname)
+        );
+        if (!empty($this->extradescription)) {
+            $this->extradescription = '<br />' . $this->extradescription;
+        }
+
+        $taskdetail = new stdClass();
+        $taskdetail->class = $this->classname;
+        $taskdetail->name = $task->get_name();
+        $taskdetail->status = $taskenabled;
+        $taskdetail->gotourl = $gotourl->out(false);
+        $taskdetail->extradescription = $this->extradescription;
+
+        return html_writer::tag('p', get_string('task_status_desc', 'admin', $taskdetail));
+    }
+}
index c865dfb..97fb85d 100644 (file)
@@ -2,6 +2,8 @@
 
 // This file defines settingpages and externalpages under the "appearance" category
 
+use core_admin\local\settings\setting_scheduled_task_status;
+
 if ($hassiteconfig) {
 
     // "languageandlocation" settingpage
@@ -16,6 +18,7 @@ if ($hassiteconfig) {
     $temp->add(new admin_setting_configcheckbox('langstringcache', new lang_string('langstringcache', 'admin'), new lang_string('configlangstringcache', 'admin'), 1));
     $temp->add(new admin_setting_configtext('locale', new lang_string('localetext', 'admin'), new lang_string('configlocale', 'admin'), '', PARAM_FILE));
     $temp->add(new admin_setting_configselect('latinexcelexport', new lang_string('latinexcelexport', 'admin'), new lang_string('configlatinexcelexport', 'admin'), '0', array('0'=>'Unicode','1'=>'Latin')));
+    $temp->add(new setting_scheduled_task_status('langimporttaskstatus', '\tool_langimport\task\update_langpacks_task'));
 
     $ADMIN->add('language', $temp);
 
index b6d9f41..6c1c92d 100644 (file)
@@ -48,3 +48,12 @@ Feature: An administrator can browse user accounts
       | User Two             | Tree            | Defence    |
     And I should not see "Email address" in the "table" "css_element"
     And I should not see "one@example.com"
+
+  Scenario: Sort user accounts by custom profile field
+    Given the following config values are set as admin:
+      | showuseridentity | profile_field_frog |
+    When I navigate to "Users > Accounts > Browse list of users" in site administration
+    And I follow "Favourite frog"
+    Then "Kermit" "text" should appear before "Tree" "text"
+    And I follow "Favourite frog"
+    Then "Tree" "text" should appear before "Kermi" "text"
index ad720c4..4b6409f 100644 (file)
@@ -234,6 +234,12 @@ if ($mform && $mform->is_cancelled()) {
     $addurl = new moodle_url('/admin/tool/oauth2/issuers.php', $params);
     echo $renderer->single_button($addurl, get_string('imsobv2p1_service', 'tool_oauth2'));
 
+    // Linkedin template.
+    $docs = 'admin/tool/oauth2/issuers/linkedin';
+    $params = ['action' => 'edittemplate', 'type' => 'linkedin', 'sesskey' => sesskey(), 'docslink' => $docs];
+    $addurl = new moodle_url('/admin/tool/oauth2/issuers.php', $params);
+    echo $renderer->single_button($addurl, get_string('linkedin_service', 'tool_oauth2'));
+
     // Generic issuer.
     $addurl = new moodle_url('/admin/tool/oauth2/issuers.php', ['action' => 'edit']);
     echo $renderer->single_button($addurl, get_string('custom_service', 'tool_oauth2'));
index 1fba234..f6fd074 100644 (file)
@@ -96,6 +96,7 @@ $string['issueruseininternalonly'] = 'Internal services only';
 $string['issueruseinloginonly'] = 'Login page only';
 $string['issuerusedforlogin'] = 'Login';
 $string['issuerusedforinternal'] = 'Internal services';
+$string['linkedin_service'] = 'LinkedIn';
 $string['logindisplay'] = 'Display on login page as';
 $string['loginissuer'] = 'Allow login';
 $string['microsoft_service'] = 'Microsoft';
index cdf7457..1491ef2 100644 (file)
@@ -76,7 +76,7 @@ class tool_task_renderer extends plugin_renderer_base {
         $data = [];
         $yes = get_string('yes');
         $no = get_string('no');
-        $canruntasks = \core\task\manager::is_runnable();
+        $canruntasks = \core\task\manager::is_runnable() && get_config('tool_task', 'enablerunnow');
         foreach ($tasks as $task) {
             $classname = get_class($task);
             $defaulttask = \core\task\manager::get_default_scheduled_task($classname, false);
@@ -108,14 +108,11 @@ class tool_task_renderer extends plugin_renderer_base {
             }
             $namecell = new html_table_cell($namecellcontent);
             $namecell->header = true;
-
-            $plugininfo = core_plugin_manager::instance()->get_plugin_info($task->get_component());
-            $plugindisabled = $plugininfo && $plugininfo->is_enabled() === false &&
-                    !$task->get_run_if_component_disabled();
-            $disabled = $plugindisabled || $task->get_disabled();
+            $namecell->id = scheduled_task::get_html_id($classname);
 
             $runnow = '';
-            if (!$plugindisabled && get_config('tool_task', 'enablerunnow') && $canruntasks ) {
+            $canrunthistask = $canruntasks && $task->can_run();
+            if ($canrunthistask) {
                 $runnow = html_writer::div(html_writer::link(
                         new moodle_url('/admin/tool/task/schedule_task.php',
                             ['task' => $classname]),
@@ -147,7 +144,7 @@ class tool_task_renderer extends plugin_renderer_base {
                         new html_table_cell($customised)]);
 
             $classes = [];
-            if ($disabled) {
+            if (!$task->is_enabled()) {
                 $classes[] = 'disabled';
             }
             if (get_class($task) == $lastchanged) {
index f297fd6..7f37541 100644 (file)
@@ -15,3 +15,7 @@
 #page-admin-tool-task-scheduledtasks .task-clearfaildelay {
     font-size: 0.75em;
 }
+
+#page-admin-tool-task-scheduledtasks :target {
+    scroll-margin-top: 60px;
+}
index 59853ca..249894b 100644 (file)
@@ -9,7 +9,7 @@
     $delete       = optional_param('delete', 0, PARAM_INT);
     $confirm      = optional_param('confirm', '', PARAM_ALPHANUM);   //md5 confirmation hash
     $confirmuser  = optional_param('confirmuser', 0, PARAM_INT);
-    $sort         = optional_param('sort', 'name', PARAM_ALPHANUM);
+    $sort         = optional_param('sort', 'name', PARAM_ALPHANUMEXT);
     $dir          = optional_param('dir', 'ASC', PARAM_ALPHA);
     $page         = optional_param('page', 0, PARAM_INT);
     $perpage      = optional_param('perpage', 30, PARAM_INT);        // how many per page
index 7be3a49..a3d3c37 100644 (file)
Binary files a/blocks/navigation/amd/build/ajax_response_renderer.min.js and b/blocks/navigation/amd/build/ajax_response_renderer.min.js differ
index c729061..709c880 100644 (file)
Binary files a/blocks/navigation/amd/build/ajax_response_renderer.min.js.map and b/blocks/navigation/amd/build/ajax_response_renderer.min.js.map differ
index e92ed46..8b6fc46 100644 (file)
@@ -68,22 +68,22 @@ define([
             var icon = null;
             var isBranch = (node.expandable || node.haschildren) ? true : false;
 
+            li.attr('role', 'treeitem');
             p.addClass('tree_item');
             p.attr('id', id);
-            p.attr('role', 'treeitem');
             // Negative tab index to allow it to receive focus.
             p.attr('tabindex', '-1');
 
             if (node.requiresajaxloading) {
-                p.attr('data-requires-ajax', true);
-                p.attr('data-node-id', node.id);
-                p.attr('data-node-key', node.key);
-                p.attr('data-node-type', node.type);
+                li.attr('data-requires-ajax', true);
+                li.attr('data-node-id', node.id);
+                li.attr('data-node-key', node.key);
+                li.attr('data-node-type', node.type);
             }
 
             if (isBranch) {
                 li.addClass('collapsed contains_branch');
-                p.attr('aria-expanded', false);
+                li.attr('aria-expanded', false);
                 p.addClass('branch');
             }
 
@@ -141,14 +141,14 @@ define([
             ul.append(li);
 
             if (node.children && node.children.length) {
-                buildDOM(p, node.children);
+                buildDOM(li, node.children);
             } else if (isBranch && !node.requiresajaxloading) {
                 li.removeClass('contains_branch');
                 p.addClass('emptybranch');
             }
         });
 
-        rootElement.parent().append(ul);
+        rootElement.append(ul);
         var id = rootElement.attr('id') + '_group';
         ul.attr('id', id);
         rootElement.attr('aria-owns', id);
@@ -167,8 +167,8 @@ define([
                 item.attr('aria-expanded', true);
                 Aria.unhide(group);
             } else {
-                if (element.parent().hasClass('contains_branch')) {
-                    element.parent().removeClass('contains_branch');
+                if (element.hasClass('contains_branch')) {
+                    element.removeClass('contains_branch');
                     element.addClass('emptybranch');
                 }
             }
index c64e55d..4583a12 100644 (file)
@@ -73,6 +73,7 @@ class block_navigation_renderer extends plugin_renderer_base {
         $lis = array();
         // Set the number to be static for unique id's.
         static $number = 0;
+        $htmlidprefix = html_writer::random_id();
         foreach ($items as $item) {
             $number++;
             if (!$item->display && !$item->contains_active_node()) {
@@ -90,8 +91,8 @@ class block_navigation_renderer extends plugin_renderer_base {
             $content = $item->get_content();
             $title = $item->get_title();
             $ulattr = ['id' => $id . '_group', 'role' => 'group'];
-            $liattr = ['class' => [$item->get_css_type(), 'depth_'.$depth]];
-            $pattr = ['class' => ['tree_item'], 'role' => 'treeitem'];
+            $liattr = ['class' => [$item->get_css_type(), 'depth_'.$depth], 'role' => 'treeitem'];
+            $pattr = ['class' => ['tree_item']];
             $pattr += !empty($item->id) ? ['id' => $item->id] : [];
             $isbranch = $isexpandable && ($item->children->count() > 0 || ($item->has_children() && (isloggedin() || $item->type <= navigation_node::TYPE_CATEGORY)));
             $hasicon = ((!$isbranch || $item->type == navigation_node::TYPE_ACTIVITY || $item->type == navigation_node::TYPE_RESOURCE) && $item->icon instanceof renderable);
@@ -112,7 +113,7 @@ class block_navigation_renderer extends plugin_renderer_base {
                 continue;
             }
 
-            $nodetextid = 'label_' . $depth . '_' . $number;
+            $nodetextid = $htmlidprefix . '_label_' . $depth . '_' . $number;
             $attributes = array('tabindex' => '-1', 'id' => $nodetextid);
             if ($title !== '') {
                 $attributes['title'] = $title;
@@ -135,11 +136,12 @@ class block_navigation_renderer extends plugin_renderer_base {
             }
 
             if ($isbranch) {
+                $ariaexpanded = $item->has_children() && (!$item->forceopen || $item->collapse);
                 $pattr['class'][] = 'branch';
                 $liattr['class'][] = 'contains_branch';
-                $pattr += ['aria-expanded' => ($item->has_children() && (!$item->forceopen || $item->collapse)) ? "false" : "true"];
+                $liattr += ['aria-expanded' => $ariaexpanded ? "false" : "true"];
                 if ($item->requiresajaxloading) {
-                    $pattr += [
+                    $liattr += [
                         'data-requires-ajax' => 'true',
                         'data-loaded' => 'false',
                         'data-node-id' => $item->id,
@@ -147,7 +149,7 @@ class block_navigation_renderer extends plugin_renderer_base {
                         'data-node-type' => $item->type
                     ];
                 } else {
-                    $pattr += ['aria-owns' => $id . '_group'];
+                    $liattr += ['aria-owns' => $id . '_group'];
                 }
             }
 
@@ -161,8 +163,8 @@ class block_navigation_renderer extends plugin_renderer_base {
             $liattr['class'] = join(' ', $liattr['class']);
             $pattr['class'] = join(' ', $pattr['class']);
 
-            $pattr += $depth == 1 ? ['data-collapsible' => 'false'] : [];
-            if (isset($pattr['aria-expanded']) && $pattr['aria-expanded'] === 'false') {
+            $liattr += $depth == 1 ? ['data-collapsible' => 'false'] : [];
+            if (isset($liattr['aria-expanded']) && $liattr['aria-expanded'] === 'false') {
                 $ulattr += ['aria-hidden' => 'true'];
             }
 
index 2369c1a..0411381 100644 (file)
@@ -57,7 +57,7 @@
     background-image: url('[[pix:t/collapsed_empty]]');
 }
 
-.block_navigation .block_tree [aria-expanded="false"].loading {
+.block_navigation .block_tree [aria-expanded="false"] p.loading {
     background-image: url('[[pix:i/loading_small]]');
 }
 
index 3e92848..1b73ad9 100644 (file)
@@ -77,8 +77,8 @@ class block_settings_renderer extends plugin_renderer_base {
             $content = $this->output->render($item);
             $id = $item->id ? $item->id : html_writer::random_id();
             $ulattr = ['id' => $id . '_group', 'role' => 'group'];
-            $liattr = ['class' => [$item->get_css_type(), 'depth_'.$depth], 'tabindex' => '-1'];
-            $pattr = ['class' => ['tree_item'], 'role' => 'treeitem'];
+            $liattr = ['class' => [$item->get_css_type(), 'depth_'.$depth], 'tabindex' => '-1', 'role' => 'treeitem'];
+            $pattr = ['class' => ['tree_item']];
             $pattr += !empty($item->id) ? ['id' => $item->id] : [];
             $hasicon = (!$isbranch && $item->icon instanceof renderable);
 
@@ -86,15 +86,15 @@ class block_settings_renderer extends plugin_renderer_base {
                 $liattr['class'][] = 'contains_branch';
                 if (!$item->forceopen || (!$item->forceopen && $item->collapse) || ($item->children->count() == 0
                         && $item->nodetype == navigation_node::NODETYPE_BRANCH)) {
-                    $pattr += ['aria-expanded' => 'false'];
+                    $liattr += ['aria-expanded' => 'false'];
                 } else {
-                    $pattr += ['aria-expanded' => 'true'];
+                    $liattr += ['aria-expanded' => 'true'];
                 }
                 if ($item->requiresajaxloading) {
-                    $pattr['data-requires-ajax'] = 'true';
-                    $pattr['data-loaded'] = 'false';
+                    $liattr['data-requires-ajax'] = 'true';
+                    $liattr['data-loaded'] = 'false';
                 } else {
-                    $pattr += ['aria-owns' => $id . '_group'];
+                    $liattr += ['aria-owns' => $id . '_group'];
                 }
             } else if ($hasicon) {
                 $liattr['class'][] = 'item_with_icon';
@@ -106,7 +106,6 @@ class block_settings_renderer extends plugin_renderer_base {
             if (!empty($item->classes) && count($item->classes) > 0) {
                 $pattr['class'] = array_merge($pattr['class'], $item->classes);
             }
-            $nodetextid = 'label_' . $depth . '_' . $number;
 
             // class attribute on the div item which only contains the item content
             $pattr['class'][] = 'tree_item';
@@ -119,7 +118,7 @@ class block_settings_renderer extends plugin_renderer_base {
             $liattr['class'] = join(' ', $liattr['class']);
             $pattr['class'] = join(' ', $pattr['class']);
 
-            if (isset($pattr['aria-expanded']) && $pattr['aria-expanded'] === 'false') {
+            if (isset($liattr['aria-expanded']) && $liattr['aria-expanded'] === 'false') {
                 $ulattr += ['aria-hidden' => 'true'];
             }
 
@@ -127,7 +126,6 @@ class block_settings_renderer extends plugin_renderer_base {
             if (!empty($item->preceedwithhr) && $item->preceedwithhr===true) {
                 $content = html_writer::empty_tag('hr') . $content;
             }
-            $liattr['aria-labelledby'] = $nodetextid;
             $content = html_writer::tag('li', $content, $liattr);
             $lis[] = $content;
         }
index 63c032d..3c9fa58 100644 (file)
@@ -46,7 +46,7 @@
     background-image: url('[[pix:t/collapsed_empty]]');
 }
 
-.block_settings .block_tree [aria-expanded="false"].loading {
+.block_settings .block_tree [aria-expanded="false"] p.loading {
     background-image: url('[[pix:i/loading_small]]');
 }
 /*rtl:raw:
index f55b3c9..1549e41 100644 (file)
@@ -38,6 +38,7 @@ use lang_string;
 use completion_info;
 use external_api;
 use stdClass;
+use core_course\output\course_format\legacy_format_renderer;
 
 /**
  * Base class for course formats
@@ -1055,7 +1056,19 @@ abstract class course_format {
      * @return renderer_base
      */
     public function get_renderer(moodle_page $page) {
-        return $page->get_renderer('format_'. $this->get_format());
+        try {
+            $renderer = $page->get_renderer('format_'. $this->get_format());
+        } catch (moodle_exception $e) {
+            $formatname = $this->get_format();
+            $expectedrenderername = 'format_'. $this->get_format() . '\output\renderer';
+            debugging(
+                "The '{$formatname}' course format does not define the {$expectedrenderername} renderer class. This is required since Moodle 4.0.",
+                 DEBUG_DEVELOPER
+            );
+            $renderer = new legacy_format_renderer($page, null);
+        }
+
+        return $renderer;
     }
 
     /**
diff --git a/course/classes/external/get_state.php b/course/classes/external/get_state.php
new file mode 100644 (file)
index 0000000..228df46
--- /dev/null
@@ -0,0 +1,132 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+namespace core_course\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/externallib.php');
+
+use external_api;
+use external_function_parameters;
+use external_value;
+
+/**
+ * Class for exporting a course state.
+ *
+ * @package    core_course
+ * @copyright  2021 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 4.0
+ */
+class get_state extends external_api {
+
+    /**
+     * Webservice parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function execute_parameters(): external_function_parameters {
+        return new external_function_parameters(
+            [
+                'courseid' => new external_value(PARAM_INT, 'course id', VALUE_REQUIRED),
+            ]
+        );
+    }
+
+    /**
+     * This method will load all course, sections and cm states needed to initialize the frontend
+     * course editor module. The state data of every individual course, section and cm is
+     * build using the specifics "state" output components.
+     *
+     * By default, the states are generated by:
+     *  - core_course\output\course_format\state
+     *  - core_course\output\section_format\state
+     *  - core_course\output\cm_format\state
+     *
+     * As the other main course outputs, format plugins can override those output components
+     * to send more information to the frontend course editor. These extended classes should
+     * be located in format_XXX\output\course_format\state, format_XXX\output\section_format\state
+     * or format_XXX\output\cm_format\state.
+     *
+     * @param int $courseid the course id
+     * @return string Course state in JSON
+     */
+    public static function execute(int $courseid): string {
+        global $PAGE, $CFG;
+
+        require_once($CFG->dirroot.'/course/lib.php');
+
+        $params = external_api::validate_parameters(self::execute_parameters(), [
+            'courseid' => $courseid,
+        ]);
+        $courseid = $params['courseid'];
+
+        self::validate_context(\context_course::instance($courseid));
+
+        $courseformat = course_get_format($courseid);
+        $modinfo = $courseformat->get_modinfo();
+
+        // Get the proper renderer.
+        $renderer = $courseformat->get_renderer($PAGE);
+
+        $result = (object)[
+            'course' => (object)[],
+            'section' => [],
+            'cm' => [],
+        ];
+
+        // Load the output class names.
+        $courseclass = $courseformat->get_output_classname('course_format\state');
+        $sectionclass = $courseformat->get_output_classname('section_format\state');
+        $cmclass = $courseformat->get_output_classname('cm_format\state');
+
+        // General state.
+        $coursestate = new $courseclass($courseformat);
+        $result->course = $coursestate->export_for_template($renderer);
+
+        // Sections and course modules state.
+        $sections = $modinfo->get_section_info_all();
+        foreach ($sections as $section) {
+            if (!empty($section->uservisible)) {
+                // Only return this section data if it's visible by current user on the course page.
+                $sectionstate = new $sectionclass($courseformat, $section);
+                $result->section[] = $sectionstate->export_for_template($renderer);
+            }
+        }
+
+        foreach ($modinfo->cms as $cm) {
+            if ($cm->is_visible_on_course_page()) {
+                // Only return this course module data if it's visible by current user on the course page.
+                $section = $sections[$cm->sectionnum];
+                $cmstate = new $cmclass($courseformat, $section, $cm);
+                $result->cm[] = $cmstate->export_for_template($renderer);
+            }
+        }
+
+        return json_encode($result);
+    }
+
+    /**
+     * Webservice returns.
+     *
+     * @return external_value
+     */
+    public static function execute_returns(): external_value {
+        return new external_value(PARAM_RAW, 'Encoded course state JSON');
+    }
+}
diff --git a/course/classes/output/cm_format/state.php b/course/classes/output/cm_format/state.php
new file mode 100644 (file)
index 0000000..f870145
--- /dev/null
@@ -0,0 +1,85 @@
+<?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/>.
+
+namespace core_course\output\cm_format;
+
+use core_course\course_format;
+use section_info;
+use cm_info;
+use renderable;
+use stdClass;
+
+/**
+ * Contains the ajax update course module structure.
+ *
+ * @package   core_course
+ * @copyright 2021 Ferran Recio <ferran@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class state implements renderable {
+
+    /** @var course_format the course format class */
+    protected $format;
+
+    /** @var section_info the course section class */
+    protected $section;
+
+    /** @var bool if cmitem HTML content must be exported as well */
+    protected $exportcontent;
+
+    /** @var cm_info the course module to display */
+    protected $cm;
+
+    /**
+     * Constructor.
+     *
+     * @param course_format $format the course format
+     * @param section_info $section the section data
+     * @param cm_info $cm the course module data
+     * @param bool $exportcontent = false if pre-rendered cmitem must be exported.
+     */
+    public function __construct(course_format $format, section_info $section, cm_info $cm, bool $exportcontent = false) {
+        $this->format = $format;
+        $this->section = $section;
+        $this->cm = $cm;
+        $this->exportcontent = $exportcontent;
+    }
+
+    /**
+     * Export this data so it can be used as state object in the course editor.
+     *
+     * @param renderer_base $output typically, the renderer that's calling this function
+     * @return stdClass data context for a mustache template
+     */
+    public function export_for_template(\renderer_base $output): stdClass {
+
+        $format = $this->format;
+        $section = $this->section;
+        $cm = $this->cm;
+
+        $data = (object)[
+            'id' => $cm->id,
+            'name' => $cm->name,
+            'visible' => !empty($cm->visible),
+        ];
+
+        if ($this->exportcontent) {
+            $data->content = $output->course_section_updated_cm_item($format, $section, $cm);
+        }
+
+        return $data;
+    }
+}
diff --git a/course/classes/output/course_format/legacy_format_renderer.php b/course/classes/output/course_format/legacy_format_renderer.php
new file mode 100644 (file)
index 0000000..9530bec
--- /dev/null
@@ -0,0 +1,38 @@
+<?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/>.
+
+/**
+ * Legacy course format renderer.
+ *
+ * Since Moodle 4.0, renderer.php file was optional (although highly recommended) for course formats. From Moodle 4.0 onwards,
+ * renderer is required to support the new course editor implementation.
+ * This legacy class has been created for backward compatibility, to avoid some errors with course formats (such as social)
+ * without this renderer.php file.
+ *
+ * @package   core_course
+ * @copyright 2021 Sara Arjona (sara@moodle.com)
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\output\course_format;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot.'/course/format/renderer.php');
+
+class legacy_format_renderer extends \format_section_renderer_base {
+
+}
diff --git a/course/classes/output/course_format/state.php b/course/classes/output/course_format/state.php
new file mode 100644 (file)
index 0000000..d0018d3
--- /dev/null
@@ -0,0 +1,71 @@
+<?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/>.
+
+namespace core_course\output\course_format;
+
+use core_course\course_format;
+use renderable;
+use stdClass;
+
+/**
+ * Contains the ajax update course structure.
+ *
+ * @package   core_course
+ * @copyright 2021 Ferran Recio <ferran@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class state implements renderable {
+
+    /** @var course_format the course format class */
+    protected $format;
+
+    /**
+     * Constructor.
+     *
+     * @param course_format $format the course format
+     */
+    public function __construct(course_format $format) {
+        $this->format = $format;
+    }
+
+    /**
+     * Export this data so it can be used as state object in the course editor.
+     *
+     * @param renderer_base $output typically, the renderer that's calling this function
+     * @return stdClass data context for a mustache template
+     */
+    public function export_for_template(\renderer_base $output): stdClass {
+        $format = $this->format;
+        $course = $format->get_course();
+        $modinfo = $this->format->get_modinfo();
+
+        $data = (object)[
+            'id' => $course->id,
+            'numsections' => $format->get_last_section_number(),
+            'sectionlist' => [],
+            'editmode' => $format->show_editor(),
+        ];
+
+        $sections = $modinfo->get_section_info_all();
+        foreach ($sections as $section) {
+            if (!empty($section->uservisible)) {
+                $data->sectionlist[] = $section->id;
+            }
+        }
+
+        return $data;
+    }
+}
index feb54c1..6ff0f0f 100644 (file)
@@ -162,8 +162,7 @@ class section_format implements renderable, templatable {
             $data->completioninfo = $completioninfo->display_help_icon();
 
             $header = new $this->headerclass($format, $thissection);
-            if (empty($this->hidetitle) && !is_null($thissection->name)
-                && !($thissection->name === '')) {
+            if (empty($this->hidetitle)) {
                 $data->header = $header->export_for_template($output);
             }
             return $data;
diff --git a/course/classes/output/section_format/state.php b/course/classes/output/section_format/state.php
new file mode 100644 (file)
index 0000000..7c58c56
--- /dev/null
@@ -0,0 +1,82 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+namespace core_course\output\section_format;
+
+use core_course\course_format;
+use section_info;
+use renderable;
+use stdClass;
+
+/**
+ * Contains the ajax update section structure.
+ *
+ * @package   core_course
+ * @copyright 2021 Ferran Recio <ferran@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class state implements renderable {
+
+    /** @var course_format the course format class */
+    protected $format;
+
+    /** @var section_info the course section class */
+    protected $section;
+
+    /**
+     * Constructor.
+     *
+     * @param course_format $format the course format
+     * @param section_info $section the section info
+     */
+    public function __construct(course_format $format, section_info $section) {
+        $this->format = $format;
+        $this->section = $section;
+    }
+
+    /**
+     * Export this data so it can be used as state object in the course editor.
+     *
+     * @param renderer_base $output typically, the renderer that's calling this function
+     * @return array data context for a mustache template
+     */
+    public function export_for_template(\renderer_base $output): stdClass {
+        $format = $this->format;
+        $section = $this->section;
+        $modinfo = $format->get_modinfo();
+
+        $data = (object)[
+            'id' => $section->id,
+            'section' => $section->section,
+            'title' => $format->get_section_name($section),
+            'cmlist' => [],
+            'visible' => !empty($section->visible),
+        ];
+
+        if (empty($modinfo->sections[$section->section])) {
+            return $data;
+        }
+
+        foreach ($modinfo->sections[$section->section] as $modnumber) {
+            $mod = $modinfo->cms[$modnumber];
+            if ($mod->is_visible_on_course_page()) {
+                $data->cmlist[] = $mod->id;
+            }
+        }
+
+        return $data;
+    }
+}
index 1140c30..77ef3d9 100644 (file)
@@ -65,7 +65,8 @@ class course_delete_modules extends \core\task\adhoc_task {
             try {
                 course_delete_module($cm->id);
             } catch (\Exception $e) {
-                throw new \coding_exception("The course module {$cm->id} could not be deleted. {$e->getTraceAsString()}");
+                throw new \coding_exception("The course module {$cm->id} could not be deleted. "
+                    . "{$e->getMessage()}: {$e->getFile()}({$e->getLine()}) {$e->getTraceAsString()}");
             }
         }
     }
index fe1eaba..df331a0 100644 (file)
@@ -264,6 +264,8 @@ class core_course_renderer extends plugin_renderer_base {
     /**
      * Renders HTML for the menus to add activities and resources to the current course
      *
+     * Renders the ajax control (the link which when clicked produces the activity chooser modal). No noscript fallback.
+     *
      * @param stdClass $course
      * @param int $section relative section number (field course_sections.section)
      * @param int $sectionreturn The section to link back to
@@ -272,41 +274,28 @@ class core_course_renderer extends plugin_renderer_base {
      * @return string
      */
     function course_section_add_cm_control($course, $section, $sectionreturn = null, $displayoptions = array()) {
-        // The returned control HTML can be one of the following:
-        // - Only the non-ajax control (select menus of activities and resources) with a noscript fallback for non js clients.
-        // Please note that non-ajax control has been deprecated and it will be removed in the future.
+        $straddeither = get_string('addresourceoractivity');
 
-        // - Only the ajax control (the link which when clicked produces the activity chooser modal). No noscript fallback.
+        $ajaxcontrol = html_writer::start_tag('div', array('class' => 'mdl-right'));
+        $ajaxcontrol .= html_writer::start_tag('div', array('class' => 'section-modchooser'));
 
-        $courseajaxenabled = course_ajax_enabled($course);
+        $icon = $this->output->pix_icon('t/add', '');
+        $span = html_writer::tag('span', $straddeither, array('class' => 'section-modchooser-text'));
 
-        // Non ajax control is under deprecated, $rendernonajaxcontrol will be removed in later versions.
-        $rendernonajaxcontrol = !$courseajaxenabled || $course->id != $this->page->course->id;
-        if ($rendernonajaxcontrol) {
-            // The non-ajax control, which includes an entirely non-js (<noscript>) fallback too.
-            return $this->course_section_add_cm_control_nonajax($course, $section, $sectionreturn, $displayoptions);
-        } else {
-            // The ajax control - the 'Add an activity or resource' link.
-            // The module chooser link.
-            $straddeither = get_string('addresourceoractivity');
-            $ajaxcontrol = html_writer::start_tag('div', array('class' => 'mdl-right'));
-            $ajaxcontrol .= html_writer::start_tag('div', array('class' => 'section-modchooser'));
-            $icon = $this->output->pix_icon('t/add', '');
-            $span = html_writer::tag('span', $straddeither, array('class' => 'section-modchooser-text'));
-            $ajaxcontrol .= html_writer::tag('button', $icon . $span, [
-                    'class' => 'section-modchooser-link btn btn-link',
-                    'data-action' => 'open-chooser',
-                    'data-sectionid' => $section,
-                    'data-sectionreturnid' => $sectionreturn,
-                ]
-            );
-            $ajaxcontrol .= html_writer::end_tag('div');
-            $ajaxcontrol .= html_writer::end_tag('div');
+        $ajaxcontrol .= html_writer::tag('button', $icon . $span, [
+            'class' => 'section-modchooser-link btn btn-link',
+            'data-action' => 'open-chooser',
+            'data-sectionid' => $section,
+            'data-sectionreturnid' => $sectionreturn,
+        ]);
 
-            // Load the JS for the modal.
-            $this->course_activitychooser($course->id);
-            return $ajaxcontrol;
-        }
+        $ajaxcontrol .= html_writer::end_tag('div');
+        $ajaxcontrol .= html_writer::end_tag('div');
+
+        // Load the JS for the modal.
+        $this->course_activitychooser($course->id);
+
+        return $ajaxcontrol;
     }
 
     /**
diff --git a/course/tests/behat/sectionzero_title.feature b/course/tests/behat/sectionzero_title.feature
new file mode 100644 (file)
index 0000000..bd70323
--- /dev/null
@@ -0,0 +1,44 @@
+@core @core_course
+Feature: Section 0 default/custom title
+  In order to set up a course
+  As a teacher
+  I need to be able to use/change default section 0 title
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | format |
+      | Course 1 | C1 | topics |
+    And the following "activities" exist:
+      | activity | name               | intro                        | course | idnumber   | section |
+      | data     | Test database name | Test database description    | C1     | database1  | 2       |
+      | forum    | Test forum name    | Test forum name description  | C1     | forum1     | 1       |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+
+  Scenario: Default section 0 title is General
+    Given I log in as "teacher1"
+    When I am on "Course 1" course homepage with editing mode on
+    Then I should see "General" in the "li#section-0" "css_element"
+
+  @javascript
+  Scenario: Editing section 0 title
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I edit the section "0" and I fill the form with:
+      | Custom                          | 1                |
+      | New value for Section name      | Edited section 0 |
+    And I should see "Edited section 0" in the "li#section-0" "css_element"
+    When I set the field "Edit topic name" in the "li#section-0" "css_element" to ""
+    Then I should not see "Edited section 0" in the "li#section-0" "css_element"
+    And I should see "General" in the "li#section-0" "css_element"
+    And "New name for topic" "field" should not exist
+    And I set the field "Edit topic name" in the "li#section-0" "css_element" to "Edited section 0"
+    And I should see "Edited section 0" in the "li#section-0" "css_element"
+    And I edit the section "0" and I fill the form with:
+      | Custom | 0                      |
+    And I should not see "Edited section 0" in the "li#section-0" "css_element"
+    And I should see "General" in the "li#section-0" "css_element"
diff --git a/course/tests/course_delete_modules_test.php b/course/tests/course_delete_modules_test.php
new file mode 100644 (file)
index 0000000..b1991ff
--- /dev/null
@@ -0,0 +1,119 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Tests for the \core_course\task\course_delete_modules class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2021 Tomo Tsuyuki <tomotsuyuki@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace tests\core_course;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Tests for the \core_course\task\course_delete_modules class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2021 Tomo Tsuyuki <tomotsuyuki@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class course_delete_modules_test extends \advanced_testcase {
+
+    /**
+     * Test to have a no message for usual process.
+     */
+    public function test_delete_module_execution() {
+        $this->resetAfterTest();
+
+        // Generate test data.
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course = $generator->create_course();
+        $assign = $generator->create_module('assign', ['course' => $course]);
+        $assigncm = get_coursemodule_from_id('assign', $assign->cmid);
+
+        // The module exists in the course.
+        $coursedmodules = get_course_mods($course->id);
+        $this->assertCount(1, $coursedmodules);
+
+        // Execute the task.
+        $removaltask = new \core_course\task\course_delete_modules();
+        $data = [
+            'cms' => [$assigncm],
+            'userid' => $user->id,
+            'realuserid' => $user->id
+        ];
+        $removaltask->set_custom_data($data);
+        $removaltask->execute();
+
+        // The module has deleted from the course.
+        $coursedmodules = get_course_mods($course->id);
+        $this->assertCount(0, $coursedmodules);
+
+    }
+
+    /**
+     * Test to have a message in the exception.
+     */
+    public function test_delete_module_exception() {
+        global $DB;
+        $this->resetAfterTest();
+
+        // Generate test data.
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course = $generator->create_course();
+        $assign = $generator->create_module('assign', ['course' => $course]);
+        $assigncm = get_coursemodule_from_id('assign', $assign->cmid);
+
+        // Modify module name to make an exception in the course_delete_modules task.
+        $module = $DB->get_record('modules', ['id' => $assigncm->module], 'id, name', MUST_EXIST);
+        $module->name = 'TestModuleToDelete';
+        $DB->update_record('modules', $module);
+
+        // Execute the task.
+        $removaltask = new \core_course\task\course_delete_modules();
+        $data = [
+            'cms' => [$assigncm],
+            'userid' => $user->id,
+            'realuserid' => $user->id
+        ];
+        $removaltask->set_custom_data($data);
+        try {
+            $removaltask->execute();
+        } catch (\coding_exception $e) {
+            // Assert exception.
+            $this->assertInstanceOf(\coding_exception::class, $e);
+            $errormsg = $e->getMessage();
+            $this->assertStringContainsString('cannotdeletemodulemissinglib', $errormsg);
+            $this->assertStringContainsString('course/lib.php', $errormsg);
+            $this->assertStringContainsString('mod/TestModuleToDelete/lib.php is missing', $errormsg);
+            // Get line numbers array which contains the exception name.
+            $lines = array_keys(preg_grep("/cannotdeletemodulemissinglib/", file('course/lib.php')));
+            // Increase 1 to keys to convert to actual line number.
+            $lines = array_map(function($key) {
+                return ++$key;
+            }, $lines);
+            $regex = "/(\(" . implode('\))|(\(', $lines) . "\))/";
+            // Assert the error message has correct line number.
+            $this->assertMatchesRegularExpression($regex, $errormsg);
+        }
+    }
+}
diff --git a/course/tests/external/get_state_test.php b/course/tests/external/get_state_test.php
new file mode 100644 (file)
index 0000000..09e7b5c
--- /dev/null
@@ -0,0 +1,268 @@
+<?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/>.
+
+namespace core_course\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+use external_api;
+
+/**
+ * Tests for the get_state class.
+ *
+ * @package    core_course
+ * @category   test
+ * @copyright  2021 Sara Arjona (sara@moodle.com)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass \core_course\external\get_state
+ */
+class get_state_test extends \externallib_advanced_testcase {
+
+    /** @var array Sections in the testing course. */
+    private $sections;
+
+    /** @var array Activities in the testing course. */
+    private $activities;
+
+    /**
+     * Setup to ensure that fixtures are loaded.
+     */
+    public static function setupBeforeClass(): void {
+        global $CFG;
+
+        require_once($CFG->dirroot . '/course/lib.php');
+        require_once($CFG->dirroot . '/course/tests/fixtures/format_theunittest.php');
+        require_once($CFG->dirroot . '/course/tests/fixtures/format_theunittest_output_course_format_state.php');
+    }
+
+    /**
+     * Setup testcase.
+     */
+    public function setUp(): void {
+        $this->resetAfterTest();
+
+        $this->sections = [];
+        $this->activities = [];
+    }
+
+    /**
+     * Test tearDown.
+     */
+    public function tearDown(): void {
+        unset($this->sections);
+        unset($this->activities);
+    }
+
+    /**
+     * Test the behaviour of get_state::execute().
+     *
+     * @dataProvider get_state_provider
+     * @covers ::execute
+     *
+     * @param string $role The role of the user that will execute the method.
+     * @param string $format The course format of the course where the method will be executed.
+     * @param string|null $expectedexception If this call will raise an exception, this is its name.
+     */
+    public function test_get_state(string $role, string $format = 'topics', ?string $expectedexception = null): void {
+        $this->resetAfterTest();
+
+        // Create a course.
+        $numsections = 6;
+        $visiblesections = $numsections + 1; // Include topic 0.
+        $course = $this->getDataGenerator()->create_course(['numsections' => $numsections, 'format' => $format]);
+        $hiddensections = [4, 6];
+        foreach ($hiddensections as $section) {
+            set_section_visible($course->id, $section, 0);
+        }
+
+        // Create and enrol user.
+        $isadmin = ($role == 'admin');
+        $canedit = $isadmin || ($role == 'editingteacher');
+        if ($isadmin) {
+            $this->setAdminUser();
+        } else {
+            if (!$canedit) {
+                // User won't see the hidden sections. Remove them from the total.
+                $visiblesections = $visiblesections - count($hiddensections);
+            }
+            $user = $this->getDataGenerator()->create_user();
+            if ($role != 'unenroled') {
+                $this->getDataGenerator()->enrol_user($user->id, $course->id, $role);
+            }
+            $this->setUser($user);
+        }
+
+        // Add some activities to the course.
+        $this->create_activity($course->id, 'page', 1, true, $canedit);
+        $this->create_activity($course->id, 'forum', 1, true, $canedit);
+        $this->create_activity($course->id, 'book', 1, false, $canedit);
+        $this->create_activity($course->id, 'assign', 2, false, $canedit);
+        $this->create_activity($course->id, 'glossary', 4, true, $canedit);
+        $this->create_activity($course->id, 'label', 5, false, $canedit);
+        $this->create_activity($course->id, 'feedback', 5, true, $canedit);
+
+        if ($expectedexception) {
+            $this->expectException($expectedexception);
+        }
+
+        // Get course state.
+        $result = get_state::execute($course->id);
+        $result = external_api::clean_returnvalue(get_state::execute_returns(), $result);
+        $result = json_decode($result);
+        if ($format == 'social' || $format == 'theunittest') {
+            // These course format's hasn't the renderer file, so a debugging message will be displayed.
+            $this->assertDebuggingCalled();
+        }
+
+        // Check course information.
+        $this->assertEquals($numsections, $result->course->numsections);
+        $this->assertCount($visiblesections, $result->section);
+        $this->assertCount(count($this->activities), $result->cm);
+        $this->assertCount(count($result->course->sectionlist), $result->section);
+        if ($format == 'theunittest') {
+            $this->assertTrue(property_exists($result->course, 'newfancyelement'));
+        } else {
+            $this->assertFalse(property_exists($result->course, 'newfancyelement'));
+        }
+
+        // Check sections information.
+        foreach ($result->section as $section) {
+            if (in_array($section->section, $hiddensections)) {
+                $this->assertFalse($section->visible);
+            } else {
+                $this->assertTrue($section->visible);
+            }
+            // Check section is defined in course->sectionlist.
+            $this->assertContains($section->id, $result->course->sectionlist);
+            // Check course modules list for this section is the expected.
+            if (array_key_exists($section->section, $this->sections)) {
+                $this->assertEquals($this->sections[$section->section], $section->cmlist);
+            }
+        }
+        // Check course modules information.
+        foreach ($result->cm as $cm) {
+            $this->assertEquals($this->activities[$cm->id]->name, $cm->name);
+            $this->assertEquals((bool) $this->activities[$cm->id]->visible, $cm->visible);
+        }
+    }
+
+    /**
+     * Data provider for test_get_state().
+     *
+     * @return array
+     */
+    public function get_state_provider(): array {
+        return [
+            // ROLES. Testing behaviour depending on the user role calling the method.
+            'Admin user should work' => [
+                'role' => 'admin',
+            ],
+            'Editing teacher should work' => [
+                'role' => 'editingteacher',
+            ],
+            'Student should work' => [
+                'role' => 'student',
+            ],
+            'Unenroled user should raise an exception' => [
+                'role' => 'unenroled',
+                'format' => 'topics',
+                'expectedexception' => 'moodle_exception',
+            ],
+
+            // COURSEFORMAT. Test behaviour depending on course formats.
+            'Single activity format should work (admin)' => [
+                'role' => 'admin',
+                'format' => 'singleactivity',
+            ],
+            'Social format should work (admin)' => [
+                'role' => 'admin',
+                'format' => 'social',
+            ],
+            'Weeks format should work (admin)' => [
+                'role' => 'admin',
+                'format' => 'weeks',
+            ],
+            'The unit tests format should work (admin)' => [
+                'role' => 'admin',
+                'format' => 'theunittest',
+            ],
+            'Single activity format should work (student)' => [
+                'role' => 'student',
+                'format' => 'singleactivity',
+            ],
+            'Social format should work (student)' => [
+                'role' => 'student',
+                'format' => 'social',
+            ],
+            'Weeks format should work (student)' => [
+                'role' => 'student',
+                'format' => 'weeks',
+            ],
+            'The unit tests format should work (student)' => [
+                'role' => 'student',
+                'format' => 'theunittest',
+            ],
+            'Single activity format should raise an exception (unenroled)' => [
+                'role' => 'unenroled',
+                'format' => 'singleactivity',
+                'expectedexception' => 'moodle_exception',
+            ],
+            'Social format should raise an exception (unenroled)' => [
+                'role' => 'unenroled',
+                'format' => 'social',
+                'expectedexception' => 'moodle_exception',
+            ],
+            'Weeks format should raise an exception (unenroled)' => [
+                'role' => 'unenroled',
+                'format' => 'weeks',
+                'expectedexception' => 'moodle_exception',
+            ],
+            'The unit tests format should raise an exception (unenroled)' => [
+                'role' => 'unenroled',
+                'format' => 'theunittest',
+                'expectedexception' => 'moodle_exception',
+            ],
+        ];
+    }
+
+    /**
+     * Helper method to create an activity into a section and add it to the $sections and $activities arrays.
+     * For non-admin users, only visible activities will be added to the activities and sections arrays.
+     *
+     * @param int $courseid Course identifier where the activity will be added.
+     * @param string $type Activity type ('forum', 'assign', ...).
+     * @param int $section Section number where the activity will be added.
+     * @param bool $visible Whether the activity will be visible or not.
+     * @param bool $canedit Whether the activity will be accessed later by a user with editing capabilities
+     */
+    private function create_activity(int $courseid, string $type, int $section, bool $visible = true, bool $canedit = true): void {
+        $activity = $this->getDataGenerator()->create_module(
+            $type,
+            ['course' => $courseid],
+            ['section' => $section, 'visible' => $visible]
+        );
+
+        list(, $activitycm) = get_course_and_cm_from_instance($activity->id, $type);
+
+        if ($visible || $canedit) {
+            $this->activities[$activitycm->id] = $activitycm;
+            $this->sections[$section][] = $activitycm->id;
+        }
+    }
+}
diff --git a/course/tests/fixtures/format_theunittest_output_course_format_state.php b/course/tests/fixtures/format_theunittest_output_course_format_state.php
new file mode 100644 (file)
index 0000000..ce49a36
--- /dev/null
@@ -0,0 +1,39 @@
+<?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/>.
+
+namespace format_theunittest\output\course_format;
+/**
+ * Fixture for fake course format testing course format API.
+ *
+ * @package    core_course
+ * @copyright  2021 Sara Arjona (sara@moodle.com)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class state extends \core_course\output\course_format\state {
+
+    /**
+     * Export this data so it can be used as state object in the course editor.
+     *
+     * @param renderer_base $output typically, the renderer that's calling this function
+     * @return stdClass data context for a mustache template
+     */
+    public function export_for_template(\renderer_base $output): \stdClass {
+        $data = parent::export_for_template($output);
+        $data->newfancyelement = 'thatsme';
+
+        return $data;
+    }
+}
index 3f0590c..9dc3e91 100644 (file)
@@ -43,6 +43,10 @@ renderer and course format renderer:
   - start_section_list (integrated in output\course_format)
   - end_section_list (integrated in output\course_format)
   - page_title (moved to output\course_format)
+* Course formats should have a renderer (until now it was only highly recommended but not mandatory). For backwards
+compatibility (to not break third-party plugins without it), legacy_format_renderer has been created and will be used when
+course formats don't have their own renderer.
+* New external core_course\external\get_state returns current state information for a given course.
 
 === 3.11 ===
 * A new callback xxx_coursemodule_definition_after_data that allows plugins to extend activity forms after the data is set.
index 10f3a0d..798f789 100644 (file)
@@ -392,9 +392,13 @@ class course_enrolment_manager {
         ['selects' => $fieldselects, 'joins' => $fieldjoins, 'params' => $params, 'mappings' => $mappings] =
                 (array)$userfields->get_sql('u', true, '', '', false);
 
-        // Searchable fields are only the identity and name ones (not userpic).
-        $searchable = array_fill_keys($userfields->get_required_fields(
-                [fields::PURPOSE_IDENTITY, fields::PURPOSE_NAME]), true);
+        // Searchable fields are only the identity and name ones (not userpic, and without exclusions).
+        $searchablefields = fields::for_identity($this->context)->with_name();
+        $searchable = array_fill_keys($searchablefields->get_required_fields(), true);
+        if (array_key_exists('username', $searchable)) {
+            // Add the username into the mappings list from the other query, because it was excluded.
+            $mappings['username'] = 'u.username';
+        }
 
         // Add some additional sensible conditions
         $tests = array("u.id <> :guestid", 'u.deleted = 0', 'u.confirmed = 1');
index 0eaa3fe..488866a 100644 (file)
@@ -461,6 +461,12 @@ class core_course_enrolment_manager_testcase extends advanced_testcase {
         $this->assertEquals([], array_keys($users[0]));
         $users = array_values($manager->get_potential_users($enrolid, 'Frogs'));
         $this->assertEquals([], array_keys($users[0]));
+
+        // Search for username field (there is special handling for this one field).
+        set_config('showuseridentity', 'username');
+        $this->setAdminUser();
+        $users = array_values($manager->get_potential_users($enrolid, 'newuse'));
+        $this->assertEquals([$newuser->id], array_keys($users[0]));
     }
 
     /**
index 97095a0..78437a4 100644 (file)
@@ -687,7 +687,10 @@ class grade_report_grader extends grade_report {
         $headerrow->attributes['class'] = 'heading';
 
         $studentheader = new html_table_cell();
-        $studentheader->attributes['class'] = 'header';
+        // The browser's scrollbar may partly cover (in certain operative systems) the content in the student header
+        // when horizontally scrolling through the table contents (most noticeable when in RTL mode).
+        // Therefore, add slight padding on the left or right when using RTL mode.
+        $studentheader->attributes['class'] = "header pl-3";
         $studentheader->scope = 'col';
         $studentheader->header = true;
         $studentheader->id = 'studentheader';
@@ -748,6 +751,10 @@ class grade_report_grader extends grade_report {
                 $icon = $OUTPUT->pix_icon('i/enrolmentsuspended', $suspendedstring);
                 $usercell->text .= html_writer::tag('span', $icon, array('class'=>'usersuspendedicon'));
             }
+            // The browser's scrollbar may partly cover (in certain operative systems) the content in the user cells
+            // when horizontally scrolling through the table contents (most noticeable when in RTL mode).
+            // Therefore, add slight padding on the left or right when using RTL mode.
+            $usercell->attributes['class'] .= ' pl-3';
 
             $userrow->cells[] = $usercell;
 
index 92d658e..e3a6280 100644 (file)
@@ -79,7 +79,6 @@
 }
 
 .path-grade-report-grader .gradeparent .user.cell .userpicture {
-    margin: 0 4px;
     border: none;
     vertical-align: middle;
 }
index bf35822..c788630 100644 (file)
@@ -1300,6 +1300,8 @@ $string['task_type:scheduled'] = 'Scheduled';
 $string['task_result:failed'] = 'Fail';
 $string['task_stats:dbreads'] = '{$a} reads';
 $string['task_stats:dbwrites'] = '{$a} writes';
+$string['task_status'] = 'Task status';
+$string['task_status_desc'] = 'The task <q>{$a->name}</q> is <strong>{$a->status}</strong>.<br />See its <a href="{$a->gotourl}">details</a>.<br />Class: {$a->class}{$a->extradescription}';
 $string['task_starttime'] = 'Start time';
 $string['task_duration'] = 'Duration';
 $string['task_dbstats'] = 'Database';
index 1d9c1c8..8b47188 100644 (file)
Binary files a/lib/amd/build/tree.min.js and b/lib/amd/build/tree.min.js differ
index 46c0374..7d5a31e 100644 (file)
Binary files a/lib/amd/build/tree.min.js.map and b/lib/amd/build/tree.min.js.map differ
index 167e7bb..481ae19 100644 (file)
@@ -269,7 +269,8 @@ define(['jquery'], function($) {
                 var moduleName = item.closest('[data-ajax-loader]').attr('data-ajax-loader');
                 var thisTree = this;
                 // Flag this node as loading.
-                item.addClass('loading');
+                const p = item.find('p');
+                p.addClass('loading');
                 // Require the ajax module (must be AMD) and try to load the items.
                 require([moduleName], function(loader) {
                     // All ajax module must implement a "load" method.
@@ -280,7 +281,7 @@ define(['jquery'], function($) {
                         thisTree.initialiseNodes(item);
                         thisTree.finishExpandingGroup(item);
                         // Make sure no child elements of the item we just loaded are tabbable.
-                        item.removeClass('loading');
+                        p.removeClass('loading');
                         promise.resolve();
                     });
                 });
index d015c2f..49610a9 100644 (file)
@@ -173,7 +173,7 @@ class publicpaths extends check {
 
         $status = result::OK;
         $details = '';
-        $summary = '';
+        $summary = get_string('check_publicpaths_ok', 'report_security');
         $errors = [];
 
         $c = new \curl();
@@ -250,6 +250,7 @@ class publicpaths extends check {
                         } else {
                             $result = new result(result::ERROR, '', '');
                             $status = result::ERROR;
+                            $summary = get_string('check_publicpaths_warning', 'report_security');
                         }
 
                         $rowdetail = isset($path['details']) ? $path['details'] : $rowsummary;
index 69ebcc6..e4b35f8 100644 (file)
@@ -69,6 +69,7 @@ class manager {
             new performance\debugging(),
             new performance\backups(),
             new performance\stats(),
+            new performance\dbschema(),
         ];
 
         // Any plugin can add status checks to this report by implementing a callback
diff --git a/lib/classes/check/performance/dbschema.php b/lib/classes/check/performance/dbschema.php
new file mode 100644 (file)
index 0000000..cdbb895
--- /dev/null
@@ -0,0 +1,85 @@
+<?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/>.
+
+/**
+ * DB schema performance check
+ *
+ * @package    core
+ * @category   check
+ * @copyright  2021 Brendan Heywood <brendan@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\check\performance;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\check\check;
+use core\check\result;
+
+/**
+ * DB schema performance check
+ *
+ * @copyright  2021 Brendan Heywood <brendan@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class dbschema extends check {
+
+    /**
+     * Get the short check name
+     *
+     * @return string
+     */
+    public function get_name(): string {
+        return get_string('check_dbschema_name', 'report_performance');
+    }
+
+    /**
+     * A link to a place to action this
+     *
+     * @return action_link|null
+     */
+    public function get_action_link(): ?\action_link {
+        return new \action_link(
+            new \moodle_url(\get_docs_url('Verify_Database_Schema')),
+            get_string('moodledocs'));
+    }
+
+    /**
+     * Return result
+     * @return result
+     */
+    public function get_result(): result {
+        global $DB;
+
+        $dbmanager = $DB->get_manager();
+        $schema = $dbmanager->get_install_xml_schema();
+
+        if (!$errors = $dbmanager->check_database_schema($schema)) {
+            return new result(result::OK, get_string('check_dbschema_ok', 'report_performance'), '');
+        }
+
+        $details = '';
+        foreach ($errors as $tablename => $items) {
+            $details .= \html_writer::tag('h4', $tablename);
+            foreach ($items as $item) {
+                $details .= \html_writer::tag('pre', $item);
+            }
+        }
+        return new result(result::ERROR, get_string('check_dbschema_errors', 'report_performance'), $details);
+    }
+}
+
index 75effc4..3878b91 100644 (file)
@@ -118,7 +118,11 @@ class table implements \renderable {
             $row[] = $output->check_result($result);
             $row[] = $output->action_link($link, $check->get_name());
 
-            $row[] = $result->get_summary();
+            $row[] = $result->get_summary()
+                . '<br>'
+                . \html_writer::start_tag('small')
+                . $output->action_link($link, get_string('moreinfo'))
+                . \html_writer::end_tag('small');
             if ($actionlink) {
                 $row[] = $output->render($actionlink);
             } else {
index c01474e..3556c25 100644 (file)
@@ -428,8 +428,8 @@ class api {
         }
         // Get all the scopes!
         $scopes = self::get_system_scopes_for_issuer($issuer);
-
-        $client = new \core\oauth2\client($issuer, null, $scopes, true);
+        $class = self::get_client_classname($issuer->get('servicetype'));
+        $client = new $class($issuer, null, $scopes, true);
 
         if (!$client->is_logged_in()) {
             if (!$client->upgrade_refresh_token($systemaccount)) {
@@ -451,11 +451,33 @@ class api {
      */
     public static function get_user_oauth_client(issuer $issuer, moodle_url $currenturl, $additionalscopes = '',
             $autorefresh = false) {
-        $client = new \core\oauth2\client($issuer, $currenturl, $additionalscopes, false, $autorefresh);
+        $class = self::get_client_classname($issuer->get('servicetype'));
+        $client = new $class($issuer, $currenturl, $additionalscopes, false, $autorefresh);
 
         return $client;
     }
 
+    /**
+     * Get the client classname for an issuer.
+     *
+     * @param string $type The OAuth issuer type (google, facebook...).
+     * @return string The classname for the custom client or core client class if the class for the defined type
+     *                 doesn't exist or null type is defined.
+     */
+    protected static function get_client_classname(?string $type): string {
+        // Default core client class.
+        $classname = 'core\\oauth2\\client';
+
+        if (!empty($type)) {
+            $typeclassname = 'core\\oauth2\\client\\' . $type;
+            if (class_exists($typeclassname)) {
+                $classname = $typeclassname;
+            }
+        }
+
+        return $classname;
+    }
+
     /**
      * Get the list of defined endpoints for this OAuth issuer
      *
@@ -798,8 +820,8 @@ class api {
         $scopes = self::get_system_scopes_for_issuer($issuer);
 
         // Allow callbacks to inject non-standard scopes to the auth request.
-
-        $client = new client($issuer, $returnurl, $scopes, true);
+        $class = self::get_client_classname($issuer->get('servicetype'));
+        $client = new $class($issuer, $returnurl, $scopes, true);
 
         if (!optional_param('response', false, PARAM_BOOL)) {
             $client->log_out();
index 5b63753..bc2cd05 100644 (file)
@@ -501,6 +501,16 @@ class client extends \oauth2_client {
             return false;
         }
 
+        return $this->map_userinfo_to_fields($userinfo);
+    }
+
+    /**
+     * Maps the oauth2 response to userfields.
+     *
+     * @param stdClass $userinfo
+     * @return array
+     */
+    protected function map_userinfo_to_fields(stdClass $userinfo): array {
         $map = $this->get_userinfo_mapping();
 
         $user = new stdClass();
@@ -508,10 +518,25 @@ class client extends \oauth2_client {
             // We support nested objects via a-b-c syntax.
             $getfunc = function($obj, $prop) use (&$getfunc) {
                 $proplist = explode('-', $prop, 2);
-                if (empty($proplist[0]) || empty($obj->{$proplist[0]})) {
+
+                // The value of proplist[0] can be falsey, so just check if not set.
+                if (empty($obj) || !isset($proplist[0])) {
+                    return false;
+                }
+
+                if (preg_match('/^(.*)\[([0-9]*)\]$/', $proplist[0], $matches)
+                        && count($matches) == 3) {
+                    $property = $matches[1];
+                    $index = $matches[2];
+                    $obj = $obj->{$property}[$index] ?? null;
+                } else if (!empty($obj->{$proplist[0]})) {
+                    $obj = $obj->{$proplist[0]};
+                } else if (is_array($obj) && !empty($obj[$proplist[0]])) {
+                    $obj = $obj[$proplist[0]];
+                } else {
+                    // Nothing found after checking all possible valid combinations, return false.
                     return false;
                 }
-                $obj = $obj->{$proplist[0]};
 
                 if (count($proplist) > 1) {
                     return $getfunc($obj, $proplist[1]);
diff --git a/lib/classes/oauth2/client/linkedin.php b/lib/classes/oauth2/client/linkedin.php
new file mode 100644 (file)
index 0000000..521dd76
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+namespace core\oauth2\client;
+
+use core\oauth2\client;
+
+/**
+ * Class linkedin - Custom client handler to fetch data from linkedin
+ *
+ * Custom oauth2 client for linkedin as it doesn't support OIDC and has a different way to get
+ * key information for users - firstname, lastname, email.
+ *
+ * @copyright  2021 Peter Dias
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @package    core
+ */
+class linkedin extends client {
+    /**
+     * Fetch the user info from the userinfo and email endpoint and map fields back
+     *
+     * @return array|false
+     */
+    public function get_userinfo() {
+        $user = array_merge(parent::get_userinfo(), $this->get_useremail());
+        return $user;
+    }
+
+    /**
+     * Get the email address of the user from the email endpoint
+     *
+     * @return array|false
+     */
+    private function get_useremail() {
+        $url = $this->get_issuer()->get_endpoint_url('email');
+
+        $response = $this->get($url);
+        if (!$response) {
+            return false;
+        }
+        $userinfo = new \stdClass();
+        try {
+            $userinfo = json_decode($response);
+        } catch (\Exception $e) {
+            return false;
+        }
+
+        return $this->map_userinfo_to_fields($userinfo);
+    }
+}
diff --git a/lib/classes/oauth2/service/linkedin.php b/lib/classes/oauth2/service/linkedin.php
new file mode 100644 (file)
index 0000000..565c367
--- /dev/null
@@ -0,0 +1,106 @@
+<?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/>.
+
+namespace core\oauth2\service;
+
+use core\oauth2\issuer;
+use core\oauth2\endpoint;
+use core\oauth2\user_field_mapping;
+
+/**
+ * Class linkedin.
+ *
+ * Custom oauth2 issuer for linkedin as it doesn't support OIDC and has a different way to get
+ * key information for users - firstname, lastname, email.
+ *
+ * @copyright  2021 Peter Dias
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @package    core
+ */
+class linkedin implements issuer_interface {
+    /**
+     * Build an OAuth2 issuer, with all the default values for this service.
+     *
+     * @return issuer The issuer initialised with proper default values.
+     */
+    public static function init(): issuer {
+        $record = (object) [
+            'name' => 'LinkedIn',
+            'image' => 'https://static.licdn.com/scds/common/u/images/logos/favicons/v1/favicon.ico',
+            'baseurl' => 'https://api.linkedin.com/v2',
+            'loginscopes' => 'r_liteprofile r_emailaddress',
+            'loginscopesoffline' => 'r_liteprofile r_emailaddress',
+            'showonloginpage' => issuer::EVERYWHERE,
+            'servicetype' => 'linkedin',
+        ];
+
+        $issuer = new issuer(0, $record);
+        return $issuer;
+    }
+
+    /**
+     * Create endpoints for this issuer.
+     *
+     * @param issuer $issuer Issuer the endpoints should be created for.
+     * @return issuer
+     */
+    public static function create_endpoints(issuer $issuer): issuer {
+        $endpoints = [
+            'authorization_endpoint' => 'https://www.linkedin.com/oauth/v2/authorization',
+            'token_endpoint' => 'https://www.linkedin.com/oauth/v2/accessToken',
+            'email_endpoint' => 'https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))',
+            'userinfo_endpoint' => "https://api.linkedin.com/v2/me?projection=(localizedFirstName,localizedLastName,"
+                                        . "profilePicture(displayImage~digitalmediaAsset:playableStreams))",
+        ];
+        foreach ($endpoints as $name => $url) {
+            $record = (object) [
+                'issuerid' => $issuer->get('id'),
+                'name' => $name,
+                'url' => $url
+            ];
+            $endpoint = new endpoint(0, $record);
+            $endpoint->create();
+        }
+
+        // Create the field mappings.
+        $mapping = [
+            'localizedFirstName' => 'firstname',
+            'localizedLastName' => 'lastname',
+            'elements[0]-handle~-emailAddress' => 'email',
+            'profilePicture-displayImage~-elements[0]-identifiers[0]-identifier' => 'picture'
+        ];
+        foreach ($mapping as $external => $internal) {
+            $record = (object) [
+                'issuerid' => $issuer->get('id'),
+                'externalfield' => $external,
+                'internalfield' => $internal
+            ];
+            $userfieldmapping = new user_field_mapping(0, $record);
+            $userfieldmapping->create();
+        }
+
+        return $issuer;
+    }
+
+    /**
+     * Linkedin does not have a discovery url that could be found. Return empty.
+     * @param issuer $issuer
+     * @return int
+     */
+    public static function discover_endpoints($issuer): int {
+        return 0;
+    }
+}
index 04fbd1f..85e9408 100644 (file)
@@ -429,6 +429,33 @@ abstract class scheduled_task extends task_base {
         return $nexttime;
     }
 
+    /**
+     * Informs whether this task can be run.
+     * @return bool true when this task can be run. false otherwise.
+     */
+    public function can_run(): bool {
+        return $this->is_component_enabled() || $this->get_run_if_component_disabled();
+    }
+
+    /**
+     * Checks whether the component and the task disabled flag enables to run this task.
+     * This do not checks whether the task manager allows running them or if the
+     * site allows tasks to "run now".
+     * @return bool true if task is enabled. false otherwise.
+     */
+    public function is_enabled(): bool {
+        return $this->can_run() && !$this->get_disabled();
+    }
+
+    /**
+     * Produces a valid id string to use as id attribute based on the given FQCN class name.
+     * @param string $classname FQCN of a task.
+     * @return string valid string to be used as id attribute.
+     */
+    public static function get_html_id(string $classname): string {
+        return str_replace('\\', '-', ltrim($classname, '\\'));
+    }
+
     /**
      * Get a descriptive name for this task (shown to admins).
      *
index bffd736..ecc89d1 100644 (file)
@@ -208,4 +208,13 @@ abstract class task_base {
     public function get_pid() {
         return $this->pid;
     }
+
+    /**
+     * Informs whether the task's component is enabled.
+     * @return bool true when enabled. false otherwise.
+     */
+    public function is_component_enabled(): bool {
+        $plugininfo = \core_plugin_manager::instance()->get_plugin_info($this->get_component());
+        return $plugininfo && $plugininfo->is_enabled();
+    }
 }
index a6689d6..4aa9583 100644 (file)
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="The time this record was created."/>
         <FIELD NAME="usermodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="The user who modified this record."/>
         <FIELD NAME="issuerid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="The oauth issuer."/>
-        <FIELD NAME="externalfield" TYPE="char" LENGTH="64" NOTNULL="true" SEQUENCE="false" COMMENT="The fieldname returned by the userinfo endpoint."/>
+        <FIELD NAME="externalfield" TYPE="char" LENGTH="500" NOTNULL="true" SEQUENCE="false" COMMENT="The fieldname returned by the userinfo endpoint."/>
         <FIELD NAME="internalfield" TYPE="char" LENGTH="64" NOTNULL="true" SEQUENCE="false" COMMENT="The name of the Moodle field this user field maps to."/>
       </FIELDS>
       <KEYS>
index 4cfda19..f83efc8 100644 (file)
@@ -508,6 +508,12 @@ $functions = array(
         'type'        => 'read',
         'ajax'        => true,
     ),
+    'core_course_get_state' => [
+        'classname'     => 'core_course\external\get_state',
+        'description'   => 'Get the current course state.',
+        'type'          => 'read',
+        'ajax'          => true,
+    ],
     'core_course_edit_module' => array(
         'classname'   => 'core_course_external',
         'methodname'  => 'edit_module',
index 92af9fd..751081e 100644 (file)
@@ -2665,5 +2665,15 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2021060200.00);
     }
 
+    if ($oldversion < 2021060900.00) {
+        // Update the externalfield to be larger.
+        $table = new xmldb_table('oauth2_user_field_mapping');
+        $field = new xmldb_field('externalfield', XMLDB_TYPE_CHAR, '500', null, XMLDB_NOTNULL, false, null, 'issuerid');
+        $dbman->change_field_type($table, $field);
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2021060900.00);
+    }
+
     return true;
 }
index 410bb17..479d9c8 100644 (file)
@@ -240,6 +240,13 @@ class MoodleQuickForm_modgrade extends MoodleQuickForm_group {
             $label = html_writer::tag('label', $this->maxgradeformelement->getLabel(),
                 array('for' => $this->maxgradeformelement->getAttribute('id')));
             $this->_elements[] = $this->createFormElement('static', 'pointlabel', '', $label);
+
+            // Check if there are grades and if so then disable maxgradeformelement.
+            if ($this->hasgrades) {
+                // If it has grades then disable it.
+                $this->maxgradeformelement->updateAttributes(['disabled' => 'disabled']);
+            }
+
             $this->_elements[] = $this->maxgradeformelement;
             $this->_elements[] = $this->createFormElement('static', 'pointspacer', '', '<br />');
         }
diff --git a/lib/form/tests/behat/graderescale_for_database_pointscale.feature b/lib/form/tests/behat/graderescale_for_database_pointscale.feature
new file mode 100644 (file)
index 0000000..97b363c
--- /dev/null
@@ -0,0 +1,56 @@
+@mod @mod_data @core_grades @core_form
+Feature: Using the database activities which support point scale
+  validate if we can change the maximum grade when users are graded
+  As a teacher
+  I need to know whether I can not edit value of Maximum grade input field
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | student1 | Student | 1 | student1@example.com |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1 | 0 | 1 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And the following "activities" exist:
+      | activity | name               | intro | course | idnumber |
+      | data     | Test database name | n     | C1     | data1    |
+
+  @javascript
+  Scenario: Database rescale grade should not be possible when users are graded
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I add a "Text input" field to "Test database name" database and I fill the form with:
+      | Field name | Test field name |
+      | Field description | Test field description |
+    And I follow "Templates"
+    And I wait until the page is ready
+    And I am on "Course 1" course homepage
+    And I follow "Test database name"
+    And I navigate to "Edit settings" in current page administration
+    And I expand all fieldsets
+    And I set the field "Ratings > Aggregate type" to "Count of ratings"
+    And I set the field "Ratings > Type" to "Point"
+    And I press "Save and return to course"
+    And I log out
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I add an entry to "Test database name" database with:
+      | Test field name | Student original entry |
+      | Test field name | Student original entry 2 |
+    And I press "Save and view"
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test database name"
+    And I follow "View single"
+    And I set the field "rating" to "51"
+    And I am on "Course 1" course homepage
+    And I follow "Test database name"
+    When I navigate to "Edit settings" in current page administration
+    And I expand all fieldsets
+    Then the "Maximum grade" "field" should be disabled
diff --git a/lib/form/tests/behat/graderescale_for_forum_pointscale.feature b/lib/form/tests/behat/graderescale_for_forum_pointscale.feature
new file mode 100644 (file)
index 0000000..ca2f4db
--- /dev/null
@@ -0,0 +1,49 @@
+@mod @mod_forum @core_grades @core_form
+Feature: Using the forum activities which support point scale
+  validate if we can change the maximum grade when users are graded
+  As a teacher
+  I need to know whether I can not edit value of Maximum grade input field
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | student1 | Student | 1 | student1@example.com |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1 | 0 | 1 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And the following "activity" exists:
+      | course      | C1                     |
+      | activity    | forum                  |
+      | name        | Test forum name        |
+      | description | Test forum description |
+      | idnumber    | forum1                 |
+
+  @javascript
+  Scenario: Forum rescale grade should not be possible when users are graded
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I add a new discussion to "Test forum name" forum with:
+      | Subject | Discussion subject   |
+      | Message | Test post in forum 1 |
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test forum name"
+    And I navigate to "Edit settings" in current page administration
+    And I expand all fieldsets
+    And I set the field "Ratings > Aggregate type" to "Count of ratings"
+    And I set the field "Ratings > Type" to "Point"
+    And I press "Save and return to course"
+    And I follow "Test forum name"
+    And I follow "Discussion subject"
+    And I set the field "rating" to "30"
+    And I am on "Course 1" course homepage
+    And I follow "Test forum name"
+    When I navigate to "Edit settings" in current page administration
+    And I expand all fieldsets
+    Then the "Maximum grade" "field" should be disabled
diff --git a/lib/form/tests/behat/graderescale_for_glossary_pointscale.feature b/lib/form/tests/behat/graderescale_for_glossary_pointscale.feature
new file mode 100644 (file)
index 0000000..441d233
--- /dev/null
@@ -0,0 +1,51 @@
+@mod @mod_glossary @core_grades @core_form
+Feature: Using the glossary activities which support point scale
+  validate if we can change the maximum grade when users are graded
+  As a teacher
+  I need to know whether I can not edit value of Maximum grade input field
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | student1 | Student | 1 | student1@example.com |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1 | 0 | 1 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And the following "activity" exists:
+      | course      | C1                        |
+      | activity    | glossary                  |
+      | name        | Test glossary name        |
+      | description | Test glossary description |
+      | idnumber    | glossary1                 |
+
+  @javascript
+  Scenario: Glossary rescale grade should not be possible when users are graded
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Test glossary name"
+    And I press "Add a new entry"
+    And I set the following fields to these values:
+      | Concept    | Testing score                   |
+      | Definition | Scoring high on tests           |
+    And I press "Save changes"
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test glossary name"
+    And I navigate to "Edit settings" in current page administration
+    And I expand all fieldsets
+    And I set the field "Ratings > Aggregate type" to "Count of ratings"
+    And I set the field "Ratings > Type" to "Point"
+    And I press "Save and return to course"
+    And I follow "Test glossary name"
+    And I set the field "rating" to "50"
+    And I am on "Course 1" course homepage
+    And I follow "Test glossary name"
+    When I navigate to "Edit settings" in current page administration
+    And I expand all fieldsets
+    Then the "Maximum grade" "field" should be disabled
diff --git a/lib/form/tests/behat/graderescale_for_lesson_pointscale.feature b/lib/form/tests/behat/graderescale_for_lesson_pointscale.feature
new file mode 100644 (file)
index 0000000..f791014
--- /dev/null
@@ -0,0 +1,54 @@
+@mod @mod_lesson @core_grades @core_form
+Feature: Using the lesson activities which support point scale
+  validate if we can change the maximum grade when users are graded
+  As a teacher
+  I need to know whether I can not edit value of Maximum grade input field
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | student1 | Student | 1 | student1@example.com |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1 | 0 | 1 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And the following "activity" exists:
+      | course      | C1                     |
+      | activity    | lesson                 |
+      | name        | Test lesson name       |
+      | intro       | Test forum description |
+      | idnumber    | lesson1                |
+
+  @javascript
+  Scenario: Lesson rescale grade should not be possible when users are graded
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test lesson name"
+    And I follow "Add a question page"
+    And I set the field "Select a question type" to "Numerical"
+    And I press "Add a question page"
+    And I set the following fields to these values:
+      | Page title | Numerical question |
+      | Page contents | What is 1 + 2? |
+      | id_answer_editor_0 | 3 |
+      | id_jumpto_0 | End of lesson |
+      | id_enableotheranswers | 1 |
+      | id_jumpto_6 | Next page |
+    And I press "Save page"
+    And I log out
+    And I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Test lesson name"
+    And I set the field "Your answer" to "5"
+    And I press "Submit"
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test lesson name"
+    When I navigate to "Edit settings" in current page administration
+    And I expand all fieldsets
+    Then the "Maximum grade" "field" should be disabled
index f27ea4a..8464091 100644 (file)
@@ -159,6 +159,7 @@ Feature: Using the activity grade form element
     And the field "grade[modgrade_type]" matches value "Scale"
     And the field "grade[modgrade_scale]" matches value "ABCDEF"
 
+  @javascript
   Scenario: Attempting to change the maximum grade when ratings exist
     Given I log in as "teacher1"
     And the following "activities" exist:
@@ -189,14 +190,11 @@ Feature: Using the activity grade form element
     And I follow "Test forum name"
     And I follow "Discussion subject"
     And I set the field "rating" to "100"
-    And I press "Rate"
     And I follow "Test forum name"
     And I navigate to "Edit settings" in current page administration
     When I expand all fieldsets
     Then I should see "You cannot change the type, as grades already exist for this item."
-    And I set the field "Ratings > Maximum grade" to "50"
-    And I press "Save and display"
-    And I should see "You cannot change the maximum grade when grades already exist for an activity with ratings"
+    And the "Maximum grade" "field" should be disabled
 
   @javascript
   Scenario: Attempting to change the maximum grade when no rescaling option has been chosen
index 7acc898..85e628f 100644 (file)
@@ -64,8 +64,8 @@ class behat_navigation extends behat_base {
         $nodetextliteral = behat_context_helper::escape($text);
         $hasblocktree = "[contains(concat(' ', normalize-space(@class), ' '), ' block_tree ')]";
         $hasbranch = "[contains(concat(' ', normalize-space(@class), ' '), ' branch ')]";
-        $hascollapsed = "p[@aria-expanded='false']";
-        $notcollapsed = "p[@aria-expanded='true']";
+        $hascollapsed = "li[@aria-expanded='false']/p";
+        $notcollapsed = "li[@aria-expanded='true']/p";
         $match = "[normalize-space(.)={$nodetextliteral}]";
 
         // Avoid problems with quotes.
@@ -75,18 +75,18 @@ class behat_navigation extends behat_base {
         } else if ($collapsed === false) {
             $iscollapsed = $notcollapsed;
         } else {
-            $iscollapsed = 'p';
+            $iscollapsed = 'li/p';
         }
 
         // First check root nodes, it can be a span or link.
-        $xpath  = "//ul{$hasblocktree}/li/{$hascollapsed}{$isbranch}/span{$match}|";
-        $xpath  .= "//ul{$hasblocktree}/li/{$hascollapsed}{$isbranch}/a{$match}|";
+        $xpath  = "//ul{$hasblocktree}/{$hascollapsed}{$isbranch}/span{$match}|";
+        $xpath  .= "//ul{$hasblocktree}/{$hascollapsed}{$isbranch}/a{$match}|";
 
         // Next search for the node containing the text within a link.
-        $xpath .= "//ul{$hasblocktree}//ul/li/{$iscollapsed}{$isbranch}/a{$match}|";
+        $xpath .= "//ul{$hasblocktree}//ul/{$iscollapsed}{$isbranch}/a{$match}|";
 
         // Finally search for the node containing the text within a span.
-        $xpath .= "//ul{$hasblocktree}//ul/li/{$iscollapsed}{$isbranch}/span{$match}";
+        $xpath .= "//ul{$hasblocktree}//ul/{$iscollapsed}{$isbranch}/span{$match}";
 
         $node = $this->find('xpath', $xpath, $exception);
         $this->ensure_node_is_visible($node);
@@ -263,16 +263,16 @@ class behat_navigation extends behat_base {
             // The p node contains the aria jazz.
             $pnodexpath = "/p[contains(concat(' ', normalize-space(@class), ' '), ' tree_item ')]";
             $pnode = $node->find('xpath', $pnodexpath);
+            $linode = $pnode->getParent();
 
             // Keep expanding all sub-parents if js enabled.
-            if ($pnode && $this->running_javascript() && $pnode->hasAttribute('aria-expanded') &&
-                ($pnode->getAttribute('aria-expanded') == "false")) {
-
+            if ($pnode && $this->running_javascript() && $linode->hasAttribute('aria-expanded') &&
+                ($linode->getAttribute('aria-expanded') == "false")) {
                 $this->js_trigger_click($pnode);
 
                 // Wait for node to load, if not loaded before.
-                if ($pnode->hasAttribute('data-loaded') && $pnode->getAttribute('data-loaded') == "false") {
-                    $jscondition = '(document.evaluate("' . $pnode->getXpath() . '", document, null, '.
+                if ($linode->hasAttribute('data-loaded') && $linode->getAttribute('data-loaded') == "false") {
+                    $jscondition = '(document.evaluate("' . $linode->getXpath() . '", document, null, '.
                         'XPathResult.ANY_TYPE, null).iterateNext().getAttribute(\'data-loaded\') == "true")';
 
                     $this->getSession()->wait(behat_base::get_extended_timeout() * 1000, $jscondition);
diff --git a/lib/tests/client_test.php b/lib/tests/client_test.php
new file mode 100644 (file)
index 0000000..2f6a718
--- /dev/null
@@ -0,0 +1,125 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit test client_test.
+ *
+ * Unit test for testable functions in core/oauth2/client.php
+ *
+ * @copyright  2021 Peter Dias
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @package    core
+ */
+class client_test extends advanced_testcase {
+    /**
+     * Uses the static dataset as feed-in
+     *
+     * @return array
+     */
+    public function map_response_provider(): array {
+        return [
+            "Nested objects syntax a-b-c syntax " => [
+                [
+                    "name-firstname" => "firstname",
+                    "contact-phone-home" => "homenumber",
+                ], [
+                    "firstname" => "John",
+                    "homenumber" => "020000000",
+                ]
+            ],
+            "Nested objects syntax with array support a-b[0]-c syntax " => [
+                [
+                    "name-firstname" => "firstname",
+                    "contact-phone-home" => "homenumber",
+                    "picture[0]-url" => "urltest",
+                ], [
+                    "firstname" => "John",
+                    "homenumber" => "020000000",
+                    "urltest" => "www.google.com",
+                ]
+            ],
+            "Nested objects syntax with array support a-b-0-c syntax " => [
+                [
+                    "name-firstname" => "firstname",
+                    "contact-phone-home" => "homenumber",
+                    "picture-0-url" => "urltest",
+                ], [
+                    "firstname" => "John",
+                    "homenumber" => "020000000",
+                    "urltest" => "www.google.com",
+                ]
+            ],
+            "Nested objects syntax with array support a-b-0-c syntax with non-existent nodes" => [
+                [
+                    "name-firstname" => "firstname",
+                    "contact-phone-home" => "homenumber",
+                    "picture-0-url-url" => "urltest",
+                ], [
+                    "firstname" => "John",
+                    "homenumber" => "020000000",
+                ]
+            ],
+        ];
+    }
+
+    /**
+     * Test the map_userinfo_to_fields function
+     *
+     * @dataProvider map_response_provider
+     * @param array $mapping
+     * @param array $expected
+     * @throws ReflectionException
+     */
+    public function test_map_userinfo_to_fields(array $mapping, array $expected) {
+        $dataset = [
+            "name" => (object) [
+                "firstname" => "John",
+                "lastname" => "Doe",
+            ],
+            "contact" => (object) [
+                "email" => "john@example.com",
+                "phone" => (object) [
+                    "mobile" => "010000000",
+                    "home" => "020000000"
+                ],
+            ],
+            "picture" => [
+                [
+                    "url" => "www.google.com",
+                    "description" => "This is a URL",
+                ],
+                [
+                    "url" => "www.facebook.com",
+                    "description" => "This is another URL",
+                ]
+            ]
+        ];
+
+        $method = new ReflectionMethod("core\oauth2\client", "map_userinfo_to_fields");
+        $method->setAccessible(true);
+
+        $issuer = new \core\oauth2\issuer(0);
+        $mockbuilder = $this->getMockBuilder('core\oauth2\client');
+        $mockbuilder->onlyMethods(['get_userinfo_mapping']);
+        $mockbuilder->setConstructorArgs([$issuer, "", ""]);
+
+        $mock = $mockbuilder->getMock();
+        $mock->expects($this->once())
+            ->method('get_userinfo_mapping')
+            ->will($this->returnValue($mapping));
+        $this->assertSame($expected, $method->invoke($mock, (object) $dataset));
+    }
+}
index 432bcff..6652e1a 100644 (file)
@@ -82,9 +82,21 @@ if ($action === 'pollconversions') {
     $completestatuslist = [combined_document::STATUS_COMPLETE, combined_document::STATUS_FAILED];
 
     if (in_array($response->status, $readystatuslist)) {
+        // It seems that the files for this submission haven't been combined by the
+        // "\assignfeedback_editpdf\task\convert_submissions" scheduled task.
+        // Try to combine them in the user session.
         $combineddocument = document_services::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
         $response->status = $combineddocument->get_status();
         $response->filecount = $combineddocument->get_document_count();
+
+        // Check status of the combined document and remove the submission
+        // from the task queue if combination completed.
+        if (in_array($response->status, $completestatuslist)) {
+            $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
+            if ($submission) {
+                $DB->delete_records('assignfeedback_editpdf_queue', array('submissionid' => $submission->id));
+            }
+        }
     }
 
     if (in_array($response->status, $completestatuslist)) {
index b45c3e0..1649f76 100644 (file)
@@ -74,5 +74,23 @@ function xmldb_assignfeedback_editpdf_upgrade($oldversion) {
     // Automatically generated Moodle v3.9.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2021060400) {
+        // Remove submissions from the processing queue that have been processed.
+        $sql = 'DELETE
+                  FROM {assignfeedback_editpdf_queue}
+                 WHERE EXISTS (SELECT 1
+                                 FROM {assign_submission} s,
+                                      {assign_grades} g
+                                WHERE s.id = submissionid
+                                  AND s.assignment = g.assignment
+                                  AND s.userid = g.userid
+                                  AND s.attemptnumber = g.attemptnumber)';
+
+        $DB->execute($sql);
+
+        // Editpdf savepoint reached.
+        upgrade_plugin_savepoint(true, 2021060400, 'assignfeedback', 'editpdf');
+    }
+
     return true;
 }
index f7890ff..6e68362 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2021052500;
+$plugin->version   = 2021060400;
 $plugin->requires  = 2021052500;
 $plugin->component = 'assignfeedback_editpdf';
index 9a442a7..e8088f7 100644 (file)
@@ -70,23 +70,25 @@ class quiz_overview_table extends quiz_attempts_report_table {
         $this->add_separator();
 
         if (!empty($this->groupstudentsjoins->joins)) {
-            $sql = "SELECT DISTINCT u.id
+            $hasgroupstudents = $DB->record_exists_sql("
+                    SELECT 1
                       FROM {user} u
                     {$this->groupstudentsjoins->joins}
-                     WHERE {$this->groupstudentsjoins->wheres}";
-            $groupstudents = $DB->get_records_sql($sql, $this->groupstudentsjoins->params);
-            if ($groupstudents) {
+                     WHERE {$this->groupstudentsjoins->wheres}
+                    ", $this->groupstudentsjoins->params);
+            if ($hasgroupstudents) {
                 $this->add_average_row(get_string('groupavg', 'grades'), $this->groupstudentsjoins);
             }
         }
 
         if (!empty($this->studentsjoins->joins)) {
-            $sql = "SELECT DISTINCT u.id
+            $hasstudents = $DB->record_exists_sql("
+                    SELECT 1
                       FROM {user} u
                     {$this->studentsjoins->joins}
-                     WHERE {$this->studentsjoins->wheres}";
-            $students = $DB->get_records_sql($sql, $this->studentsjoins->params);
-            if ($students) {
+                     WHERE {$this->studentsjoins->wheres}
+                    " , $this->studentsjoins->params);
+            if ($hasstudents) {
                 $this->add_average_row(get_string('overallaverage', 'grades'), $this->studentsjoins);
             }
         }
index 57e0842..f717256 100644 (file)
@@ -29,6 +29,9 @@ $string['check_backup_details'] = 'Enabling automated backup will automatically
 $string['check_cachejs_comment_disable'] = 'If enabled, page loading performance is improved.';
 $string['check_cachejs_comment_enable'] = 'If disabled, page might load slow.';
 $string['check_cachejs_details'] = 'Javascript caching and compression greatly improves page loading performance. It is strongly recommended for production sites.';
+$string['check_dbschema_name'] = 'Database schema check';
+$string['check_dbschema_ok'] = 'Database schema is correct.';
+$string['check_dbschema_errors'] = 'Database schema is not aligned.';
 $string['check_debugmsg_comment_nodeveloper'] = 'If set to DEVELOPER, performance may be affected slightly.';
 $string['check_debugmsg_comment_developer'] = 'If set to a value other than DEVELOPER, performance may be improved slightly.';
 $string['check_debugmsg_details'] = 'There is rarely any advantage in going to Developer level, unless requested by a developer.<p>Once you have obtained the error message, and copied and pasted it somewhere, it is HIGHLY RECOMMENDED to turn Debug back to NONE. Debug messages can give clues to a hacker as to the setup of your site and may affect performance.</p>';
index f28a7b9..4464ac8 100644 (file)
@@ -95,6 +95,8 @@ $string['check_preventexecpath_ok'] = 'Executable paths only settable in config.
 $string['check_preventexecpath_warning'] = 'Executable paths can be set in the Admin GUI.';
 $string['check_preventexecpath_details'] = '<p>Allowing executable paths to be set via the Admin GUI is a vector for privilege escalation. This must be forced in config.php:</p><p><code>$CFG->preventexecpath = true;<code></p>';
 $string['check_publicpaths_name'] = 'Check all public / private paths';
+$string['check_publicpaths_ok'] = 'All internal paths are not publicly accessible';
+$string['check_publicpaths_warning'] = 'Some internal paths are publicly accessible';
 $string['check_publicpaths_generic'] = '{$a} files should not be public';
 $string['check_publicpaths_403'] = ' (Returned a 403, ideally should be 404)';
 $string['check_riskadmin_detailsok'] = '<p>Please verify the following list of system administrators:</p>{$a}';
index 5c5f0fa..e989e43 100644 (file)
@@ -1224,6 +1224,7 @@ M.core_filepicker.init = function(Y, options) {
             var client_id = this.options.client_id;
             var selectnode = this.selectnode;
             var getfile = selectnode.one('.fp-select-confirm');
+            var filePickerHelper = this;
             // bind labels with corresponding inputs
             selectnode.all('.fp-saveas,.fp-linktype-2,.fp-linktype-1,.fp-linktype-4,fp-linktype-8,.fp-setauthor,.fp-setlicense').each(function (node) {
                 node.all('label').set('for', node.one('input,select').generateID());
@@ -1239,6 +1240,28 @@ M.core_filepicker.init = function(Y, options) {
                         node.addClassIf('uneditable', !allowinputs);
                         node.all('input,select').set('disabled', allowinputs?'':'disabled');
                     });
+
+                    // If the link to the file is selected, only then.
+                    // Remember: this is not to be done for all repos.
+                    // Only for those repos where the filereferencewarning is set.
+                    // The value 4 represents FILE_REFERENCE here.
+                    if (e.currentTarget.get('value') === '4') {
+                        var filereferencewarning = filePickerHelper.active_repo.filereferencewarning;
+                        if (filereferencewarning) {
+                            var fileReferenceNode = e.currentTarget.ancestor('.fp-linktype-4');
+                            var fileReferenceWarningNode = Y.Node.create('<div/>').
+                                addClass('alert alert-warning px-3 py-1 my-1 small').
+                                setAttrs({role: 'alert'}).
+                                setContent(filereferencewarning);
+                            fileReferenceNode.append(fileReferenceWarningNode);
+                        }
+                    } else {
+                        var fileReferenceInput = selectnode.one('.fp-linktype-4 input');
+                        var fileReferenceWarningNode = fileReferenceInput.ancestor('.fp-linktype-4').one('.alert-warning');
+                        if (fileReferenceWarningNode) {
+                            fileReferenceWarningNode.remove();
+                        }
+                    }
                 }
             };
             selectnode.all('.fp-linktype-2,.fp-linktype-1,.fp-linktype-4,.fp-linktype-8').each(function (node) {
@@ -1574,6 +1597,8 @@ M.core_filepicker.init = function(Y, options) {
             this.active_repo.message = (data.message || '');
             this.active_repo.help = data.help?data.help:null;
             this.active_repo.manage = data.manage?data.manage:null;
+            // Warning message related to the file reference option, if applicable to the given repository.
+            this.active_repo.filereferencewarning = data.filereferencewarning ? data.filereferencewarning : null;
             this.print_header();
         },
         print_login: function(data) {
index cca3992..3375095 100644 (file)
@@ -62,3 +62,6 @@ $string['invalidresponse'] = 'Invalid server response.';
 $string['noclientconnection'] = 'The OAuth clients could not be connected.';
 $string['pathnotcreated'] = 'Folder path {$a} could not be created in the system account.';
 $string['endpointnotdefined'] = 'Endpoint {$a} not defined.';
+
+// Warnings.
+$string['externalpubliclinkwarning'] = '<b>Warning:</b> This file will become public.';
index 62f38c0..5e4dce0 100644 (file)
@@ -92,6 +92,12 @@ class repository_nextcloud extends repository {
      */
     private $controlledlinkfoldername;
 
+    /**
+     * Curl instance that can be used to fetch file from nextcloud instance.
+     * @var curl
+     */
+    private $curl;
+
     /**
      * repository_nextcloud constructor.
      *
@@ -143,6 +149,7 @@ class repository_nextcloud extends repository {
         }
 
         $this->ocsclient = new ocs_client($this->get_user_oauth_client());
+        $this->curl = new curl();
     }
 
     /**
@@ -291,6 +298,7 @@ class repository_nextcloud extends repository {
      *
      */
     public function get_link($url) {
+        // Create a read only public link, remember no update possible in this file/folder.
         $ocsparams = [
             'path' => $url,
             'shareType' => ocs_client::SHARE_TYPE_PUBLIC,
@@ -319,9 +327,16 @@ class repository_nextcloud extends repository {
      * This method does not do any translation of the file source.
      *
      * @param string $source source of the file, returned by repository as 'source' and received back from user (not cleaned)
-     * @return string file reference, ready to be stored
+     * @return string file reference, ready to be stored or json encoded string for public link reference
      */
     public function get_file_reference($source) {
+        $usefilereference = optional_param('usefilereference', false, PARAM_BOOL);
+        if ($usefilereference) {
+            return json_encode([
+                'type' => 'FILE_REFERENCE',
+                'link' => $this->get_link($source),
+            ]);
+        }
         // The simple relative path to the file is enough.
         return $source;
     }
@@ -420,6 +435,12 @@ class repository_nextcloud extends repository {
         $repositoryname = $this->get_name();
         $reference = json_decode($storedfile->get_reference());
 
+        // If the file is a reference which means its a public link in nextcloud.
+        if ($reference->type === 'FILE_REFERENCE') {
+            // This file points to the public link just fetch the latest one from nextcloud repo.
+            redirect($reference->link);
+        }
+
         // 1. assure the client and user is logged in.
         if (empty($this->client) || $this->get_system_oauth_client() === false || $this->get_system_ocs_client() === null) {
             $details = get_string('contactadminwith', 'repository_nextcloud',
@@ -751,10 +772,10 @@ class repository_nextcloud extends repository {
             } else if ($setting === 'external') {
                 return FILE_CONTROLLED_LINK;
             } else {
-                return FILE_CONTROLLED_LINK | FILE_INTERNAL;
+                return FILE_CONTROLLED_LINK | FILE_INTERNAL | FILE_REFERENCE;
             }
         } else {
-            return FILE_INTERNAL;
+            return FILE_INTERNAL | FILE_REFERENCE;
         }
     }
 
@@ -866,6 +887,7 @@ class repository_nextcloud extends repository {
             'defaultreturntype' => $this->default_returntype(),
             'manage' => $this->issuer->get('baseurl'), // Provide button to go into file management interface quickly.
             'list' => array(), // Contains all file/folder information and is required to build the file/folder tree.
+            'filereferencewarning' => get_string('externalpubliclinkwarning', 'repository_nextcloud'),
         ];
 
         // If relative path is a non-top-level path, calculate all its parents' paths.
@@ -909,4 +931,65 @@ class repository_nextcloud extends repository {
 
         return $path;
     }
+
+    /**
+     * Synchronize the external file if there is an update happened to it.
+     *
+     * If the file has been updated in the nextcloud instance, this method
+     * would take care of the file we copy into the moodle file pool.
+     *
+     * The call to this method reaches from stored_file::sync_external_file()
+     *
+     * @param stored_file $file
+     * @return bool true if synced successfully else false if not ready to sync or reference link not set
+     */
+    public function sync_reference(stored_file $file):bool {
+        global $CFG;
+
+        if ($file->get_referencelastsync() + DAYSECS > time()) {
+            // Synchronize once per day.
+            return false;
+        }
+
+        $reference = json_decode($file->get_reference());
+
+        if (!isset($reference->link)) {
+            return false;
+        }
+
+        $url = $reference->link;
+        if (file_extension_in_typegroup($file->get_filepath() . $file->get_filename(), 'web_image')) {
+            $saveas = $this->prepare_file(uniqid());
+            try {
+                $result = $this->curl->download_one($url, [], [
+                    'filepath' => $saveas,
+                    'timeout' => $CFG->repositorysyncimagetimeout,
+                    'followlocation' => true,
+                ]);
+
+                $info = $this->curl->get_info();
+
+                if ($result === true && isset($info['http_code']) && $info['http_code'] === 200) {
+                    $file->set_synchronised_content_from_file($saveas);
+                    return true;
+                }
+            } catch (Exception $e) {
+                // If the download fails lets download with get().
+                $this->curl->get($url, null, ['timeout' => $CFG->repositorysyncimagetimeout, 'followlocation' => true, 'nobody' => true]);
+                $info = $this->curl->get_info();
+
+                if (isset($info['http_code']) && $info['http_code'] === 200 &&
+                    array_key_exists('download_content_length', $info) &&
+                    $info['download_content_length'] >= 0) {
+                        $filesize = (int)$info['download_content_length'];
+                        $file->set_synchronized(null, $filesize);
+                        return true;
+                }
+
+                $file->set_missingsource();
+                return true;
+            }
+        }
+        return false;
+    }
 }
index cf6fe64..0554fec 100644 (file)
@@ -623,12 +623,12 @@ JSON;
 
     /**
      * Test supported_returntypes.
-     * FILE_INTERNAL when no system account is connected.
-     * FILE_INTERNAL | FILE_CONTROLLED_LINK when a system account is connected.
+     * FILE_INTERNAL | FILE_REFERENCE when no system account is connected.
+     * FILE_INTERNAL | FILE_CONTROLLED_LINK | FILE_REFERENCE when a system account is connected.
      */
     public function test_supported_returntypes() {
         global $DB;
-        $this->assertEquals(FILE_INTERNAL, $this->repo->supported_returntypes());
+        $this->assertEquals(FILE_INTERNAL | FILE_REFERENCE, $this->repo->supported_returntypes());
         $dataobject = new stdClass();
         $dataobject->timecreated = time();
         $dataobject->timemodified = time();
@@ -641,12 +641,12 @@ JSON;
 
         $DB->insert_record('oauth2_system_account', $dataobject);
         // When a system account is registered the file_type FILE_CONTROLLED_LINK is supported.
-        $this->assertEquals(FILE_INTERNAL | FILE_CONTROLLED_LINK,
+        $this->assertEquals(FILE_INTERNAL | FILE_CONTROLLED_LINK | FILE_REFERENCE,
             $this->repo->supported_returntypes());
     }
 
     /**
-     * The reference_file_selected() methode is called every time a FILE_CONTROLLED_LINK is chosen for upload.
+     * The reference_file_selected() method is called every time a FILE_CONTROLLED_LINK is chosen for upload.
      * Since the function is very long the private function are tested separately, and merely the abortion of the
      * function are tested.
      *
@@ -844,6 +844,150 @@ XML;
         $this->repo->send_file('', '', '', '');
     }
 
+    /**
+     * This function provides the data for test_sync_reference
+     *
+     * @return array[]
+     */
+    public function sync_reference_provider():array {
+        return [
+            'referecncelastsync done recently' => [
+                [
+                    'storedfile_record' => [
+                            'contextid' => context_system::instance()->id,
+                            'component' => 'core',
+                            'filearea'  => 'unittest',
+                            'itemid'    => 0,
+                            'filepath'  => '/',
+                            'filename'  => 'testfile.txt',
+                    ],
+                    'storedfile_reference' => json_encode(
+                        [
+                            'type' => 'FILE_REFERENCE',
+                            'link' => 'https://test.local/fakelink/',
+                            'usesystem' => true,
+                            'referencelastsync' => DAYSECS + time()
+                        ]
+                    ),
+                ],
+                'mockfunctions' => ['get_referencelastsync'],
+                'expectedresult' => false
+            ],
+            'file without link' => [
+                [
+                    'storedfile_record' => [
+                        'contextid' => context_system::instance()->id,
+                        'component' => 'core',
+                        'filearea'  => 'unittest',
+                        'itemid'    => 0,
+                        'filepath'  => '/',
+                        'filename'  => 'testfile.txt',
+                    ],
+                    'storedfile_reference' => json_encode(
+                        [
+                            'type' => 'FILE_REFERENCE',
+                            'usesystem' => true,
+                        ]
+                    ),
+                ],
+                'mockfunctions' => [],
+                'expectedresult' => false
+            ],
+            'file extenstion to exclude' => [
+                [
+                    'storedfile_record' => [
+                        'contextid' => context_system::instance()->id,
+                        'component' => 'core',
+                        'filearea'  => 'unittest',
+                        'itemid'    => 0,
+                        'filepath'  => '/',
+                        'filename'  => 'testfile.txt',
+                    ],
+                    'storedfile_reference' => json_encode(
+                        [
+                            'link' => 'https://test.local/fakelink/',
+                            'type' => 'FILE_REFERENCE',
+                            'usesystem' => true,
+                        ]
+                    ),
+                ],
+                'mockfunctions' => [],
+                'expectedresult' => false
+            ],
+            'file extenstion for image' => [
+                [
+                    'storedfile_record' => [
+                        'contextid' => context_system::instance()->id,
+                        'component' => 'core',
+                        'filearea'  => 'unittest',
+                        'itemid'    => 0,
+                        'filepath'  => '/',
+                        'filename'  => 'testfile.png',
+                    ],
+                    'storedfile_reference' => json_encode(
+                        [
+                            'link' => 'https://test.local/fakelink/',
+                            'type' => 'FILE_REFERENCE',
+                            'usesystem' => true,
+                        ]
+                    ),
+                    'mock_curl' => true,
+                ],
+                'mockfunctions' => [''],
+                'expectedresult' => true
+            ],
+        ];
+    }
+
+    /**
+     * Testing sync_reference
+     *
+     * @dataProvider sync_reference_provider
+     * @param array $storedfileargs
+     * @param array $storedfilemethodsmock
+     * @param bool $expectedresult
+     * @return void
+     */
+    public function test_sync_reference(array $storedfileargs, $storedfilemethodsmock, bool $expectedresult):void {
+        $this->resetAfterTest(true);
+
+        if (isset($storedfilemethodsmock[0])) {
+            $storedfile = $this->createMock(stored_file::class);
+
+            if ($storedfilemethodsmock[0] === 'get_referencelastsync') {
+                if (!$expectedresult) {
+                    $storedfile->method('get_referencelastsync')->willReturn(DAYSECS + time());
+                }
+            } else {
+                $storedfile->method('get_referencelastsync')->willReturn(null);
+            }
+
+            $storedfile->method('get_reference')->willReturn($storedfileargs['storedfile_reference']);
+            $storedfile->method('get_filepath')->willReturn($storedfileargs['storedfile_record']['filepath']);
+            $storedfile->method('get_filename')->willReturn($storedfileargs['storedfile_record']['filename']);
+
+            if ((isset($storedfileargs['mock_curl']) && $storedfileargs)) {
+                // Lets mock curl, else it would not serve the purpose here.
+                $curl = $this->createMock(curl::class);
+                $curl->method('download_one')->willReturn(true);
+                $curl->method('get_info')->willReturn(['http_code' => 200]);
+
+                $reflectionproperty = new \ReflectionProperty($this->repo, 'curl');
+                $reflectionproperty->setAccessible(true);
+                $reflectionproperty->setValue($this->repo, $curl);
+            }
+        } else {
+            $fs = get_file_storage();
+            $storedfile = $fs->create_file_from_reference(
+                $storedfileargs['storedfile_record'],
+                $this->repo->id,
+                $storedfileargs['storedfile_reference']);
+        }
+
+        $actualresult = $this->repo->sync_reference($storedfile);
+        $this->assertEquals($expectedresult, $actualresult);
+    }
+
     /**
      * Helper method, which inserts a given mock value into the repository_nextcloud object.
      *
@@ -879,6 +1023,8 @@ XML;
         $ret['defaultreturntype'] = FILE_INTERNAL;
         $ret['list'] = array();
 
+        $ret['filereferencewarning'] = get_string('externalpubliclinkwarning', 'repository_nextcloud');
+
         return $ret;
     }
 }
index 95ac0b7..6ca04b5 100644 (file)
@@ -322,8 +322,8 @@ body.drawer-open-left #region-main.has-blocks {
 .block_navigation .block_tree [aria-expanded="false"] {
     background-image: none;
 }
-.block_settings .block_tree [aria-expanded="true"]:before,
-.block_navigation .block_tree [aria-expanded="true"]:before {
+.block_settings .block_tree [aria-expanded="true"] > p:before,
+.block_navigation .block_tree [aria-expanded="true"] > p:before {
     content: $fa-var-angle-down;
     margin-right: 0;
     @include fa-icon();
@@ -331,8 +331,8 @@ body.drawer-open-left #region-main.has-blocks {
     width: 16px;
 }
 
-.block_settings .block_tree [aria-expanded="false"]:before,
-.block_navigation .block_tree [aria-expanded="false"]:before {
+.block_settings .block_tree [aria-expanded="false"] > p:before,
+.block_navigation .block_tree [aria-expanded="false"] > p:before {
     content: $fa-var-angle-right;
     margin-right: 0;
     @include fa-icon();
@@ -340,8 +340,8 @@ body.drawer-open-left #region-main.has-blocks {
     width: 16px;
 }
 .dir-rtl {
-    .block_settings .block_tree [aria-expanded="false"]:before,
-    .block_navigation .block_tree [aria-expanded="false"]:before {
+    .block_settings .block_tree [aria-expanded="false"] > p:before,
+    .block_navigation .block_tree [aria-expanded="false"] > p:before {
         content: $fa-var-angle-left;
     }
 }
index 90d9458..4cd3903 100644 (file)
@@ -12744,8 +12744,8 @@ input[disabled] {
 .block_navigation .block_tree [aria-expanded="false"] {
   background-image: none; }
 
-.block_settings .block_tree [aria-expanded="true"]:before,
-.block_navigation .block_tree [aria-expanded="true"]:before {
+.block_settings .block_tree [aria-expanded="true"] > p:before,
+.block_navigation .block_tree [aria-expanded="true"] > p:before {
   content: "";
   margin-right: 0;
   display: inline-block;
@@ -12757,8 +12757,8 @@ input[disabled] {
   font-size: 16px;
   width: 16px; }
 
-.block_settings .block_tree [aria-expanded="false"]:before,
-.block_navigation .block_tree [aria-expanded="false"]:before {
+.block_settings .block_tree [aria-expanded="false"] > p:before,
+.block_navigation .block_tree [aria-expanded="false"] > p:before {
   content: "";
   margin-right: 0;
   display: inline-block;
@@ -12770,8 +12770,8 @@ input[disabled] {
   font-size: 16px;
   width: 16px; }
 
-.dir-rtl .block_settings .block_tree [aria-expanded="false"]:before,
-.dir-rtl .block_navigation .block_tree [aria-expanded="false"]:before {
+.dir-rtl .block_settings .block_tree [aria-expanded="false"] > p:before,
+.dir-rtl .block_navigation .block_tree [aria-expanded="false"] > p:before {
   content: ""; }
 
 .block_navigation .block_tree p.hasicon,
index 03e9517..e85e3d0 100644 (file)
@@ -5,7 +5,7 @@ $gray-200: #e9ecef !default;
 $gray-300: #dee2e6 !default;
 $gray-400: #ced4da !default;
 $gray-500: #8f959e !default;
-$gray-600: #6c757d !default;
+$gray-600: #6e7377 !default;
 $gray-700: #495057 !default;
 $gray-800: #343a40 !default;
 $gray-900: #212529 !default;
index 1fd4946..d444630 100644 (file)
   --teal: #20c997;
   --cyan: #008196;
   --white: #fff;
-  --gray: #6c757d;
+  --gray: #6e7377;
   --gray-dark: #343a40;
   --primary: #0f6fc5;
   --secondary: #ced4da;
@@ -2505,7 +2505,7 @@ table {
 caption {
   padding-top: 0.75rem;
   padding-bottom: 0.75rem;
-  color: #6c757d;
+  color: #6e7377;
   text-align: left;
   caption-side: bottom; }
 
@@ -2750,7 +2750,7 @@ mark,
 .blockquote-footer {
   display: block;
   font-size: 80%;
-  color: #6c757d; }
+  color: #6e7377; }
   .blockquote-footer::before {
     content: "\2014\00A0"; }
 
@@ -2775,7 +2775,7 @@ mark,
 
 .figure-caption {
   font-size: 90%;
-  color: #6c757d; }
+  color: #6e7377; }
 
 code {
   font-size: 87.5%;
@@ -3783,7 +3783,7 @@ pre {
     outline: 0;
     box-shadow: 0 0 0 0.2rem rgba(15, 111, 197, 0.75); }
   .form-control::placeholder {
-    color: #6c757d;
+    color: #6e7377;
     opacity: 1; }
   .form-control:disabled, .form-control[readonly] {
     background-color: #e9ecef;
@@ -3895,7 +3895,7 @@ textarea.form-control {
   margin-left: -1.25rem; }
   .form-check-input[disabled] ~ .form-check-label,
   .form-check-input:disabled ~ .form-check-label {
-    color: #6c757d; }
+    color: #6e7377; }
 
 .form-check-label {
   margin-bottom: 0; }
@@ -4545,7 +4545,7 @@ fieldset:disabled a.btn {
   .btn-link:focus, .btn-link.focus {
     text-decoration: underline; }
   .btn-link:disabled, .btn-link.disabled {
-    color: #6c757d;
+    color: #6e7377;
     pointer-events: none; }
 
 .btn-lg, .btn-group-lg > .btn {
@@ -4791,7 +4791,7 @@ input[type="button"].btn-block {
   padding: 0.5rem 1.5rem;
   margin-bottom: 0;
   font-size: 0.8203125rem;
-  color: #6c757d;
+  color: #6e7377;
   white-space: nowrap; }
 
 .dropdown-item-text {
@@ -5083,7 +5083,7 @@ input[type="button"].btn-block {
     background-color: #90c6f7;
     border-color: #90c6f7; }
   .custom-control-input[disabled] ~ .custom-control-label, .custom-control-input:disabled ~ .custom-control-label {
-    color: #6c757d; }
+    color: #6e7377; }
     .custom-control-input[disabled] ~ .custom-control-label::before, .custom-control-input:disabled ~ .custom-control-label::before {
       background-color: #e9ecef; }
 
@@ -5193,7 +5193,7 @@ input[type="button"].btn-block {
     padding-right: 0.75rem;
     background-image: none; }
   .custom-select:disabled {
-    color: #6c757d;
+    color: #6e7377;
     background-color: #e9ecef; }
   .custom-select::-ms-expand {
     display: none; }
@@ -5399,7 +5399,7 @@ input[type="button"].btn-block {
   .nav-link:hover, .nav-link:focus {
     text-decoration: none; }
   .nav-link.disabled {
-    color: #6c757d;
+    color: #6e7377;
     pointer-events: none;
     cursor: default; }
 
@@ -5413,7 +5413,7 @@ input[type="button"].btn-block {
     .nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus {
       border-color: #e9ecef #e9ecef #dee2e6; }
     .nav-tabs .nav-link.disabled {
-      color: #6c757d;
+      color: #6e7377;
       background-color: transparent;
       border-color: transparent; }
   .nav-tabs .nav-link.active,
@@ -5916,7 +5916,7 @@ input[type="button"].btn-block {
   .breadcrumb-item + .breadcrumb-item::before {
     float: left;
     padding-right: 0.5rem;
-    color: #6c757d;
+    color: #6e7377;
     content: "/"; }
 
 .breadcrumb-item + .breadcrumb-item:hover::before {
@@ -5926,7 +5926,7 @@ input[type="button"].btn-block {
   text-decoration: none; }
 
 .breadcrumb-item.active {
-  color: #6c757d; }
+  color: #6e7377; }
 
 .pagination {
   display: flex;
@@ -5970,7 +5970,7 @@ input[type="button"].btn-block {
   border-color: #0f6fc5; }
 
 .page-item.disabled .page-link {
-  color: #6c757d;
+  color: #6e7377;
   pointer-events: none;
   cursor: auto;
   background-color: #fff;
@@ -6301,7 +6301,7 @@ input[type="button"].btn-block {
     border-bottom-right-radius: inherit;
     border-bottom-left-radius: inherit; }
   .list-group-item.disabled, .list-group-item:disabled {
-    color: #6c757d;
+    color: #6e7377;
     pointer-events: none;
     background-color: #fff; }
   .list-group-item.active {
@@ -9745,7 +9745,7 @@ a.text-dark:hover, a.text-dark:focus {
   color: #212529 !important; }
 
 .text-muted {
-  color: #6c757d !important; }
+  color: #6e7377 !important; }
 
 .text-black-50 {
   color: rgba(0, 0, 0, 0.5) !important; }
@@ -9968,7 +9968,7 @@ a.dimmed_text:visited,
 .usersuspended a:visited,
 .dimmed_category,
 .dimmed_category a {
-  color: #6c757d; }
+  color: #6e7377; }
 
 .aalink.focus, .aalink:focus,
 #page-footer a:not([class]).focus,
@@ -10618,7 +10618,7 @@ tr.flagged-tag a {
   padding-left: 10px; }
 
 .tag_feed .media .muted a {
-  color: #6c757d; }
+  color: #6e7377; }
 
 .tag_cloud {
   text-align: center; }
@@ -11255,7 +11255,7 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview {
     .modchoosercontainer .optionscontainer .option .optionactions .optionaction,
     .modchoosercontainer .searchresultitemscontainer .option .optionactions .optionaction {
       cursor: pointer;
-      color: #6c757d; }
+      color: #6e7377; }
       .modchoosercontainer .optionscontainer .option .optionactions .optionaction i,
       .modchoosercontainer .searchresultitemscontainer .option .optionactions .optionaction i {
         margin: 0; }
@@ -12078,22 +12078,22 @@ input[disabled] {
       margin: 0.125rem; }
       .nav-tabs .nav-link.active,
       .nav-pills .nav-link.active {
-        color: #6c757d;
-        border-color: #6c757d;
-        border-color: #6c757d; }
+        color: #6e7377;
+        border-color: #6e7377;
+        border-color: #6e7377; }
         .nav-tabs .nav-link.active:hover,
         .nav-pills .nav-link.active:hover {
           color: #fff;
-          background-color: #6c757d;
-          border-color: #6c757d; }
+          background-color: #6e7377;
+          border-color: #6e7377; }
         .nav-tabs .nav-link.active:focus, .nav-tabs .nav-link.active.focus,
         .nav-pills .nav-link.active:focus,
         .nav-pills .nav-link.active.focus {
-          box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5); }
+          box-shadow: 0 0 0 0.2rem rgba(110, 115, 119, 0.5); }
         .nav-tabs .nav-link.active.disabled, .nav-tabs .nav-link.active:disabled,
         .nav-pills .nav-link.active.disabled,
         .nav-pills .nav-link.active:disabled {
-          color: #6c757d;
+          color: #6e7377;
           background-color: transparent; }
         .nav-tabs .nav-link.active:not(:disabled):not(.disabled):active, .nav-tabs .nav-link.active:not(:disabled):not(.disabled).active,
         .show > .nav-tabs .nav-link.active.dropdown-toggle,
@@ -12102,15 +12102,15 @@ input[disabled] {
         .show >
         .nav-pills .nav-link.active.dropdown-toggle {
           color: #fff;
-          background-color: #6c757d;
-          border-color: #6c757d; }
+          background-color: #6e7377;
+          border-color: #6e7377; }
           .nav-tabs .nav-link.active:not(:disabled):not(.disabled):active:focus, .nav-tabs .nav-link.active:not(:disabled):not(.disabled).active:focus,
           .show > .nav-tabs .nav-link.active.dropdown-toggle:focus,
           .nav-pills .nav-link.active:not(:disabled):not(.disabled):active:focus,
           .nav-pills .nav-link.active:not(:disabled):not(.disabled).active:focus,
           .show >
           .nav-pills .nav-link.active.dropdown-toggle:focus {
-            box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5); } }
+            box-shadow: 0 0 0 0.2rem rgba(110, 115, 119, 0.5); } }
 
 @media (max-width: 576px) and (max-height: 320px) {
   div#page {
@@ -12609,12 +12609,12 @@ input[disabled] {
 
 #page-admin-plugins #plugins-control-panel .pluginname .componentname {
   font-size: 0.8203125rem;
-  color: #6c757d;
+  color: #6e7377;
   margin-left: 22px; }
 
 #page-admin-plugins #plugins-control-panel .version .versionnumber {
   font-size: 0.8203125rem;
-  color: #6c757d; }
+  color: #6e7377; }
 
 #page-admin-plugins #plugins-control-panel .uninstall a {
   color: #ca3120; }
@@ -12624,7 +12624,7 @@ input[disabled] {
 
 #page-admin-plugins #plugins-control-panel .notes .requiredby {
   font-size: 0.8203125rem;
-  color: #6c757d; }
+  color: #6e7377; }
 
 #plugins-check-page #plugins-check .status-missing td,
 #plugins-check-page #plugins-check .status-downgrade td {
@@ -12729,7 +12729,7 @@ input[disabled] {
 
 #page-admin-tasklogs .task-class {
   font-size: 0.8203125rem;
-  color: #6c757d; }
+  color: #6e7377; }
 
 .path-admin-tool-uploaduser .uuwarning {
   background-color: #fcefdc; }
@@ -12966,8 +12966,8 @@ input[disabled] {
 .block_navigation .block_tree [aria-expanded="false"] {
   background-image: none; }
 
-.block_settings .block_tree [aria-expanded="true"]:before,
-.block_navigation .block_tree [aria-expanded="true"]:before {
+.block_settings .block_tree [aria-expanded="true"] > p:before,
+.block_navigation .block_tree [aria-expanded="true"] > p:before {
   content: "";
   margin-right: 0;
   display: inline-block;
@@ -12979,8 +12979,8 @@ input[disabled] {
   font-size: 16px;
   width: 16px; }
 
-.block_settings .block_tree [aria-expanded="false"]:before,
-.block_navigation .block_tree [aria-expanded="false"]:before {
+.block_settings .block_tree [aria-expanded="false"] > p:before,
+.block_navigation .block_tree [aria-expanded="false"] > p:before {
   content: "";
   margin-right: 0;
   display: inline-block;
@@ -12992,8 +12992,8 @@ input[disabled] {
   font-size: 16px;
   width: 16px; }
 
-.dir-rtl .block_settings .block_tree [aria-expanded="false"]:before,
-.dir-rtl .block_navigation .block_tree [aria-expanded="false"]:before {
+.dir-rtl .block_settings .block_tree [aria-expanded="false"] > p:before,
+.dir-rtl .block_navigation .block_tree [aria-expanded="false"] > p:before {
   content: ""; }
 
 .block_navigation .block_tree p.hasicon,
@@ -13004,7 +13004,7 @@ input[disabled] {
     margin-right: 2px; }
 
 .block.invisibleblock .card-title {
-  color: #6c757d; }
+  color: #6e7377; }
 
 @media (max-width: 767.98px) {
   .block.card {
@@ -13226,7 +13226,7 @@ input[disabled] {
     font-size: 0.8em;
     text-align: center; }
   .block .minicalendar td.weekend {
-    color: #6c757d; }
+    color: #6e7377; }
   .block .minicalendar td a {
     width: 100%;
     height: 100%;
@@ -13342,7 +13342,7 @@ table.calendartable caption {
     margin: 0; }
 
 .cal_courses_flt {
-  color: #6c757d; }
+  color: #6e7377; }
 
 .content-bank-container .cb-content-wrapper {
   padding: 0.5rem;
@@ -13429,7 +13429,7 @@ table.calendartable caption {
 
 .content-bank-container.view-list .cb-listitem.cb-unlisted .cb-column,
 .content-bank-container.view-list .cb-listitem.cb-unlisted .cb-column a {
-  color: #6c757d; }
+  color: #6e7377; }
 
 @media (max-width: 767.98px) {
   .content-bank-container.view-list .cb-column {
@@ -13544,7 +13544,7 @@ body:not(.editing) .sitetopic ul.section {
     .section .activity .activityinstance .dimmed .activityicon {
       opacity: .5; }
   .section .activity .stealth {
-    color: #6c757d; }
+    color: #6e7377; }
   .section .activity a.stealth,
   .section .activity a.stealth:hover {
     color: #61aef3 !important;
@@ -13670,7 +13670,7 @@ body:not(.editing) .sitetopic ul.section {
   margin: 2px 5px 2px 5px; }
 
 .course-content .section-summary .section-summary-activities .activity-count {
-  color: #6c757d;
+  color: #6e7377;
   font-size: 0.8203125rem;
   margin: 3px;
   white-space: nowrap;
@@ -13720,7 +13720,7 @@ body:not(.editing) .sitetopic ul.section {
 .course-content ul li.section.hidden .sectionname > span,
 .course-content ul li.section.hidden .content > div.summary,
 .course-content ul li.section.hidden .activity .activityinstance {
-  color: #6c757d; }
+  color: #6e7377; }
 
 .course-content ul.topics,
 .course-content ul.weeks {
@@ -14168,9 +14168,9 @@ span.editinstructions {
   #course-category-listings .listitem > div .idnumber {
     margin-right: 2em; }
   #course-category-listings .listitem[data-visible="0"] {
-    color: #6c757d; }
+    color: #6e7377; }
     #course-category-listings .listitem[data-visible="0"] > div > a {
-      color: #6c757d; }
+      color: #6e7377; }
     #course-category-listings .listitem[data-visible="0"] > div .item-actions .action-show {
       display: inline; }
     #course-category-listings .listitem[data-visible="0"] > div .item-actions .action-hide {
@@ -14280,7 +14280,7 @@ span.editinstructions {
   #course-category-listings .listing-pagination-totals {
     text-align: center; }
     #course-category-listings .listing-pagination-totals.dimmed {
-      color: #6c757d;
+      color: #6e7377;
       margin: 0.4rem 1rem 0.45rem; }
   #course-category-listings .select-a-category .notifymessage,
   #course-category-listings .select-a-category .alert {
@@ -15017,7 +15017,7 @@ a.ygtvspacer:hover {
   background-color: #ebebe4; }
 
 .fitem.disabled .fp-btn-choose {
-  color: #6c757d; }
+  color: #6e7377; }
 
 .fitem.disabled .filepicker-filelist .filepicker-filename {
   display: none; }
@@ -16553,14 +16553,14 @@ body.path-question-type .mform fieldset.hidden {
 
 .simplesearchform .btn-submit {
   border-color: #8f959e;
-  color: #6c757d; }
+  color: #6e7377; }
 
 .simplesearchform .btn-close,
 .simplesearchform .btn-clear {
   position: absolute;
   top: 0;
   right: 0;
-  color: #6c757d;
+  color: #6e7377;
   z-index: 4; }
 
 .simplesearchform .btn-close {
@@ -16687,7 +16687,7 @@ input#id_externalurl {
 
 .form-defaultinfo,
 .form-label .form-shortname {
-  color: #6c757d; }
+  color: #6e7377; }
 
 .form-label .form-shortname {
   font-size: 0.703125rem;
@@ -16700,10 +16700,10 @@ input#id_externalurl {
   margin-left: 0.5rem; }
 
 .formsettingheading .form-horizontal {
-  color: #6c757d; }
+  color: #6e7377; }
 
 .no-felement.fstatic {
-  color: #6c757d;
+  color: #6e7377;
   padding-top: 5px; }
 
 .no-fitem .fstaticlabel {
@@ -17868,7 +17868,7 @@ div#dock {
 .path-mod-quiz #mod_quiz_navblock .qnbutton.complete .trafficlight,
 .path-mod-quiz #mod_quiz_navblock .qnbutton.answersaved .trafficlight,
 .path-mod-quiz #mod_quiz_navblock .qnbutton.requiresgrading .trafficlight {
-  background-color: #6c757d; }
+  background-color: #6e7377; }
 
 #page-mod-quiz-edit ul.slots li.section li.activity .instancemaxmarkcontainer form input {
   height: 1.4em;
@@ -18261,7 +18261,7 @@ div#dock {
   margin-top: 1rem;
   margin-bottom: 1rem; }
   .path-backup .backup_progress .backup_stage {
-    color: #6c757d; }
+    color: #6e7377; }
     .path-backup .backup_progress .backup_stage.backup_stage_current {
       font-weight: bold;
       color: inherit; }
@@ -19522,7 +19522,7 @@ span[data-flexitour="container"][x-placement="right"], span[data-flexitour="cont
       font-size: 4em; }
 
 .form-control:-ms-input-placeholder {
-  color: #6c757d; }
+  color: #6e7377; }
 
 .custom-select {
   -webkit-appearance: none;
@@ -19961,26 +19961,26 @@ body {
   background-color: #fff; }
 
 .btn-outline-secondary {
-  color: #6c757d;
-  border-color: #6c757d;
-  border-color: #6c757d; }
+  color: #6e7377;
+  border-color: #6e7377;
+  border-color: #6e7377; }
   .btn-outline-secondary:hover {
     color: #fff;
-    background-color: #6c757d;
-    border-color: #6c757d; }
+    background-color: #6e7377;
+    border-color: #6e7377; }
   .btn-outline-secondary:focus, .btn-outline-secondary.focus {
-    box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5); }
+    box-shadow: 0 0 0 0.2rem rgba(110, 115, 119, 0.5); }
   .btn-outline-secondary.disabled, .btn-outline-secondary:disabled {
-    color: #6c757d;
+    color: #6e7377;
     background-color: transparent; }
   .btn-outline-secondary:not(:disabled):not(.disabled):active, .btn-outline-secondary:not(:disabled):not(.disabled).active,
   .show > .btn-outline-secondary.dropdown-toggle {
     color: #fff;
-    background-color: #6c757d;
-    border-color: #6c757d; }
+    background-color: #6e7377;
+    border-color: #6e7377; }
     .btn-outline-secondary:not(:disabled):not(.disabled):active:focus, .btn-outline-secondary:not(:disabled):not(.disabled).active:focus,
     .show > .btn-outline-secondary.dropdown-toggle:focus {
-      box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5); }
+      box-shadow: 0 0 0 0.2rem rgba(110, 115, 119, 0.5); }
 
 .btn-outline-info {
   color: #1f7e9a;
index 4e5dbfe..f5ac4e7 100644 (file)
@@ -380,6 +380,10 @@ class fields {
                         case PROFILE_VISIBLE_PRIVATE:
                             $allowed = !$context || has_capability('moodle/user:viewalldetails', $context);
                             break;
+                        case PROFILE_VISIBLE_TEACHERS:
+                            // This is actually defined (in user/profile/lib.php) based on whether
+                            // you have moodle/site:viewuseridentity in context. We already checked
+                            // that, so treat it as visible (fall through).
                         case PROFILE_VISIBLE_ALL:
                             $allowed = true;
                             break;
index 86381b2..0f402df 100644 (file)
@@ -564,106 +564,143 @@ class core_user_external extends external_api {
                 'maxfiles'       => 1,
                 'accepted_types' => 'optimised_image');
 
-        $transaction = $DB->start_delegated_transaction();
-
+        $warnings = array();
         foreach ($params['users'] as $user) {
-            // First check the user exists.
-            if (!$existinguser = core_user::get_user($user['id'])) {
-                continue;
-            }
-            // Check if we are trying to update an admin.
-            if ($existinguser->id != $USER->id and is_siteadmin($existinguser) and !is_siteadmin($USER)) {
-                continue;
-            }
-            // Other checks (deleted, remote or guest users).
-            if ($existinguser->deleted or is_mnet_remote_user($existinguser) or isguestuser($existinguser->id)) {
-                continue;
-            }
-            // Check duplicated emails.
-            if (isset($user['email']) && $user['email'] !== $existinguser->email) {
-                if (!validate_email($user['email'])) {
-                    continue;
-                } else if (empty($CFG->allowaccountssameemail)) {
-                    // Make a case-insensitive query for the given email address and make sure to exclude the user being updated.
-                    $select = $DB->sql_equal('email', ':email', false) . ' AND mnethostid = :mnethostid AND id <> :userid';
-                    $params = array(
-                        'email' => $user['email'],
-                        'mnethostid' => $CFG->mnet_localhost_id,
-                        'userid' => $user['id']
-                    );
-                    // Skip if there are other user(s) that already have the same email.
-                    if ($DB->record_exists_select('user', $select, $params)) {
-                        continue;
+            // Catch any exception while updating a user and return it as a warning.
+            try {
+                $transaction = $DB->start_delegated_transaction();
+
+                // First check the user exists.
+                if (!$existinguser = core_user::get_user($user['id'])) {
+                    throw new moodle_exception('invaliduserid', '', '', null,
+                            'Invalid user ID');
+                }
+                // Check if we are trying to update an admin.
+                if ($existinguser->id != $USER->id and is_siteadmin($existinguser) and !is_siteadmin($USER)) {
+                    throw new moodle_exception('usernotupdatedadmin', '', '', null,
+                            'Cannot update admin accounts');
+                }
+                // Other checks (deleted, remote or guest users).
+                if ($existinguser->deleted) {
+                    throw new moodle_exception('usernotupdateddeleted', '', '', null,
+                            'User is a deleted user');
+                }
+                if (is_mnet_remote_user($existinguser)) {
+                    throw new moodle_exception('usernotupdatedremote', '', '', null,
+                            'User is a remote user');
+                }
+                if (isguestuser($existinguser->id)) {
+                    throw new moodle_exception('usernotupdatedguest', '', '', null,
+                            'Cannot update guest account');
+                }
+                // Check duplicated emails.
+                if (isset($user['email']) && $user['email'] !== $existinguser->email) {
+                    if (!validate_email($user['email'])) {
+                        throw new moodle_exception('useremailinvalid', '', '', null,
+                                'Invalid email address');
+                    } else if (empty($CFG->allowaccountssameemail)) {
+                        // Make a case-insensitive query for the given email address
+                        // and make sure to exclude the user being updated.
+                        $select = $DB->sql_equal('email', ':email', false) . ' AND mnethostid = :mnethostid AND id <> :userid';
+                        $params = array(
+                            'email' => $user['email'],
+                            'mnethostid' => $CFG->mnet_localhost_id,
+                            'userid' => $user['id']
+                        );
+                        // Skip if there are other user(s) that already have the same email.
+                        if ($DB->record_exists_select('user', $select, $params)) {
+                            throw new moodle_exception('useremailduplicate', '', '', null,
+                                    'Duplicate email address');
+                        }
                     }
                 }
-            }
 
-            user_update_user($user, true, false);
+                user_update_user($user, true, false);
 
-            $userobject = (object)$user;
+                $userobject = (object)$user;
 
-            // Update user picture if it was specified for this user.
-            if (empty($CFG->disableuserimages) && isset($user['userpicture'])) {
-                $userobject->deletepicture = null;
+                // Update user picture if it was specified for this user.
+                if (empty($CFG->disableuserimages) && isset($user['userpicture'])) {
+                    $userobject->deletepicture = null;
 
-                if ($user['userpicture'] == 0) {
-                    $userobject->deletepicture = true;
-                } else {
-                    $userobject->imagefile = $user['userpicture'];
-                }
+                    if ($user['userpicture'] == 0) {
+                        $userobject->deletepicture = true;
+                    } else {
+                        $userobject->imagefile = $user['userpicture'];
+                    }
 
-                core_user::update_picture($userobject, $filemanageroptions);
-            }
+                    core_user::update_picture($userobject, $filemanageroptions);
+                }
 
-            // Update user interests.
-            if (!empty($user['interests'])) {
-                $trimmedinterests = array_map('trim', explode(',', $user['interests']));
-                $interests = array_filter($trimmedinterests, function($value) {
-                    return !empty($value);
-                });
-                useredit_update_interests($userobject, $interests);
-            }
+                // Update user interests.
+                if (!empty($user['interests'])) {
+                    $trimmedinterests = array_map('trim', explode(',', $user['interests']));
+                    $interests = array_filter($trimmedinterests, function($value) {
+                        return !empty($value);
+                    });
+                    useredit_update_interests($userobject, $interests);
+                }
 
-            // Update user custom fields.
-            if (!empty($user['customfields'])) {
+                // Update user custom fields.
+                if (!empty($user['customfields'])) {
 
-                foreach ($user['customfields'] as $customfield) {
-                    // Profile_save_data() saves profile file it's expecting a user with the correct id,
-                    // and custom field to be named profile_field_"shortname".
-                    $user["profile_field_".$customfield['type']] = $customfield['value'];
+                    foreach ($user['customfields'] as $customfield) {
+                        // Profile_save_data() saves profile file it's expecting a user with the correct id,
+                        // and custom field to be named profile_field_"shortname".
+                        $user["profile_field_".$customfield['type']] = $customfield['value'];
+                    }
+                    profile_save_data((object) $user);
                 }
-                profile_save_data((object) $user);
-            }
 
-            // Trigger event.
-            \core\event\user_updated::create_from_userid($user['id'])->trigger();
+                // Trigger event.
+                \core\event\user_updated::create_from_userid($user['id'])->trigger();
 
-            // Preferences.
-            if (!empty($user['preferences'])) {
-                $userpref = clone($existinguser);
-                foreach ($user['preferences'] as $preference) {
-                    $userpref->{'preference_'.$preference['type']} = $preference['value'];
+                // Preferences.
+                if (!empty($user['preferences'])) {
+                    $userpref = clone($existinguser);
+                    foreach ($user['preferences'] as $preference) {
+                        $userpref->{'preference_'.$preference['type']} = $preference['value'];
+                    }
+                    useredit_update_user_preference($userpref);
+                }
+                if (isset($user['suspended']) and $user['suspended']) {
+                    \core\session\manager::kill_user_sessions($user['id']);
+                }
+
+                $transaction->allow_commit();
+            } catch (Exception $e) {
+                try {
+                    $transaction->rollback($e);
+                } catch (Exception $e) {
+                    $warning = [];
+                    $warning['item'] = 'user';
+                    $warning['itemid'] = $user['id'];
+                    if ($e instanceof moodle_exception) {
+                        $warning['warningcode'] = $e->errorcode;
+                    } else {
+                        $warning['warningcode'] = $e->getCode();
+                    }
+                    $warning['message'] = $e->getMessage();
+                    $warnings[] = $warning;
                 }
-                useredit_update_user_preference($userpref);
-            }
-            if (isset($user['suspended']) and $user['suspended']) {
-                \core\session\manager::kill_user_sessions($user['id']);
             }
         }
 
-        $transaction->allow_commit();
-
-        return null;
+        return ['warnings' => $warnings];
     }
 
     /**
      * Returns description of method result value
      *
-     * @return null
+     * @return external_description
      * @since Moodle 2.2
      */
     public static function update_users_returns() {
-        return null;
+        return new external_single_structure(
+            array(
+                'warnings' => new external_warnings()
+            )
+        );
     }
 
     /**
index f2a6176..fd9f5cb 100644 (file)
@@ -715,6 +715,7 @@ class core_user_externallib_testcase extends externallib_advanced_testcase {
         global $USER, $CFG, $DB;
 
         $this->resetAfterTest(true);
+        $this->preventResetByRollback();
 
         $wsuser = self::getDataGenerator()->create_user();
         self::setUser($wsuser);
@@ -780,8 +781,20 @@ class core_user_externallib_testcase extends externallib_advanced_testcase {
         $user4['id'] = $userdeleted->id;
         user_delete_user($userdeleted);
 
+        $user5 = self::getDataGenerator()->create_user();
+        $user5 = array('id' => $user5->id, 'email' => $user5->email);
+
         // Call the external function.
-        core_user_external::update_users(array($user1, $user2, $user3, $user4));
+        $returnvalue = core_user_external::update_users(array($user1, $user2, $user3, $user4));
+        $returnvalue = external_api::clean_returnvalue(core_user_external::update_users_returns(), $returnvalue);
+
+        // Check warnings.
+        $this->assertEquals($user2['id'], $returnvalue['warnings'][0]['itemid']); // Guest user.
+        $this->assertEquals('usernotupdatedguest', $returnvalue['warnings'][0]['warningcode']);
+        $this->assertEquals($user3['id'], $returnvalue['warnings'][1]['itemid']); // Admin user.
+        $this->assertEquals('usernotupdatedadmin', $returnvalue['warnings'][1]['warningcode']);
+        $this->assertEquals($user4['id'], $returnvalue['warnings'][2]['itemid']); // Deleted user.
+        $this->assertEquals('usernotupdateddeleted', $returnvalue['warnings'][2]['warningcode']);
 
         $dbuser2 = $DB->get_record('user', array('id' => $user2['id']));
         $this->assertNotEquals($dbuser2->username, $user2['username']);
@@ -824,6 +837,39 @@ class core_user_externallib_testcase extends externallib_advanced_testcase {
         $dbuserdelpic = $DB->get_record('user', array('id' => $user1['id']));
         $this->assertEquals(0, $dbuserdelpic->picture, 'Picture must be deleted when sent as 0.');
 
+        // Updating user with an invalid email.
+        $user5['email'] = 'bogus';
+        $returnvalue = core_user_external::update_users(array($user5));
+        $returnvalue = external_api::clean_returnvalue(core_user_external::update_users_returns(), $returnvalue);
+        $this->assertEquals('useremailinvalid', $returnvalue['warnings'][0]['warningcode']);
+        $this->assertStringContainsString('Invalid email address',
+            $returnvalue['warnings'][0]['message']);
+
+        // Updating user with a duplicate email.
+        $user5['email'] = $user1['email'];
+        $returnvalue = core_user_external::update_users(array($user1, $user5));
+        $returnvalue = external_api::clean_returnvalue(core_user_external::update_users_returns(), $returnvalue);
+        $this->assertEquals('useremailduplicate', $returnvalue['warnings'][0]['warningcode']);
+        $this->assertStringContainsString('Duplicate email address',
+                $returnvalue['warnings'][0]['message']);
+
+        // Updating a user that does not exist.
+        $user5['id'] = -1;
+        $returnvalue = core_user_external::update_users(array($user5));
+        $returnvalue = external_api::clean_returnvalue(core_user_external::update_users_returns(), $returnvalue);
+        $this->assertEquals('invaliduserid', $returnvalue['warnings'][0]['warningcode']);
+        $this->assertStringContainsString('Invalid user ID',
+                $returnvalue['warnings'][0]['message']);
+
+        // Updating a remote user.
+        $user1['mnethostid'] = 5;
+        user_update_user($user1); // Update user not using webservice.
+        unset($user1['mnethostid']); // The mnet host ID field is not in the allowed field list for the webservice.
+        $returnvalue = core_user_external::update_users(array($user1));
+        $returnvalue = external_api::clean_returnvalue(core_user_external::update_users_returns(), $returnvalue);
+        $this->assertEquals('usernotupdatedremote', $returnvalue['warnings'][0]['warningcode']);
+        $this->assertStringContainsString('User is a remote user',
+                $returnvalue['warnings'][0]['message']);
 
         // Call without required capability.
         $this->unassignUserCapability('moodle/user:update', $context->id, $roleid);
index a4a1db2..6a36e38 100644 (file)
@@ -23,7 +23,7 @@ namespace core_user;
  * @copyright 2014 The Open University
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class fields_testcase extends \advanced_testcase {
+class fields_test extends \advanced_testcase {
 
     /**
      * Tests getting the user picture fields.
@@ -51,18 +51,26 @@ class fields_testcase extends \advanced_testcase {
      * Tests getting the identity fields.
      */
     public function test_get_identity_fields() {
-        global $DB;
+        global $DB, $CFG;
 
         $this->resetAfterTest();
 
-        // Create two custom profile fields, one of which is private.
+        require_once($CFG->dirroot . '/user/profile/lib.php');
+
+        // Create custom profile fields, one with each visibility option.
         $generator = self::getDataGenerator();
-        $generator->create_custom_profile_field(['datatype' => 'text', 'shortname' => 'a', 'name' => 'A']);
+        $generator->create_custom_profile_field(['datatype' => 'text', 'shortname' => 'a', 'name' => 'A',
+                'visible' => PROFILE_VISIBLE_ALL]);
         $generator->create_custom_profile_field(['datatype' => 'text', 'shortname' => 'b', 'name' => 'B',
                 'visible' => PROFILE_VISIBLE_PRIVATE]);
-
-        // Set the extra user fields to include email, department, and both custom profile fields.
-        set_config('showuseridentity', 'email,department,profile_field_a,profile_field_b');
+        $generator->create_custom_profile_field(['datatype' => 'text', 'shortname' => 'c', 'name' => 'C',
+                'visible' => PROFILE_VISIBLE_NONE]);
+        $generator->create_custom_profile_field(['datatype' => 'text', 'shortname' => 'd', 'name' => 'D',
+                'visible' => PROFILE_VISIBLE_TEACHERS]);
+
+        // Set the extra user fields to include email, department, and all custom profile fields.
+        set_config('showuseridentity', 'email,department,profile_field_a,profile_field_b,' .
+                'profile_field_c,profile_field_d');
         set_config('hiddenuserfields', 'email');
 
         // Create a test course and a student in the course.
@@ -74,7 +82,8 @@ class fields_testcase extends \advanced_testcase {
         $generator->enrol_user($user->id, $course->id, 'student');
 
         // When no context is provided, it does no access checks and should return all specified.
-        $this->assertEquals(['email', 'department', 'profile_field_a', 'profile_field_b'],
+        $this->assertEquals(['email', 'department', 'profile_field_a', 'profile_field_b',
+                'profile_field_c', 'profile_field_d'],
                 fields::get_identity_fields(null));
 
         // If you turn off custom profile fields, you don't get those.
@@ -82,7 +91,8 @@ class fields_testcase extends \advanced_testcase {
 
         // Request in context as an administator.
         $this->setAdminUser();
-        $this->assertEquals(['email', 'department', 'profile_field_a', 'profile_field_b'],
+        $this->assertEquals(['email', 'department', 'profile_field_a', 'profile_field_b',
+                'profile_field_c', 'profile_field_d'],
                 fields::get_identity_fields($coursecontext));
         $this->assertEquals(['email', 'department'],
                 fields::get_identity_fields($coursecontext, false));
@@ -92,24 +102,26 @@ class fields_testcase extends \advanced_testcase {
         $this->setUser($user);
         $this->assertEquals([], fields::get_identity_fields($coursecontext));
 
-        // Give the student the basic identity fields permission.
+        // Give the student the basic identity fields permission (also makes them count as 'teacher'
+        // for the teacher-restricted field).
         $roleid = $DB->get_field('role', 'id', ['shortname' => 'student']);
         role_change_permission($roleid, $coursecontext, 'moodle/site:viewuseridentity', CAP_ALLOW);
-        $this->assertEquals(['department', 'profile_field_a'],
+        $this->assertEquals(['department', 'profile_field_a', 'profile_field_d'],
                 fields::get_identity_fields($coursecontext));
         $this->assertEquals(['department'],
                 fields::get_identity_fields($coursecontext, false));
 
         // Give them permission to view hidden user fields.
         role_change_permission($roleid, $coursecontext, 'moodle/course:viewhiddenuserfields', CAP_ALLOW);
-        $this->assertEquals(['email', 'department', 'profile_field_a'],
+        $this->assertEquals(['email', 'department', 'profile_field_a', 'profile_field_d'],
                 fields::get_identity_fields($coursecontext));
         $this->assertEquals(['email', 'department'],
                 fields::get_identity_fields($coursecontext, false));
 
         // Also give them permission to view all profile fields.
         role_change_permission($roleid, $coursecontext, 'moodle/user:viewalldetails', CAP_ALLOW);
-        $this->assertEquals(['email', 'department', 'profile_field_a', 'profile_field_b'],
+        $this->assertEquals(['email', 'department', 'profile_field_a', 'profile_field_b',
+                'profile_field_c', 'profile_field_d'],
                 fields::get_identity_fields($coursecontext));
         $this->assertEquals(['email', 'department'],
                 fields::get_identity_fields($coursecontext, false));
@@ -120,21 +132,22 @@ class fields_testcase extends \advanced_testcase {
 
         // Give them basic permission.
         role_change_permission($roleid, $usercontext, 'moodle/site:viewuseridentity', CAP_ALLOW);
-        $this->assertEquals(['department', 'profile_field_a'],
+        $this->assertEquals(['department', 'profile_field_a', 'profile_field_d'],
                 fields::get_identity_fields($usercontext));
         $this->assertEquals(['department'],
                 fields::get_identity_fields($usercontext, false));
 
         // Give them the hidden user fields permission (it's a different one).
         role_change_permission($roleid, $usercontext, 'moodle/user:viewhiddendetails', CAP_ALLOW);
-        $this->assertEquals(['email', 'department', 'profile_field_a'],
+        $this->assertEquals(['email', 'department', 'profile_field_a', 'profile_field_d'],
                 fields::get_identity_fields($usercontext));
         $this->assertEquals(['email', 'department'],
                 fields::get_identity_fields($usercontext, false));
 
         // Also give them permission to view all profile fields.
         role_change_permission($roleid, $usercontext, 'moodle/user:viewalldetails', CAP_ALLOW);
-        $this->assertEquals(['email', 'department', 'profile_field_a', 'profile_field_b'],
+        $this->assertEquals(['email', 'department', 'profile_field_a', 'profile_field_b',
+                'profile_field_c', 'profile_field_d'],
                 fields::get_identity_fields($usercontext));
         $this->assertEquals(['email', 'department'],
                 fields::get_identity_fields($usercontext, false));
index d20af1a..51d0ee8 100644 (file)
@@ -1,5 +1,12 @@
 This files describes API changes for code that uses the user API.
 
+=== 4.0 ===
+
+* External function core_user_external::update_users() will now fail on a per user basis. Previously if one user
+  update failed all users in the operation would fail.
+* External function core_user_external::update_users() now returns an error code and message to why a user update
+  action failed.
+
 === 3.11 ===
 
 * Added new core_user/form_user_selector JS module that can be used as the 'ajax' handler for the autocomplete form
index 3edc4c1..24d85fe 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2021060400.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2021060900.00;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
 $release  = '4.0dev (Build: 20210604)'; // Human-friendly version name