Merge branch 'MDL-66072-master-3' of git://github.com/peterRd/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Thu, 5 Sep 2019 00:48:00 +0000 (02:48 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Thu, 5 Sep 2019 00:48:00 +0000 (02:48 +0200)
65 files changed:
.eslintrc
.jshintignore [new file with mode: 0644]
.nvmrc [new file with mode: 0644]
Gruntfile.js
admin/tool/capability/renderer.php
admin/tool/messageinbound/lang/en/tool_messageinbound.php
admin/tool/mobile/classes/external.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/launch.php
admin/tool/mobile/settings.php
admin/tool/mobile/tests/externallib_test.php
admin/tool/mobile/tests/fixtures/output/mobile.php
babel-plugin-add-module-to-define.js
backup/util/ui/renderer.php
blocks/myoverview/lang/en/block_myoverview.php
course/edit.php
course/format/singleactivity/lib.php
course/format/singleactivity/tests/behat/create_course.feature [new file with mode: 0644]
course/tests/behat/course_creation.feature
lang/en/admin.php
lang/en/analytics.php
lang/en/backup.php
lang/en/badges.php
lang/en/completion.php
lang/en/hub.php
lang/en/message.php
lang/en/moodle.php
lang/en/my.php
lib/amd/build/templates.min.js
lib/amd/build/templates.min.js.map
lib/amd/src/templates.js
lib/behat/behat_base.php
lib/classes/output/mustache_engine.php [new file with mode: 0644]
lib/classes/output/mustache_helper_collection.php [new file with mode: 0644]
lib/db/upgrade.php
lib/dml/pgsql_native_moodle_database.php
lib/editor/atto/lang/en/editor_atto.php
lib/form/amd/build/submit.min.js [new file with mode: 0644]
lib/form/amd/build/submit.min.js.map [new file with mode: 0644]
lib/form/amd/src/submit.js [new file with mode: 0644]
lib/form/templates/element-submit-inline.mustache
lib/form/templates/element-submit.mustache
lib/mlbackend/python/classes/processor.php
lib/outputrenderers.php
lib/tests/behat/behat_permissions.php
lib/tests/core_renderer_template_exploit_test.php [new file with mode: 0644]
lib/tests/output_mustache_helper_collection_test.php [new file with mode: 0644]
message/tests/behat/block_user.feature
mod/assign/lang/en/assign.php
mod/assign/locallib.php
mod/assign/tests/behat/relative_dates.feature
mod/assign/tests/events_test.php
mod/data/field.php
mod/forum/subscribe.php
mod/quiz/lang/en/quiz.php
mod/scorm/classes/external.php
mod/scorm/tests/externallib_test.php
npm-shrinkwrap.json
package.json
rating/classes/external.php
user/selector/module.js
user/tests/behat/set_default_homepage.feature
webservice/externallib.php
webservice/tests/externallib_test.php
webservice/upgrade.txt

index 2388717..e44591a 100644 (file)
--- a/.eslintrc
+++ b/.eslintrc
       }
     },
     {
-      files: ["**/amd/src/*.js", "**/amd/src/**/*.js"],
+      files: ["**/amd/src/*.js", "**/amd/src/**/*.js", "Gruntfile*.js", "babel-plugin-add-module-to-define.js"],
       // We support es6 now. Woot!
       env: {
         es6: true
diff --git a/.jshintignore b/.jshintignore
new file mode 100644 (file)
index 0000000..5e61b7c
--- /dev/null
@@ -0,0 +1,2 @@
+**/amd/**
+/*.js
diff --git a/.nvmrc b/.nvmrc
new file mode 100644 (file)
index 0000000..7796292
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+v8.16.1
index 771bed1..bd41cf3 100644 (file)
@@ -24,6 +24,7 @@
  * Grunt configuration
  */
 
+/* eslint-env node */
 module.exports = function(grunt) {
     var path = require('path'),
         tasks = {},
@@ -575,6 +576,7 @@ module.exports = function(grunt) {
                 };
 
                 if (relativePath) {
+                    /* eslint-disable camelcase */
                     sub.relative_root = relativePath;
                 }
 
index dde7c3e..30a9628 100644 (file)
@@ -114,15 +114,17 @@ class tool_capability_renderer extends plugin_renderer_base {
         }
 
         // Start the list item, and print the context name as a link to the place to make changes.
-        if ($contextid == context_system::instance()->id) {
+        $context = context::instance_by_id($contextid);
+
+        if ($context instanceof context_system) {
             $url = new moodle_url('/admin/roles/manage.php');
-            $title = get_string('changeroles', 'tool_capability');
         } else {
-            $url = new moodle_url('/admin/roles/override.php', array('contextid' => $contextid));
-            $title = get_string('changeoverrides', 'tool_capability');
+            $url = new moodle_url('/admin/roles/permissions.php', ['contextid' => $contextid]);
         }
-        $context = context::instance_by_id($contextid);
-        $html = $this->output->heading(html_writer::link($url, $context->get_context_name(), array('title' => $title)), 3);
+
+        $title = get_string('permissionsincontext', 'core_role', $context->get_context_name());
+
+        $html = $this->output->heading(html_writer::link($url, $title), 3);
         $html .= html_writer::table($table);
         // If there are any child contexts, print them recursively.
         if (!empty($contexts[$contextid]->children)) {
@@ -133,4 +135,4 @@ class tool_capability_renderer extends plugin_renderer_base {
         return $html;
     }
 
-}
\ No newline at end of file
+}
index 1711f5e..8daab15 100644 (file)
@@ -105,7 +105,7 @@ $string['requirevalidation'] = 'Validate sender address';
 $string['name'] = 'Name';
 $string['ssl'] = 'SSL (Auto-detect SSL version)';
 $string['sslv2'] = 'SSLv2 (Force SSL Version 2)';
-$string['sslv3'] = 'SSLv2 (Force SSL Version 3)';
+$string['sslv3'] = 'SSLv3 (Force SSL Version 3)';
 $string['taskcleanup'] = 'Cleanup of unverified incoming email';
 $string['taskpickup'] = 'Incoming email pickup';
 $string['tls'] = 'TLS (TLS; started via protocol-level negotiation over unencrypted channel; RECOMMENDED way of initiating secure connection)';
index e9d1c67..73c81ff 100644 (file)
@@ -369,12 +369,12 @@ class external extends external_api {
 
     /**
      * Returns a piece of content to be displayed in the Mobile app, it usually returns a template, javascript and
-     * other structured data that will be used to render a view in the Mobile app..
+     * other structured data that will be used to render a view in the Mobile app.
      *
      * Callbacks (placed in \$component\output\mobile) that are called by this web service are responsible for doing the
      * appropriate security checks to access the information to be returned.
      *
-     * @param string $component fame of the component.
+     * @param string $component name of the component.
      * @param string $method function method name in class \$component\output\mobile.
      * @param array $args optional arguments for the method.
      * @return array HTML, JavaScript and other required data and information to create a view in the app.
@@ -423,6 +423,7 @@ class external extends external_api {
             'otherdata'  => $otherdata,
             'files'      => !empty($result['files']) ? $result['files'] : array(),
             'restrict'   => !empty($result['restrict']) ? $result['restrict'] : array(),
+            'disabled'   => !empty($result['disabled']) ? true : false,
         );
     }
 
@@ -465,7 +466,8 @@ class external extends external_api {
                         ),
                     ),
                     'Restrict this content to certain users or courses.'
-                )
+                ),
+                'disabled' => new external_value(PARAM_BOOL, 'Whether we consider this disabled or not.', VALUE_OPTIONAL),
             )
         );
     }
index 0b6ba81..b02ff9e 100644 (file)
@@ -58,7 +58,7 @@ $string['downloadcourse'] = 'Download course';
 $string['downloadcourses'] = 'Download courses';
 $string['enablesmartappbanners'] = 'Enable App Banners';
 $string['enablesmartappbanners_desc'] = 'If enabled, a banner promoting the mobile app will be displayed when accessing the site using a mobile browser.';
-$string['forcedurlscheme'] = 'If you want to allow only your custom branded app to be opened via a browser window, then specify its URL scheme here; otherwise leave the field empty.';
+$string['forcedurlscheme'] = 'If you want to allow only your custom branded app to be opened via a browser window, then specify its URL scheme here. If you want to allow only the official app, then set the default value. Leave the field empty if you want to allow any app.';
 $string['forcedurlscheme_key'] = 'URL scheme';
 $string['forcelogout'] = 'Force log out';
 $string['forcelogout_desc'] = 'If enabled, the app option \'Change site\' is replaced by \'Log out\'. This results in the user being completely logged out. They must then re-enter their password the next time they wish to access the site.';
@@ -82,7 +82,7 @@ $string['mobileappearance'] = 'Mobile appearance';
 $string['mobileauthentication'] = 'Mobile authentication';
 $string['mobilecssurl'] = 'CSS';
 $string['mobilefeatures'] = 'Mobile features';
-$string['mobilenotificationsdisabledwarning'] = 'Mobile notifications are not enabled. They should be enabled in Manage message outputs.';
+$string['mobilenotificationsdisabledwarning'] = 'Mobile notifications are not enabled. They should be enabled in Notification settings.';
 $string['mobilesettings'] = 'Mobile settings';
 $string['offlineuse'] = 'Offline use';
 $string['pluginname'] = 'Moodle app tools';
index 1e74e37..2c5fc18 100644 (file)
@@ -30,7 +30,7 @@ require_once($CFG->libdir . '/externallib.php');
 
 $serviceshortname  = required_param('service',  PARAM_ALPHANUMEXT);
 $passport          = required_param('passport',  PARAM_RAW);    // Passport send from the app to validate the response URL.
-$urlscheme         = optional_param('urlscheme', 'moodlemobile', PARAM_NOTAGS); // The URL scheme the app supports.
+$urlscheme         = optional_param('urlscheme', 'moodlemobile', PARAM_ALPHANUM); // The URL scheme the app supports.
 $confirmed         = optional_param('confirmed', false, PARAM_BOOL);  // If we are being redirected after user confirmation.
 $oauthsso          = optional_param('oauthsso', 0, PARAM_INT); // Id of the OpenID issuer (for OAuth direct SSO).
 
index 5a313ed..b5033d8 100644 (file)
@@ -63,7 +63,7 @@ if ($hassiteconfig) {
 
         $temp->add(new admin_setting_configtext('tool_mobile/forcedurlscheme',
                     new lang_string('forcedurlscheme_key', 'tool_mobile'),
-                    new lang_string('forcedurlscheme', 'tool_mobile'), '', PARAM_NOTAGS));
+                    new lang_string('forcedurlscheme', 'tool_mobile'), 'moodlemobile', PARAM_ALPHANUM));
 
         $ADMIN->add('mobileapp', $temp);
 
index 6bfea7f..955fbb9 100644 (file)
@@ -371,6 +371,19 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
         $this->assertEquals(array(1, 2), $result['restrict']['users']);
         $this->assertEquals(array(3, 4), $result['restrict']['courses']);
         $this->assertEmpty($result['files']);
+        $this->assertFalse($result['disabled']);
+    }
+
+    /**
+     * Test get_content disabled.
+     */
+    public function test_get_content_disabled() {
+
+        $paramval = 16;
+        $result = external::get_content('tool_mobile', 'test_view_disabled',
+            array(array('name' => 'param1', 'value' => $paramval)));
+        $result = external_api::clean_returnvalue(external::get_content_returns(), $result);
+        $this->assertTrue($result['disabled']);
     }
 
     /**
index d803743..451b00f 100644 (file)
@@ -38,7 +38,6 @@ class mobile {
     /**
      * Returns a test view.
      * @param  array $args Arguments from tool_mobile_get_content WS
-     *
      * @return array       HTML, javascript and otherdata
      */
     public static function test_view($args) {
@@ -57,4 +56,27 @@ class mobile {
             'files' => array()
         );
     }
+
+    /**
+     * Returns a test view disabled.
+     * @param  array $args Arguments from tool_mobile_get_content WS
+     * @return array       HTML, javascript and otherdata
+     */
+    public static function test_view_disabled($args) {
+        $args = (object) $args;
+
+        return array(
+            'templates' => array(
+                array(
+                    'id' => 'main',
+                    'html' => 'The HTML code',
+                ),
+            ),
+            'javascript' => 'alert();',
+            'otherdata' => array('otherdata1' => $args->param1),
+            'restrict' => array('users' => array(1, 2), 'courses' => array(3, 4)),
+            'files' => array(),
+            'disabled' => true,
+        );
+    }
 }
index 133df3f..cdbd8a7 100644 (file)
@@ -33,8 +33,9 @@
  */
 
 "use strict";
+/* eslint-env node */
 
-module.exports = ({ template, types }) => {
+module.exports = ({template, types}) => {
     const fs = require('fs');
     const path = require('path');
     const glob = require('glob');
@@ -120,15 +121,20 @@ module.exports = ({ template, types }) => {
         throw new Error('Unable to find module name for ' + searchFileName);
     }
 
-    // This is heavily inspired by the babel-plugin-add-module-exports plugin.
-    // See: https://github.com/59naga/babel-plugin-add-module-exports
-    //
-    // This is used when we detect a module using "export default Foo;" to make
-    // sure the transpiled code just returns Foo directly rather than an object
-    // with the default property (i.e. {default: Foo}).
-    //
-    // Note: This means that we can't support modules that combine named exports
-    // with a default export.
+    /**
+     * This is heavily inspired by the babel-plugin-add-module-exports plugin.
+     * See: https://github.com/59naga/babel-plugin-add-module-exports
+     *
+     * This is used when we detect a module using "export default Foo;" to make
+     * sure the transpiled code just returns Foo directly rather than an object
+     * with the default property (i.e. {default: Foo}).
+     *
+     * Note: This means that we can't support modules that combine named exports
+     * with a default export.
+     *
+     * @param {String} path
+     * @param {String} exportObjectName
+     */
     function addModuleExportsDefaults(path, exportObjectName) {
         const rootPath = path.findParent(path => {
             return path.key === 'body' || !path.parentPath;
@@ -136,7 +142,7 @@ module.exports = ({ template, types }) => {
 
         // HACK: `path.node.body.push` instead of path.pushContainer(due doesn't work in Plugin.post).
         // This is hardcoded to work specifically with AMD.
-        rootPath.node.body.push(template(`return ${exportObjectName}.default`)())
+        rootPath.node.body.push(template(`return ${exportObjectName}.default`)());
     }
 
     return {
@@ -174,9 +180,9 @@ module.exports = ({ template, types }) => {
 
                             // Check for any Object.defineProperty('exports', 'default') calls.
                             if (!this.addedReturnForDefaultExport && path.get('callee').matchesPattern('Object.defineProperty')) {
-                                const [identifier, prop] = path.get('arguments')
-                                const objectName = identifier.get('name').node
-                                const propertyName = prop.get('value').node
+                                const [identifier, prop] = path.get('arguments');
+                                const objectName = identifier.get('name').node;
+                                const propertyName = prop.get('value').node;
 
                                 if ((objectName === 'exports' || objectName === '_exports') && propertyName === 'default') {
                                     addModuleExportsDefaults(path, objectName);
index a46499f..43e9dd7 100644 (file)
@@ -404,8 +404,10 @@ class core_backup_renderer extends plugin_renderer_base {
         $html .= $this->output->heading(get_string('importdatafrom'), 2, array('class' => 'header'));
         $html .= $this->backup_detail_pair(get_string('selectacourse', 'backup'), $this->render($courses));
         $attrs = array('type' => 'submit', 'value' => get_string('continue'), 'class' => 'btn btn-primary');
+        $html .= html_writer::start_tag('div', array('class' => 'mt-3'));
         $html .= $this->backup_detail_pair('', html_writer::empty_tag('input', $attrs));
         $html .= html_writer::end_tag('div');
+        $html .= html_writer::end_tag('div');
         $html .= html_writer::end_tag('form');
         $html .= html_writer::end_tag('div');
         return $html;
@@ -787,7 +789,7 @@ class core_backup_renderer extends plugin_renderer_base {
         if ($component->get_count() === 0) {
             $output .= $this->output->notification(get_string('nomatchingcourses', 'backup'));
 
-            $output .= html_writer::start_tag('div', array('class' => 'ics-search'));
+            $output .= html_writer::start_tag('div', array('class' => 'ics-search form-inline'));
             $attrs = array(
                 'type' => 'text',
                 'name' => restore_course_search::$VAR_SEARCH,
@@ -799,7 +801,7 @@ class core_backup_renderer extends plugin_renderer_base {
                 'type' => 'submit',
                 'name' => 'searchcourses',
                 'value' => get_string('search'),
-                'class' => 'btn btn-secondary'
+                'class' => 'btn btn-secondary ml-1'
             );
             $output .= html_writer::empty_tag('input', $attrs);
             $output .= html_writer::end_tag('div');
@@ -845,7 +847,7 @@ class core_backup_renderer extends plugin_renderer_base {
         $output .= html_writer::table($table);
         $output .= html_writer::end_tag('div');
 
-        $output .= html_writer::start_tag('div', array('class' => 'ics-search'));
+        $output .= html_writer::start_tag('div', array('class' => 'ics-search form-inline'));
         $attrs = array(
             'type' => 'text',
             'name' => restore_course_search::$VAR_SEARCH,
@@ -856,7 +858,7 @@ class core_backup_renderer extends plugin_renderer_base {
             'type' => 'submit',
             'name' => 'searchcourses',
             'value' => get_string('search'),
-            'class' => 'btn btn-secondary'
+            'class' => 'btn btn-secondary ml-1'
         );
         $output .= html_writer::empty_tag('input', $attrs);
         $output .= html_writer::end_tag('div');
index 8fe7076..3c7226a 100644 (file)
@@ -57,8 +57,8 @@ $string['favourites'] = 'Starred';
 $string['future'] = 'Future';
 $string['inprogress'] = 'In progress';
 $string['lastaccessed'] = 'Last accessed';
-$string['layouts'] = 'Available Layouts';
-$string['layouts_help'] = 'The layouts which are available for selection by users';
+$string['layouts'] = 'Available layouts';
+$string['layouts_help'] = 'Course overview layouts which are available for selection by users. If none are selected, the card layout will be used.';
 $string['list'] = 'List';
 $string['myoverview:myaddinstance'] = 'Add a new course overview block to Dashboard';
 $string['past'] = 'Past';
index 81626d8..2072fe0 100644 (file)
@@ -166,7 +166,11 @@ if ($editform->is_cancelled()) {
 
         if (!empty($CFG->creatornewroleid) and !is_viewing($context, NULL, 'moodle/role:assign') and !is_enrolled($context, NULL, 'moodle/role:assign')) {
             // Deal with course creators - enrol them internally with default role.
-            enrol_try_internal_enrol($course->id, $USER->id, $CFG->creatornewroleid);
+            if (user_can_assign($context, $CFG->creatornewroleid)) {
+                enrol_try_internal_enrol($course->id, $USER->id, $CFG->creatornewroleid);
+            } else {
+                enrol_try_internal_enrol($course->id, $USER->id);
+            }
         }
 
         // The URL to take them to if they chose save and display.
index 50fa91a..5db9dea 100644 (file)
@@ -36,6 +36,9 @@ class format_singleactivity extends format_base {
     /** @var cm_info the current activity. Use get_activity() to retrieve it. */
     private $activity = false;
 
+    /** @var int The category ID guessed from the form data. */
+    private $categoryid = false;
+
     /**
      * The URL to use for the specified course
      *
@@ -145,6 +148,30 @@ class format_singleactivity extends format_base {
      */
     public function course_format_options($foreditform = false) {
         static $courseformatoptions = false;
+
+        $fetchtypes = $courseformatoptions === false;
+        $fetchtypes = $fetchtypes || ($foreditform && !isset($courseformatoptions['activitytype']['label']));
+
+        if ($fetchtypes) {
+            $availabletypes = $this->get_supported_activities();
+            if ($this->course) {
+                // The course exists. Test against the course.
+                $testcontext = context_course::instance($this->course->id);
+            } else if ($this->categoryid) {
+                // The course does not exist yet, but we have a category ID that we can test against.
+                $testcontext = context_coursecat::instance($this->categoryid);
+            } else {
+                // The course does not exist, and we somehow do not have a category. Test capabilities against the system context.
+                $testcontext = context_system::instance();
+            }
+            foreach (array_keys($availabletypes) as $activity) {
+                $capability = "mod/{$activity}:addinstance";
+                if (!has_capability($capability, $testcontext)) {
+                    unset($availabletypes[$activity]);
+                }
+            }
+        }
+
         if ($courseformatoptions === false) {
             $config = get_config('format_singleactivity');
             $courseformatoptions = array(
@@ -153,9 +180,13 @@ class format_singleactivity extends format_base {
                     'type' => PARAM_TEXT,
                 ),
             );
+
+            if (!empty($availabletypes) && !isset($availabletypes[$config->activitytype])) {
+                $courseformatoptions['activitytype']['default'] = array_keys($availabletypes)[0];
+            }
         }
+
         if ($foreditform && !isset($courseformatoptions['activitytype']['label'])) {
-            $availabletypes = $this->get_supported_activities();
             $courseformatoptionsedit = array(
                 'activitytype' => array(
                     'label' => new lang_string('activitytype', 'format_singleactivity'),
@@ -183,6 +214,11 @@ class format_singleactivity extends format_base {
      */
     public function create_edit_form_elements(&$mform, $forsection = false) {
         global $PAGE;
+
+        if (!$this->course && $submitvalues = $mform->getSubmitValues()) {
+            $this->categoryid = $submitvalues['category'];
+        }
+
         $elements = parent::create_edit_form_elements($mform, $forsection);
         if (!$forsection && ($course = $PAGE->course) && !empty($course->format) &&
                 $course->format !== 'site' && $course->format !== 'singleactivity') {
diff --git a/course/format/singleactivity/tests/behat/create_course.feature b/course/format/singleactivity/tests/behat/create_course.feature
new file mode 100644 (file)
index 0000000..6eee07f
--- /dev/null
@@ -0,0 +1,38 @@
+@format @format_singleactivity
+Feature: Courses can be created in Single Activity mode
+  In order to create a single activity course
+  As a manager
+  I need to create courses and set default values on them
+
+  Scenario: Create a course as a custom course creator
+    Given the following "users" exist:
+      | username  | firstname | lastname | email          |
+      | kevin  | Kevin   | the        | kevin@example.com |
+    And the following "roles" exist:
+      | shortname | name    | archetype |
+      | creator   | Creator |           |
+    And the following "system role assigns" exist:
+      | user   | role    | contextlevel |
+      | kevin  | creator | System       |
+    And I log in as "admin"
+    And I set the following system permissions of "Creator" role:
+      | capability | permission |
+      | moodle/course:create | Allow |
+      | moodle/course:update | Allow |
+      | moodle/course:manageactivities | Allow |
+      | moodle/course:viewparticipants | Allow |
+      | moodle/role:assign | Allow |
+      | mod/quiz:addinstance | Allow |
+    And I log out
+    And I log in as "kevin"
+    And I am on site homepage
+    When I press "Add a new course"
+    And I set the following fields to these values:
+      | Course full name  | My first course |
+      | Course short name | myfirstcourse |
+      | Format | Single activity format |
+    And I press "Update format"
+    Then I should see "Quiz" in the "Type of activity" "field"
+    And I should not see "Forum" in the "Type of activity" "field"
+    And I press "Save and display"
+    And I should see "Adding a new Quiz"
index 2596eb2..8571b97 100644 (file)
@@ -68,3 +68,48 @@ Feature: Managers can create courses
       | id_enddate_day | 24 |
       | id_enddate_month | October |
       | id_enddate_year | 2016 |
+
+  Scenario: Create a course as a custom course creator
+    Given the following "users" exist:
+      | username  | firstname | lastname | email          |
+      | kevin  | Kevin   | the        | kevin@example.com |
+    And the following "roles" exist:
+      | shortname | name    | archetype |
+      | creator   | Creator |           |
+    And the following "system role assigns" exist:
+      | user   | role    | contextlevel |
+      | kevin  | creator | System       |
+    And I log in as "admin"
+    And I set the following system permissions of "Creator" role:
+      | capability | permission |
+      | moodle/course:create | Allow |
+      | moodle/course:manageactivities | Allow |
+      | moodle/course:viewparticipants | Allow |
+      | moodle/role:assign | Allow |
+    And I log out
+    And I log in as "kevin"
+    And I am on site homepage
+    When I press "Add a new course"
+    And I set the following fields to these values:
+      | Course full name  | My first course |
+      | Course short name | myfirstcourse |
+    And I press "Save and display"
+    And I follow "Participants"
+    Then I should see "Kevin the"
+    And I should not see "Teacher"
+    And I log out
+    Given I log in as "admin"
+    And I define the allowed role assignments for the "Creator" role as:
+      | Teacher | Assignable |
+    And I log out
+    And I log in as "kevin"
+    And I am on site homepage
+    And I turn editing mode on
+    When I press "Add a new course"
+    And I set the following fields to these values:
+      | Course full name  | My second course |
+      | Course short name | mysecondcourse |
+    And I press "Save and display"
+    And I follow "Participants"
+    Then I should see "Kevin the"
+    And I should see "Teacher"
index fa8f10f..752a030 100644 (file)
@@ -200,7 +200,7 @@ $string['configdebug'] = 'If you turn this on, then PHP\'s error_reporting will
 $string['configdebugdisplay'] = 'Set to on, the error reporting will go to the HTML page. This is practical, but breaks XHTML, JS, cookies and HTTP headers in general. Set to off, it will send the output to your server logs, allowing better debugging. The PHP setting error_log controls which log this goes to.';
 $string['configdebugpageinfo'] = 'Enable if you want page information printed in page footer.';
 $string['configdebugvalidators'] = 'Enable if you want to have links to external validator servers in page footer. You may need to create new user with username <em>w3cvalidator</em>, and enable guest access. These changes may allow unauthorized access to server, do not enable on production sites!';
-$string['configdefaulthomepage'] = 'This determines the home page for logged in users';
+$string['configdefaulthomepage'] = 'This determines the first link in the navigation for logged-in users.';
 $string['configdefaultrequestcategory'] = 'Courses requested by users will be automatically placed in this category.';
 $string['configdefaultrequestedcategory'] = 'Default category to put courses that were requested into, if they\'re approved.';
 $string['configdefaultuserroleid'] = 'All logged in users will be given the capabilities of the role you specify here, at the site level, in ADDITION to any other roles they may have been given.  The default is the Authenticated user role.  Note that this will not conflict with other roles they have unless you prohibit capabilities, it just ensures that all users have capabilities that are not assignable at the course level (eg post blog entries, manage own calendar, etc).';
@@ -455,7 +455,7 @@ $string['debugvalidators'] = 'Show validator links';
 $string['defaultcity'] = 'Default city';
 $string['defaultcity_help'] = 'A city entered here will be the default city when creating new user accounts.';
 $string['defaultformatnotset'] = 'Error determining default course format. Please check site settings.';
-$string['defaulthomepage'] = 'Default home page for users';
+$string['defaulthomepage'] = 'Home page for users';
 $string['defaultrequestcategory'] = 'Default category for course requests';
 $string['defaultsettinginfo'] = 'Default: {$a}';
 $string['defaultuserroleid'] = 'Default role for all users';
@@ -563,8 +563,8 @@ $string['experimentalsettings'] = 'Experimental settings';
 $string['extendedusernamechars'] = 'Allow extended characters in usernames';
 $string['extramemorylimit'] = 'Extra PHP memory limit';
 $string['fatalsessionautostart'] = '<p>Serious configuration error detected, please notify server administrator.</p><p> To operate properly, Moodle requires that administrator changes PHP settings.</p><p><code>session.auto_start</code> must be set to <code>off</code>.</p><p>This setting is controlled by editing <code>php.ini</code>, Apache/IIS <br />configuration or <code>.htaccess</code> file on the server.</p>';
-$string['filescleanupperiod'] = 'Clean trash pool files';
-$string['filescleanupperiod_help'] = 'How often trash files are removed. These are files that are associated with a context that no longer exists';
+$string['filescleanupperiod'] = 'Clean up trash pool files';
+$string['filescleanupperiod_help'] = 'How often trash pool files are deleted. These are files that are associated with a context that no longer exists, for example when a course is deleted. Please note: This setting can result in missing files in a course which is backed up, deleted and then restored if the setting \'Include files\' (backup_auto_files) in \'Automated backup settings\' is disabled.';
 $string['fileconversioncleanuptask'] = 'Cleanup of temporary records for file conversions.';
 $string['filecreated'] = 'New file created';
 $string['filesizeunits'] = 'file size units';
@@ -1008,7 +1008,7 @@ $string['quizattemptsupgradedmessage'] = 'In Moodle 2.1 there was a major upgrad
 $string['recaptchaprivatekey'] = 'ReCAPTCHA secret key';
 $string['recaptchapublickey'] = 'ReCAPTCHA site key';
 $string['register'] = 'Register your site';
-$string['registermoodlenet'] = '<p>We\'d love to stay in touch and provide you with important things for your Moodle site!</p><p>By registering:</p><ul><li>You\'ll be one of the first to find out about important notifications such as security alerts and new Moodle releases.</li><li>You can access and activate mobile push notifications from your Moodle site through our free <a href="https://download.moodle.org/mobile/">Moodle app</a>.</li><li>You are contributing to our <a href="https://moodle.net/stats/">Moodle statistics</a> of the worldwide community, which help us improve Moodle and our community sites.</li><li>If you wish, your site can be included in the <a href="https://moodle.net/sites/">list of registered Moodle sites</a> in your country.</li></ul>';
+$string['registermoodlenet'] = '<p>We\'d love to stay in touch and provide you with important things for your Moodle site!</p><p>By registering:</p><ul><li>You can subscribe to receive notifications of new Moodle releases, security alerts and other important news.</li><li>You can access and activate mobile push notifications from your Moodle site through our free <a href="https://download.moodle.org/mobile/">Moodle app</a>.</li><li>You are contributing to our <a href="https://moodle.net/stats/">Moodle statistics</a> of the worldwide community, which help us improve Moodle and our community sites.</li><li>If you wish, your site can be included in the <a href="https://moodle.net/sites/">list of registered Moodle sites</a> in your country.</li></ul>';
 $string['registermoodleorg'] = 'When you register your site';
 $string['registermoodleorgli1'] = 'You are added to a low-volume mailing list for important notifications such as security alerts and new releases of Moodle.';
 $string['registermoodleorgli2'] = 'Statistics about your site will be added to the {$a} of the worldwide Moodle community.';
@@ -1302,6 +1302,7 @@ $string['unbookmarkthispage'] = 'Unbookmark this page';
 $string['unicoderequired'] = 'It is required that you store all your data in Unicode format (UTF-8). New installations must be performed into databases that have their default character set as Unicode.  If you are upgrading, you should perform the UTF-8 migration process (see the Admin page).';
 $string['uninstallplugin'] = 'Uninstall';
 $string['unlockaccount'] = 'Unlock account';
+$string['unoconvwarning'] = 'The version of unoconv you have installed is not supported.';
 $string['unsettheme'] = 'Unset theme';
 $string['unsupported'] = 'Unsupported';
 $string['unsupporteddbfileformat'] = 'Your database uses Antelope as the file format. Full UTF-8 support in MySQL and MariaDB requires the Barracuda file format. Please switch to the Barracuda file format. See the documentation <a href="https://docs.moodle.org/en/admin/environment/custom check/mysql full unicode support">MySQL full unicode support</a> for details.';
@@ -1391,8 +1392,8 @@ $string['usermanagement'] = 'User management';
 $string['userpreference'] = 'User preference';
 $string['userpolicies'] = 'User policies';
 $string['users'] = 'Users';
-$string['userquota'] = 'User quota';
-$string['userquota_desc'] = 'The maximum number of bytes that a user can store in their own private file area.';
+$string['userquota'] = 'Private files space';
+$string['userquota_desc'] = 'The maximum amount of data that each user can store in their private files area.';
 $string['usesitenameforsitepages'] = 'Use site name for site pages';
 $string['usetags'] = 'Enable tags functionality';
 $string['validateemptylineerror'] = 'Empty lines are not valid';
index cbeceea..ff7e173 100644 (file)
@@ -97,8 +97,8 @@ $string['noevaluationbasedassumptions'] = 'Models based on assumptions cannot be
 $string['nodata'] = 'No data to analyse';
 $string['noinsightsmodel'] = 'This model does not generate insights';
 $string['noinsights'] = 'No insights reported';
-$string['nonewdata'] = 'No new data available. It will be analysed after the next analysis interval.';
-$string['nonewranges'] = 'No new predictions yet. It will be analysed after the next analysis interval.';
+$string['nonewdata'] = 'No new data available. The model will be analysed after the next analysis interval.';
+$string['nonewranges'] = 'No new predictions yet. The model will be analysed after the next analysis interval.';
 $string['nopredictionsyet'] = 'No predictions available yet';
 $string['noranges'] = 'No predictions yet';
 $string['notrainingbasedassumptions'] = 'Models based on assumptions do not need training';
index 9cfe05e..0c08c50 100644 (file)
@@ -127,7 +127,7 @@ $string['configgeneralblocks'] = 'Sets the default for including blocks in a bac
 $string['configgeneralcalendarevents'] = 'Sets the default for including calendar events in a backup.';
 $string['configgeneralcomments'] = 'Sets the default for including comments in a backup.';
 $string['configgeneralcompetencies'] = 'Sets the default for including competencies in a backup.';
-$string['configgeneralfiles'] = 'Sets the default for including files in a backup.';
+$string['configgeneralfiles'] = 'Sets the default for including files in a backup. Please note: Disabling this setting will result in a backup which only includes references to files. This is not a problem if the backup is restored on the same site and the files have not been deleted according to the setting \'Clean up trash pool files\' (filescleanupperiod).';
 $string['configgeneralfilters'] = 'Sets the default for including filters in a backup.';
 $string['configgeneralhistories'] = 'Sets the default for including user history within a backup.';
 $string['configgenerallogs'] = 'If enabled logs will be included in backups by default.';
index 7b54068..6ff0088 100644 (file)
@@ -111,7 +111,7 @@ $string['backpackemail_help'] = 'The email address associated with your backpack
 $string['backpackemailverificationpending'] = 'Verification pending';
 $string['backpackemailverifyemailbody'] = 'Hi,
 
-A new connection to your OpenBadges backpack has been requested from \'{$a->sitename}\' using your email address.
+A new connection to your badges backpack has been requested from \'{$a->sitename}\' using your email address.
 
 To confirm and activate the connection to your backpack, please go to
 
@@ -121,7 +121,7 @@ In most mail programs, this should appear as a blue link which you can just clic
 
 If you need help, please contact the site administrator,
 {$a->admin}';
-$string['backpackemailverifyemailsubject'] = '{$a}: OpenBadges Backpack email verification';
+$string['backpackemailverifyemailsubject'] = '{$a}: Badges backpack email verification';
 $string['backpackemailverifypending'] = 'A verification email has been sent to <strong>{$a}</strong>. Click on the verification link in the email to activate your Backpack connection.';
 $string['backpackemailverifysuccess'] = 'Thanks for verifying your email address. You are now connected to your backpack.';
 $string['backpackemailverifytokenmismatch'] = 'The token in the link you clicked does not match the stored token. Make sure you clicked the link in most recent email you received.';
index c0cf86b..cae7b1e 100644 (file)
@@ -148,7 +148,7 @@ $string['err_noactivities'] = 'Completion information is not enabled for any act
 $string['err_nocourses'] = 'Course completion is not enabled for any other courses, so none can be displayed. You can enable course completion in the course settings.';
 $string['err_nograde'] = 'A course pass grade has not been set for this course. To enable this criteria type you must create a pass grade for this course.';
 $string['err_noroles'] = 'There are no roles with the capability moodle/course:markcomplete in this course.';
-$string['err_nousers'] = 'There are no students on this course or group for whom completion information is displayed. (By default, completion information is displayed only for students, so if there are no students, you will see this error. Administrators can alter this option via the admin screens.)';
+$string['err_nousers'] = 'There are no students in this course or group for whom completion information is displayed. (Completion information is displayed only for users with the capability \'Be shown on completion reports\'. The capability is allowed for the default role of student only, so if there are no students, you will see this message.)';
 $string['err_settingslocked'] = 'One or more students have already completed a criterion so the settings have been locked. Unlocking the completion criteria settings will delete any existing user data and may cause confusion.';
 $string['err_system'] = 'An internal error occurred in the completion system. (System administrators can enable debugging information to see more detail.)';
 $string['eventcoursecompleted'] = 'Course completed';
index d10a47d..ebe023f 100644 (file)
@@ -111,10 +111,10 @@ $string['sendfollowinginfo_help'] = 'The following information will be sent to c
 $string['sent'] = '...finished';
 $string['siteadmin'] = 'Administrator';
 $string['siteadmin_help'] = 'The full name of the site administrator.';
-$string['sitecommnews'] = 'Updates about Moodle news and features';
-$string['sitecommnews_help'] = 'You have the option of subscribing to our low volume email list including a newsletter about happenings in the Moodle community. ';
-$string['sitecommnewsno'] = 'No, I do not want to receive any email from Moodle HQ';
-$string['sitecommnewsyes'] = 'Yes please, include me in Moodle’s regular e-newsletter updates';
+$string['sitecommnews'] = 'Moodle newsletter';
+$string['sitecommnews_help'] = 'You have the option of subscribing to our Moodle newsletter. You may unsubscribe at any time.';
+$string['sitecommnewsno'] = 'No, I do not wish to receive any emails';
+$string['sitecommnewsyes'] = 'Yes, I would like to receive the Moodle newsletter';
 $string['sitecountry'] = 'Country';
 $string['sitecountry_help'] = 'The country your organisation or institution is located in.';
 $string['sitedesc'] = 'Description';
@@ -136,8 +136,8 @@ $string['siteprivacypublished'] = 'Only display my site name';
 $string['siteprivacylinked'] = 'Display my site name with the link';
 $string['siteregistrationcontact'] = 'Display contact form';
 $string['siteregistrationcontact_help'] = 'If you allow it, other people in our Moodle community (who need a login account) can contact you via a form on our Moodle community site. However, they will never be able to see your email address.';
-$string['siteregistrationemail'] = 'Notifications about important security and technical issues.';
-$string['siteregistrationemail_help'] = 'You have the option of subscribing to our low volume email list for important news (on security issues or new releases).';
+$string['siteregistrationemail'] = 'Notifications of new Moodle releases, security alerts and other important news';
+$string['siteregistrationemail_help'] = 'You have the option of subscribing to our low-volume mailing list for notifications of new Moodle releases, security alerts and other important news. You may unsubscribe at any time.';
 $string['siteregistrationupdated'] = 'Site registration updated';
 $string['siterelease'] = 'Moodle release';
 $string['sitereleasenum'] = 'Moodle release ({$a})';
index 21389c4..6ff6bfc 100644 (file)
@@ -39,7 +39,7 @@ $string['blockuserconfirm'] = 'Are you sure you want to block {$a}?';
 $string['blockuserconfirmbutton'] = 'Block';
 $string['blocknoncontacts'] = 'Prevent non-contacts from messaging me';
 $string['cancelselection'] = 'Cancel message selection';
-$string['cantblockuser'] = 'You are unable to block {$a} because they have a role with permission to message all users';
+$string['cantblockuser'] = 'You can\'t block {$a} because they have a role with permission to message all users.';
 $string['contactableprivacy'] = 'Accept messages from:';
 $string['contactableprivacy_onlycontacts'] = 'My contacts only';
 $string['contactableprivacy_coursemember'] = 'My contacts and anyone in my courses';
index b0cef08..1152336 100644 (file)
@@ -381,12 +381,11 @@ $string['coursesettings'] = 'Course default settings';
 $string['coursesmovedout'] = 'Courses moved out from {$a}';
 $string['coursespending'] = 'Courses pending approval';
 $string['coursesearch'] = 'Search courses';
-$string['coursesearch_help'] = '<p>You can search for multiple words at once and can refine your search as follows:</p>
-<ul>
-<li>word - find any match of this word within the text.</li>
-<li>+word - only exact matching words will be found.</li>
-<li>-word - don\'t include results containing this word.</li>
-</ul>';
+$string['coursesearch_help'] = 'You can search for multiple words at once and can refine your search as follows:
+
+* word - find any match of this word within the text
+* +word - only exact matching words will be found
+* -word - don\'t include results containing this word.';
 $string['coursestart'] = 'Course start';
 $string['coursesummary'] = 'Course summary';
 $string['coursesummary_help'] = 'The course summary is displayed in the list of courses. A course search searches course summary text in addition to course names.';
@@ -467,8 +466,8 @@ $string['defaultcoursesummary'] = 'Write a concise and interesting paragraph her
 $string['defaultcourseteacher'] = 'Teacher';
 $string['defaultcourseteacherdescription'] = 'Teachers can do anything within a course, including changing the activities and grading students.';
 $string['defaultcourseteachers'] = 'Teachers';
-$string['defaulthomepageuser'] = 'Default home page';
-$string['defaulthomepageuser_help'] = 'This determines the home page for your account';
+$string['defaulthomepageuser'] = 'Home page';
+$string['defaulthomepageuser_help'] = 'Your home page is the first link in the navigation.';
 $string['delete'] = 'Delete';
 $string['deleteablock'] = 'Delete a block';
 $string['deleteall'] = 'Delete all';
@@ -1145,7 +1144,7 @@ $string['maincoursepage'] = 'Main course page';
 $string['makeafolder'] = 'Create folder';
 $string['makeavailable'] = 'Make available';
 $string['makeeditable'] = 'If you make \'{$a}\' editable by the web server process (eg apache) then you could edit this file directly from this page';
-$string['makethismyhome'] = 'Make this my default home page';
+$string['makethismyhome'] = 'Make this my home page';
 $string['makeunavailable'] = 'Make unavailable';
 $string['manageblocks'] = 'Blocks';
 $string['managecategorythis'] = 'Manage this category';
@@ -1175,8 +1174,8 @@ $string['maxnumberweeks_desc'] = 'The maximum value in the number of sections dr
 $string['maxnumcoursesincombo'] = 'Browse <a href="{$a->link}">{$a->numberofcourses} courses</a>.';
 $string['maxsize'] = 'Max size: {$a}';
 $string['maxsizeandareasize'] = 'Maximum size for new files: {$a->size}, overall limit: {$a->areasize}';
-$string['maxsizeandattachments'] = 'Maximum size for new files: {$a->size}, maximum attachments: {$a->attachments}';
-$string['maxsizeandattachmentsandareasize'] = 'Maximum size for new files: {$a->size}, maximum attachments: {$a->attachments}, overall limit: {$a->areasize}';
+$string['maxsizeandattachments'] = 'Maximum file size: {$a->size}, maximum number of files: {$a->attachments}';
+$string['maxsizeandattachmentsandareasize'] = 'Maximum file size: {$a->size}, maximum number of files: {$a->attachments}, maximum total size: {$a->areasize}';
 $string['memberincourse'] = 'People in the course';
 $string['messagebody'] = 'Message body';
 $string['messagedselectedusers'] = 'Selected users have been messaged and the recipient list has been reset.';
@@ -1631,9 +1630,9 @@ for important notifications such as security alerts and new releases of Moodle.<
 <p>If you choose, you can allow your site name, country and URL to be added to the public list of Moodle Sites.</p>
 <p>All new registrations are verified manually before they are added to the list, but once you are added you can update your registration (and your entry on the public list) at any time by resubmitting this form.</p>';
 $string['registrationinfotitle'] = 'Registration information';
-$string['registrationno'] = 'No, I do not want to receive any email from Moodle HQ';
+$string['registrationno'] = 'No, I do not wish to receive any emails';
 $string['registrationsend'] = 'Send registration information to moodle.org';
-$string['registrationyes'] = 'Yes, notify me about important news (e.g. security issues or releases) ';
+$string['registrationyes'] = 'Yes, notify me of new Moodle releases, security alerts and other important news';
 $string['reject'] = 'Reject';
 $string['rejectdots'] = 'Reject...';
 $string['relativedatesmode'] = 'Relative dates mode';
@@ -1919,7 +1918,7 @@ $string['statsreport10'] = 'User activity';
 $string['statsreport11'] = 'Most active courses';
 $string['statsreport12'] = 'Most active courses (weighted)';
 $string['statsreport13'] = 'Most participatory courses (enrolments)';
-$string['statsreport14'] = 'Most participatory courses (views/posts)';
+$string['statsreport14'] = 'Most participatory courses (posts/views)';
 $string['statsreport2'] = 'Views (all roles)';
 $string['statsreport3'] = 'Posts (all roles)';
 $string['statsreport4'] = 'All activity (all roles)';
index 216b1ab..02b2a82 100644 (file)
@@ -38,4 +38,4 @@ $string['reseteveryonesdashboard'] = 'Reset Dashboard for all users';
 $string['reseteveryonesprofile'] = 'Reset profile for all users';
 $string['resetpage'] = 'Reset page to default';
 $string['reseterror'] = 'There was an error resetting your page';
-$string['privacy:metadata:core_my:preference:user_home_page_preference'] = 'The user home page preference configured for the Dashboard page.';
+$string['privacy:metadata:core_my:preference:user_home_page_preference'] = 'The user home page preference.';
index a2dcac9..77410bc 100644 (file)
Binary files a/lib/amd/build/templates.min.js and b/lib/amd/build/templates.min.js differ
index 88c4bb0..fac38b1 100644 (file)
Binary files a/lib/amd/build/templates.min.js.map and b/lib/amd/build/templates.min.js.map differ
index 019118f..26cb71e 100644 (file)
@@ -65,6 +65,9 @@ define([
     /** @var {Bool} isLoadingTemplates - Whether templates are currently being loaded */
     var isLoadingTemplates = false;
 
+    /** @var {Array} blacklistedNestedHelpers - List of helpers that can't be called within other helpers */
+    var blacklistedNestedHelpers = ['js'];
+
     /**
      * Search the various caches for a template promise for the given search key.
      * The search key should be in the format <theme>/<component>/<template> e.g. boost/core/modal.
@@ -544,6 +547,60 @@ define([
         return '[[_t_' + index + ']]';
     };
 
+    /**
+     * Return a helper function to be added to the context for rendering the a
+     * template.
+     *
+     * This will parse the provided text before giving it to the helper function
+     * in order to remove any blacklisted nested helpers to prevent one helper
+     * from calling another.
+     *
+     * In particular to prevent the JS helper from being called from within another
+     * helper because it can lead to security issues when the JS portion is user
+     * provided.
+     *
+     * @param  {function} helperFunction The helper function to add
+     * @param  {object} context The template context for the helper function
+     * @return {Function} To be set in the context
+     */
+    Renderer.prototype.addHelperFunction = function(helperFunction, context) {
+        return function() {
+            return function(sectionText, helper) {
+                // Override the blacklisted helpers in the template context with
+                // a function that returns an empty string for use when executing
+                // other helpers. This is to prevent these helpers from being
+                // executed as part of the rendering of another helper in order to
+                // prevent any potential security issues.
+                var originalHelpers = blacklistedNestedHelpers.reduce(function(carry, name) {
+                    if (context.hasOwnProperty(name)) {
+                        carry[name] = context[name];
+                    }
+
+                    return carry;
+                }, {});
+
+                blacklistedNestedHelpers.forEach(function(helperName) {
+                    context[helperName] = function() {
+                        return '';
+                    };
+                });
+
+                // Execute the helper with the modified context that doesn't include
+                // the blacklisted nested helpers. This prevents the blacklisted
+                // helpers from being called from within other helpers.
+                var result = helperFunction.apply(this, [context, sectionText, helper]);
+
+                // Restore the original helper implementation in the context so that
+                // any further rendering has access to them again.
+                for (var name in originalHelpers) {
+                    context[name] = originalHelpers[name];
+                }
+
+                return result;
+            }.bind(this);
+        }.bind(this);
+    };
+
     /**
      * Add some common helper functions to all context objects passed to templates.
      * These helpers match exactly the helpers available in php.
@@ -558,24 +615,12 @@ define([
         this.requiredStrings = [];
         this.requiredJS = [];
         context.uniqid = (uniqInstances++);
-        context.str = function() {
-          return this.stringHelper.bind(this, context);
-        }.bind(this);
-        context.pix = function() {
-          return this.pixHelper.bind(this, context);
-        }.bind(this);
-        context.js = function() {
-          return this.jsHelper.bind(this, context);
-        }.bind(this);
-        context.quote = function() {
-          return this.quoteHelper.bind(this, context);
-        }.bind(this);
-        context.shortentext = function() {
-          return this.shortenTextHelper.bind(this, context);
-        }.bind(this);
-        context.userdate = function() {
-          return this.userDateHelper.bind(this, context);
-        }.bind(this);
+        context.str = this.addHelperFunction(this.stringHelper, context);
+        context.pix = this.addHelperFunction(this.pixHelper, context);
+        context.js = this.addHelperFunction(this.jsHelper, context);
+        context.quote = this.addHelperFunction(this.quoteHelper, context);
+        context.shortentext = this.addHelperFunction(this.shortenTextHelper, context);
+        context.userdate = this.addHelperFunction(this.userDateHelper, context);
         context.globals = {config: config};
         context.currentTheme = themeName;
     };
index 65c2c6a..5226f2b 100644 (file)
@@ -197,27 +197,7 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
                     return $context->getSession()->getPage()->findAll($args['selector'], $args['locator']);
                 }
 
-                // For nodes contained in other nodes we can not use the basic named selectors
-                // as they include unions and they would look for matches in the DOM root.
-                $elementxpath = $context->getSession()->getSelectorsHandler()->selectorToXpath($args['selector'], $args['locator']);
-
-                // Split the xpath in unions and prefix them with the container xpath.
-                $unions = explode('|', $elementxpath);
-                foreach ($unions as $key => $union) {
-                    $union = trim($union);
-
-                    // We are in the container node.
-                    if (strpos($union, '.') === 0) {
-                        $union = substr($union, 1);
-                    } else if (strpos($union, '/') !== 0) {
-                        // Adding the path separator in case it is not there.
-                        $union = '/' . $union;
-                    }
-                    $unions[$key] = $args['node']->getXpath() . $union;
-                }
-
-                // We can not use usual Element::find() as it prefixes with DOM root.
-                return $context->getSession()->getDriver()->find(implode('|', $unions));
+                return $args['node']->findAll($args['selector'], $args['locator']);
             },
             $params,
             $timeout,
diff --git a/lib/classes/output/mustache_engine.php b/lib/classes/output/mustache_engine.php
new file mode 100644 (file)
index 0000000..4dd2b90
--- /dev/null
@@ -0,0 +1,77 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Custom Moodle engine for mustache.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\output;
+
+/**
+ * Custom Moodle engine for mustache.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mustache_engine extends \Mustache_Engine {
+    /**
+     * @var mustache_helper_collection
+     */
+    private $helpers;
+
+    /**
+     * @var string[] Names of helpers that aren't allowed to be called within other helpers.
+     */
+    private $blacklistednestedhelpers = [];
+
+    /**
+     * Mustache engine constructor.
+     *
+     * This provides an additional option to the parent \Mustache_Engine implementation:
+     * $options = [
+     *      // A list of helpers (by name) to prevent from executing within the rendering
+     *      // of other helpers.
+     *      'blacklistednestedhelpers' => ['js']
+     * ];
+     * @param array $options [description]
+     */
+    public function __construct(array $options = []) {
+        if (isset($options['blacklistednestedhelpers'])) {
+            $this->blacklistednestedhelpers = $options['blacklistednestedhelpers'];
+        }
+
+        parent::__construct($options);
+    }
+
+    /**
+     * Get the current set of Mustache helpers.
+     *
+     * @see Mustache_Engine::setHelpers
+     *
+     * @return \Mustache_HelperCollection
+     */
+    public function getHelpers()
+    {
+        if (!isset($this->helpers)) {
+            $this->helpers = new mustache_helper_collection(null, $this->blacklistednestedhelpers);
+        }
+
+        return $this->helpers;
+    }
+}
diff --git a/lib/classes/output/mustache_helper_collection.php b/lib/classes/output/mustache_helper_collection.php
new file mode 100644 (file)
index 0000000..233d741
--- /dev/null
@@ -0,0 +1,176 @@
+<?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/>.
+
+/**
+ * Custom Moodle helper collection for mustache.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\output;
+
+/**
+ * Custom Moodle helper collection for mustache.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mustache_helper_collection extends \Mustache_HelperCollection {
+
+    /**
+     * @var string[] Names of helpers that aren't allowed to be called within other helpers.
+     */
+    private $blacklistednestedhelpers = [];
+
+    /**
+     * Helper Collection constructor.
+     *
+     * Optionally accepts an array (or Traversable) of `$name => $helper` pairs.
+     *
+     * @throws \Mustache_Exception_InvalidArgumentException if the $helpers argument isn't an array or Traversable
+     *
+     * @param array|\Traversable $helpers (default: null)
+     * @param string[] $blacklistednestedhelpers Names of helpers that aren't allowed to be called within other helpers.
+     */
+    public function __construct($helpers = null, array $blacklistednestedhelpers = []) {
+        $this->blacklistednestedhelpers = $blacklistednestedhelpers;
+        parent::__construct($helpers);
+    }
+
+    /**
+     * Add a helper to this collection.
+     *
+     * This function has overridden the parent implementation to provide blacklist
+     * functionality for certain helpers to prevent them being called from within
+     * other helpers. This is because the JavaScript helper can be used in a
+     * security exploit if it can be nested.
+     *
+     * The function will wrap callable helpers in an anonymous function that strips
+     * out the blacklisted helpers from the source string before giving it to the
+     * helper function. This prevents the blacklisted helper functions from being
+     * called by nested render functions from within other helpers.
+     *
+     * @see \Mustache_HelperCollection::add()
+     * @param string $name
+     * @param mixed  $helper
+     */
+    public function add($name, $helper)
+    {
+        $blacklist = $this->blacklistednestedhelpers;
+
+        if (is_callable($helper) && !empty($blacklist)) {
+            $helper = function($source, \Mustache_LambdaHelper $lambdahelper) use ($helper, $blacklist) {
+
+                // Temporarily override the blacklisted helpers to return nothing
+                // so that they can't be executed from within other helpers.
+                $disabledhelpers = $this->disable_helpers($blacklist);
+                // Call the original function with the modified sources.
+                $result = call_user_func($helper, $source, $lambdahelper);
+                // Restore the original blacklisted helper implementations now
+                // that this helper has finished executing so that the rest of
+                // the rendering process continues to work correctly.
+                $this->restore_helpers($disabledhelpers);
+                // Lastly parse the returned string to strip out any unwanted helper
+                // tags that were added through variable substitution (or other means).
+                // This is done because a secondary render is called on the result
+                // of a helper function if it still includes mustache tags. See
+                // the section function of Mustache_Compiler for details.
+                return $this->strip_blacklisted_helpers($blacklist, $result);
+            };
+        }
+
+        parent::add($name, $helper);
+    }
+
+    /**
+     * Disable a list of helpers (by name) by changing their implementation to
+     * simply return an empty string.
+     *
+     * @param  string[] $names List of helper names to disable
+     * @return \Closure[] The original helper functions indexed by name
+     */
+    private function disable_helpers($names) {
+        $disabledhelpers = [];
+
+        foreach ($names as $name) {
+            if ($this->has($name)) {
+                $function = $this->get($name);
+                // Null out the helper. Must call parent::add here to avoid
+                // a recursion problem.
+                parent::add($name, function() {
+                    return '';
+                });
+
+                $disabledhelpers[$name] = $function;
+            }
+        }
+
+        return $disabledhelpers;
+    }
+
+    /**
+     * Restore the original helper implementations. Typically used after disabling
+     * a helper.
+     *
+     * @param  \Closure[] $helpers The helper functions indexed by name
+     */
+    private function restore_helpers($helpers) {
+        foreach ($helpers as $name => $function) {
+            // Restore the helper functions. Must call parent::add here to avoid
+            // a recursion problem.
+            parent::add($name, $function);
+        }
+    }
+
+    /**
+     * Parse the given string and remove any reference to blacklisted helpers.
+     *
+     * E.g.
+     * $blacklist = ['js'];
+     * $string = "core, move, {{#js}} some nasty JS hack {{/js}}"
+     * result: "core, move, {{}}"
+     *
+     * @param  string[] $blacklist List of helper names to strip
+     * @param  string $string String to parse
+     * @return string Parsed string
+     */
+    public function strip_blacklisted_helpers($blacklist, $string) {
+        $starttoken = \Mustache_Tokenizer::T_SECTION;
+        $endtoken = \Mustache_Tokenizer::T_END_SECTION;
+        if ($endtoken == '/') {
+            $endtoken = '\/';
+        }
+
+        $regexes = array_map(function($name) use ($starttoken, $endtoken) {
+            // We only strip out the name of the helper (excluding delimiters)
+            // the user is able to change the delimeters on a per template
+            // basis so they may not be curly braces.
+            return '/\s*' . $starttoken . '\s*'. $name . '\W+.*' . $endtoken . '\s*' . $name . '\s*/';
+        }, $blacklist);
+
+        // This will strip out unwanted helpers from the $source string
+        // before providing it to the original helper function.
+        // E.g.
+        // Before:
+        // "core, move, {{#js}} some nasty JS hack {{/js}}"
+        // After:
+        // "core, move, {{}}"
+        return preg_replace_callback($regexes, function() {
+            return '';
+        }, $string);
+    }
+}
index c530788..abb4128 100644 (file)
@@ -3465,7 +3465,7 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2019073100.00);
     }
 
-    if ($oldversion < 2019082400.01) {
+    if ($oldversion < 2019083000.01) {
 
         // If block_community is no longer present, remove it.
         if (!file_exists($CFG->dirroot . '/blocks/community/communitycourse.php')) {
@@ -3510,7 +3510,7 @@ function xmldb_main_upgrade($oldversion) {
             $DB->delete_records_list('capabilities', 'name', $capabilitiestoberemoved);
         }
 
-        upgrade_main_savepoint(true, 2019082400.01);
+        upgrade_main_savepoint(true, 2019083000.01);
     }
 
     if ($oldversion < 2019083000.02) {
index d894b36..f5d5690 100644 (file)
@@ -187,6 +187,17 @@ class pgsql_native_moodle_database extends moodle_database {
             throw new dml_connection_exception($dberr);
         }
 
+        if (!empty($this->dboptions['dbpersist'])) {
+            // There are rare situations (such as PHP out of memory errors) when open cursors may
+            // not be closed at the end of a connection. When using persistent connections, the
+            // cursors remain open and 'get in the way' of future connections. To avoid this
+            // problem, close all cursors here.
+            $result = pg_query($this->pgsql, 'CLOSE ALL');
+            if ($result) {
+                pg_free_result($result);
+            }
+        }
+
         if (!empty($this->dboptions['dbhandlesoptions'])) {
             /* We don't trust people who just set the dbhandlesoptions, this code checks up on them.
              * These functions do not talk to the server, they use the client library knowledge to determine state.
index ffe8c16..b01fda6 100644 (file)
@@ -36,7 +36,7 @@ $string['pluginname'] = 'Atto HTML editor';
 $string['subplugintype_atto'] = 'Atto plugin';
 $string['subplugintype_atto_plural'] = 'Atto plugins';
 $string['settings'] = 'Atto toolbar settings';
-$string['taskautosavecleanup'] = 'Delete expired autosave drafts from the database.';
+$string['taskautosavecleanup'] = 'Delete expired autosave drafts';
 $string['textrecovered'] = 'A draft version of this text was automatically restored.';
 $string['toolbarconfig'] = 'Toolbar config';
 $string['toolbarconfig_desc'] = 'The list of plugins and the order they are displayed can be configured here. The configuration consists of groups (one per line) followed by the ordered list of plugins for that group. The group is separated from the plugins with an equals sign and the plugins are separated with commas. The group names must be unique and should indicate what the buttons have in common. Button and group names should not be repeated and may only contain alphanumeric characters.';
diff --git a/lib/form/amd/build/submit.min.js b/lib/form/amd/build/submit.min.js
new file mode 100644 (file)
index 0000000..46f722e
Binary files /dev/null and b/lib/form/amd/build/submit.min.js differ
diff --git a/lib/form/amd/build/submit.min.js.map b/lib/form/amd/build/submit.min.js.map
new file mode 100644 (file)
index 0000000..afc8bae
Binary files /dev/null and b/lib/form/amd/build/submit.min.js.map differ
diff --git a/lib/form/amd/src/submit.js b/lib/form/amd/src/submit.js
new file mode 100644 (file)
index 0000000..1e4e7dd
--- /dev/null
@@ -0,0 +1,48 @@
+// 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/>.
+
+/**
+ * Submit button JavaScript. All submit buttons will be automatically disabled once the form is
+ * submitted, unless that submission results in an error/cancelling the submit.
+ *
+ * @module core_form/submit
+ * @package core_form
+ * @copyright 2019 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since 3.8
+ */
+
+/**
+ * Initialises submit buttons.
+ *
+ * @param {String} elementId Form element
+ */
+export const init = (elementId) => {
+    const button = document.getElementById(elementId);
+    button.form.addEventListener('submit', function() {
+        // Only disable it if the browser is really going to another page as a result of the
+        // submit.
+        const disableAction = function() {
+            button.disabled = true;
+        };
+        window.addEventListener('beforeunload', disableAction);
+        // If there is no beforeunload event as a result of this form submit, then the form
+        // submit must have been cancelled, so don't disable the button if the page is
+        // unloaded later.
+        setTimeout(function() {
+            window.removeEventListener('beforeunload', disableAction);
+        }, 0);
+    }, false);
+};
index 8c2c047..51b9700 100644 (file)
         {{/element.frozen}}
     {{/element}}
 {{/ core_form/element-template-inline }}
+{{# js }}
+    {{^element.frozen}}
+        require(['core_form/submit'], function(Submit) {
+            Submit.init("{{ element.id }}");
+        });
+    {{/element.frozen}}
+{{/ js }}
index 058e414..330c8be 100644 (file)
         {{/element.frozen}}
     {{/element}}
 {{/ core_form/element-template }}
+{{# js }}
+    {{^element.frozen}}
+        require(['core_form/submit'], function(Submit) {
+            Submit.init("{{ element.id }}");
+        });
+    {{/element.frozen}}
+{{/ js }}
index fa6bfe5..31cb592 100644 (file)
@@ -38,7 +38,7 @@ class processor implements  \core_analytics\classifier, \core_analytics\regresso
     /**
      * The required version of the python package that performs all calculations.
      */
-    const REQUIRED_PIP_PACKAGE_VERSION = '1.0.0';
+    const REQUIRED_PIP_PACKAGE_VERSION = '2.0.0';
 
     /**
      * The path to the Python bin.
index a00f66b..fa0cc06 100644 (file)
@@ -120,12 +120,16 @@ class renderer_base {
                              'userdate' => array($userdatehelper, 'transform'),
                          );
 
-            $this->mustache = new Mustache_Engine(array(
+            $this->mustache = new \core\output\mustache_engine(array(
                 'cache' => $cachedir,
                 'escape' => 's',
                 'loader' => $loader,
                 'helpers' => $helpers,
-                'pragmas' => [Mustache_Engine::PRAGMA_BLOCKS]));
+                'pragmas' => [Mustache_Engine::PRAGMA_BLOCKS],
+                // Don't allow the JavaScript helper to be executed from within another
+                // helper. If it's allowed it can be used by users to inject malicious
+                // JS into the page.
+                'blacklistednestedhelpers' => ['js']));
 
         }
 
index 4cba909..842e320 100644 (file)
@@ -234,11 +234,11 @@ class behat_permissions extends behat_base {
 
             if ($allowed == 'Assignable') {
                 if (!$node->isChecked()) {
-                    $node->click();
+                    $node->check();
                 }
             } else if ($allowed == 'Not assignable') {
                 if ($node->isChecked()) {
-                    $node->click();
+                    $node->uncheck();
                 }
             } else {
                 throw new ExpectationException(
diff --git a/lib/tests/core_renderer_template_exploit_test.php b/lib/tests/core_renderer_template_exploit_test.php
new file mode 100644 (file)
index 0000000..873ec02
--- /dev/null
@@ -0,0 +1,462 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for core renderer render template exploit.
+ *
+ * @copyright 2019 Ryan Wyllie <ryan@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Unit tests for core renderer render template exploit.
+ */
+class core_renderer_template_exploit_testcase extends advanced_testcase {
+    /**
+     * Test cases to confirm that blacklisted helpers are stripped from the source
+     * text by the helper before being passed to other another helper. This prevents
+     * nested calls to helpers.
+     */
+    public function get_template_testcases() {
+        // Different helper implementations to test various combinations of nested
+        // calls to render the templates.
+        $norender = function($text) {
+            return $text;
+        };
+        $singlerender = function($text, $helper) {
+            return $helper->render($text);
+        };
+        $recursiverender = function($text, $helper) {
+            $result = $helper->render($text);
+
+            while (strpos($result, '{{') != false) {
+                $result = $helper->render($result);
+            }
+
+            return $result;
+        };
+
+        return [
+            'nested JS helper' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, {{#js}} some nasty JS {{/js}}{{/testpix}}',
+                ],
+                'torender' => 'test',
+                'context' => [],
+                'helpers' => [
+                    'testpix' => $singlerender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move,',
+                'include' => false
+            ],
+            'other nested helper' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, {{#test1}} some text {{/test1}}{{/testpix}}',
+                ],
+                'torender' => 'test',
+                'context' => [],
+                'helpers' => [
+                    'testpix' => $singlerender,
+                    'test1' => $norender,
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move,  some text',
+                'include' => false
+            ],
+            'double nested helper' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, {{#test1}} some text {{#js}} some nasty JS {{/js}} {{/test1}}{{/testpix}}',
+                ],
+                'torender' => 'test',
+                'context' => [],
+                'helpers' => [
+                    'testpix' => $singlerender,
+                    'test1' => $norender,
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move,  some text',
+                'include' => false
+            ],
+            'js helper not nested' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, some text {{/testpix}}{{#js}} some nasty JS {{/js}}',
+                ],
+                'torender' => 'test',
+                'context' => [],
+                'helpers' => [
+                    'testpix' => $singlerender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move, some text',
+                'include' => true
+            ],
+            'js in context not in helper' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, {{/testpix}}{{hack}}',
+                ],
+                'torender' => 'test',
+                'context' => [
+                    'hack' => '{{#js}} some nasty JS {{/js}}'
+                ],
+                'helpers' => [
+                    'testpix' => $singlerender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move, {{#js}} some nasty JS {{/js}}',
+                'include' => false
+            ],
+            'js in context' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, {{hack}}{{/testpix}}',
+                ],
+                'torender' => 'test',
+                'context' => [
+                    'hack' => '{{#js}} some nasty JS {{/js}}'
+                ],
+                'helpers' => [
+                    'testpix' => $singlerender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move,',
+                'include' => false
+            ],
+            'js in context double depth with single render' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, {{first}}{{/testpix}}',
+                ],
+                'torender' => 'test',
+                'context' => [
+                    'first' => '{{second}}',
+                    'second' => '{{#js}} some nasty JS {{/js}}'
+                ],
+                'helpers' => [
+                    'testpix' => $singlerender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move, {{#js}} some nasty JS {{/js}}',
+                'include' => false
+            ],
+            'js in context double depth with recursive render' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, {{first}}{{/testpix}}',
+                ],
+                'torender' => 'test',
+                'context' => [
+                    'first' => '{{second}}',
+                    'second' => '{{#js}} some nasty JS {{/js}}'
+                ],
+                'helpers' => [
+                    'testpix' => $recursiverender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move,',
+                'include' => false
+            ],
+            'partial' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, blah{{/testpix}}, {{> test2}}',
+                    'test2' => 'some content',
+                ],
+                'torender' => 'test',
+                'context' => [],
+                'helpers' => [
+                    'testpix' => $recursiverender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move, blah, some content',
+                'include' => false
+            ],
+            'partial nested' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, {{> test2}}{{/testpix}}',
+                    'test2' => 'some content',
+                ],
+                'torender' => 'test',
+                'context' => [],
+                'helpers' => [
+                    'testpix' => $recursiverender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move, some content',
+                'include' => false
+            ],
+            'partial with js' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, blah{{/testpix}}, {{> test2}}',
+                    'test2' => '{{#js}} some nasty JS {{/js}}',
+                ],
+                'torender' => 'test',
+                'context' => [],
+                'helpers' => [
+                    'testpix' => $recursiverender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move, blah,',
+                'include' => true
+            ],
+            'partial nested with js' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, {{> test2}}{{/testpix}}',
+                    'test2' => '{{#js}} some nasty JS {{/js}}',
+                ],
+                'torender' => 'test',
+                'context' => [],
+                'helpers' => [
+                    'testpix' => $recursiverender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move,',
+                'include' => false
+            ],
+            'partial with js from context' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, blah{{/testpix}}, {{{foo}}}',
+                    'test2' => '{{#js}} some nasty JS {{/js}}',
+                ],
+                'torender' => 'test',
+                'context' => [
+                    'foo' => '{{> test2}}'
+                ],
+                'helpers' => [
+                    'testpix' => $recursiverender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move, blah, {{> test2}}',
+                'include' => false
+            ],
+            'partial nested with js from context recursive render' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, {{foo}}{{/testpix}}',
+                    'test2' => '{{#js}} some nasty JS {{/js}}',
+                ],
+                'torender' => 'test',
+                'context' => [
+                    'foo' => '{{> test2}}'
+                ],
+                'helpers' => [
+                    'testpix' => $recursiverender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move,',
+                'include' => false
+            ],
+            'partial nested with js from context single render' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, {{foo}}{{/testpix}}',
+                    'test2' => '{{#js}} some nasty JS {{/js}}',
+                ],
+                'torender' => 'test',
+                'context' => [
+                    'foo' => '{{> test2}}'
+                ],
+                'helpers' => [
+                    'testpix' => $singlerender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move,',
+                'include' => false
+            ],
+            'partial double nested with js from context single render' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, {{foo}}{{/testpix}}',
+                    'test2' => '{{#js}} some nasty JS {{/js}}',
+                ],
+                'torender' => 'test',
+                'context' => [
+                    'foo' => '{{{bar}}}',
+                    'bar' => '{{> test2}}'
+                ],
+                'helpers' => [
+                    'testpix' => $singlerender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move, {{> test2}}',
+                'include' => false
+            ],
+            'partial double nested with js from context recursive render' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, {{foo}}{{/testpix}}',
+                    'test2' => '{{#js}} some nasty JS {{/js}}',
+                ],
+                'torender' => 'test',
+                'context' => [
+                    'foo' => '{{bar}}',
+                    'bar' => '{{> test2}}'
+                ],
+                'helpers' => [
+                    'testpix' => $recursiverender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move,',
+                'include' => false
+            ],
+            'array context depth 1' => [
+                'templates' => [
+                    'test' => '{{#items}}{{#testpix}} core, move, {{.}}{{/testpix}}{{/items}}'
+                ],
+                'torender' => 'test',
+                'context' => [
+                    'items' => [
+                        'legit',
+                        '{{#js}}some nasty JS{{/js}}'
+                    ]
+                ],
+                'helpers' => [
+                    'testpix' => $recursiverender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move, legit core, move,',
+                'include' => false
+            ],
+            'array context depth 2' => [
+                'templates' => [
+                    'test' => '{{#items}}{{#subitems}}{{#testpix}} core, move, {{.}}{{/testpix}}{{/subitems}}{{/items}}'
+                ],
+                'torender' => 'test',
+                'context' => [
+                    'items' => [
+                        [
+                            'subitems' => [
+                                'legit',
+                                '{{#js}}some nasty JS{{/js}}'
+                            ]
+                        ],
+                    ]
+                ],
+                'helpers' => [
+                    'testpix' => $recursiverender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move, legit core, move,',
+                'include' => false
+            ],
+            'object context depth 1' => [
+                'templates' => [
+                    'test' => '{{#items}}{{#testpix}} core, move, {{.}}{{/testpix}}{{/items}}'
+                ],
+                'torender' => 'test',
+                'context' => (object) [
+                    'items' => [
+                        'legit',
+                        '{{#js}}some nasty JS{{/js}}'
+                    ]
+                ],
+                'helpers' => [
+                    'testpix' => $recursiverender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move, legit core, move,',
+                'include' => false
+            ],
+            'object context depth 2' => [
+                'templates' => [
+                    'test' => '{{#items}}{{#subitems}}{{#testpix}} core, move, {{.}}{{/testpix}}{{/subitems}}{{/items}}'
+                ],
+                'torender' => 'test',
+                'context' => (object) [
+                    'items' => [
+                        (object) [
+                            'subitems' => [
+                                'legit',
+                                '{{#js}}some nasty JS{{/js}}'
+                            ]
+                        ],
+                    ]
+                ],
+                'helpers' => [
+                    'testpix' => $recursiverender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move, legit core, move,',
+                'include' => false
+            ],
+            'change delimeters' => [
+                'templates' => [
+                    'test' => '{{#testpix}} core, move, {{{foo}}}{{/testpix}}'
+                ],
+                'torender' => 'test',
+                'context' => [
+                    'foo' => '{{=<% %>=}} <%#js%>some nasty JS,<%/js%>'
+                ],
+                'helpers' => [
+                    'testpix' => $recursiverender
+                ],
+                'js' => 'some nasty JS',
+                'expected' => 'core, move,',
+                'include' => false
+            ]
+        ];
+    }
+
+    /**
+     * Test that the mustache_helper_collection class correctly strips
+     * @dataProvider get_template_testcases()
+     * @param string $templates The template to add
+     * @param string $torender The name of the template to render
+     * @param array $context The template context
+     * @param array $helpers Mustache helpers to add
+     * @param string $js The JS string from the template
+     * @param string $expected The expected output of the string after stripping JS
+     * @param bool $include If the JS should be added to the page or not
+     */
+    public function test_core_mustache_engine_strips_js_helper(
+        $templates,
+        $torender,
+        $context,
+        $helpers,
+        $js,
+        $expected,
+        $include
+    ) {
+        $page = new \moodle_page();
+        $renderer = $page->get_renderer('core');
+
+        // Get the mustache engine from the renderer.
+        $reflection = new \ReflectionMethod($renderer, 'get_mustache');
+        $reflection->setAccessible(true);
+        $engine = $reflection->invoke($renderer);
+
+        // Swap the loader out with an array loader so that we can set some
+        // inline templates for testing.
+        $loader = new \Mustache_Loader_ArrayLoader([]);
+        $engine->setLoader($loader);
+
+        // Add our test helpers.
+        $helpercollection = $engine->getHelpers();
+        foreach ($helpers as $name => $function) {
+            $helpercollection->add($name, $function);
+        }
+
+        // Add our test template to be rendered.
+        foreach ($templates as $name => $template) {
+            $loader->setTemplate($name, $template);
+        }
+
+        // Confirm that the rendered template matches what we expect.
+        $this->assertEquals($expected, trim($engine->render($torender, $context)));
+
+        if ($include) {
+            // Confirm that the JS was added to the page.
+            $this->assertContains($js, $page->requires->get_end_code());
+        } else {
+            // Confirm that the JS wasn't added to the page.
+            $this->assertNotContains($js, $page->requires->get_end_code());
+        }
+    }
+}
diff --git a/lib/tests/output_mustache_helper_collection_test.php b/lib/tests/output_mustache_helper_collection_test.php
new file mode 100644 (file)
index 0000000..aaac1e9
--- /dev/null
@@ -0,0 +1,177 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for lib/classes/output/mustache_helper_collection
+ *
+ * @copyright 2019 Ryan Wyllie <ryan@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\output\mustache_helper_collection;
+
+/**
+ * Unit tests for the mustache_helper_collection class.
+ */
+class core_output_mustache_helper_collection_testcase extends advanced_testcase {
+    /**
+     * Test cases to confirm that blacklisted helpers are stripped from the source
+     * text by the helper before being passed to other another helper. This prevents
+     * nested calls to helpers.
+     */
+    public function get_strip_blacklisted_helpers_testcases() {
+        return [
+            'no blacklist' => [
+                'blacklist' => [],
+                'input' => 'core, move, {{#js}} some nasty JS {{/js}}',
+                'expected' => 'core, move, {{#js}} some nasty JS {{/js}}'
+            ],
+            'blacklist no match' => [
+                'blacklist' => ['foo'],
+                'input' => 'core, move, {{#js}} some nasty JS {{/js}}',
+                'expected' => 'core, move, {{#js}} some nasty JS {{/js}}'
+            ],
+            'blacklist partial match 1' => [
+                'blacklist' => ['js'],
+                'input' => 'core, move, {{#json}} some nasty JS {{/json}}',
+                'expected' => 'core, move, {{#json}} some nasty JS {{/json}}'
+            ],
+            'blacklist partial match 2' => [
+                'blacklist' => ['js'],
+                'input' => 'core, move, {{#onjs}} some nasty JS {{/onjs}}',
+                'expected' => 'core, move, {{#onjs}} some nasty JS {{/onjs}}'
+            ],
+            'single blacklist 1' => [
+                'blacklist' => ['js'],
+                'input' => 'core, move, {{#js}} some nasty JS {{/js}}',
+                'expected' => 'core, move, {{}}'
+            ],
+            'single blacklist 2' => [
+                'blacklist' => ['js'],
+                'input' => 'core, move, {{ # js }} some nasty JS {{ /  js }}',
+                'expected' => 'core, move, {{}}'
+            ],
+            'single blacklist 3' => [
+                'blacklist' => ['js'],
+                'input' => 'core, {{#js}} some nasty JS {{/js}}, test',
+                'expected' => 'core, {{}}, test'
+            ],
+            'single blacklist 3' => [
+                'blacklist' => ['js'],
+                'input' => 'core, {{#ok}} this is ok {{/ok}}, {{#js}} some nasty JS {{/js}}',
+                'expected' => 'core, {{#ok}} this is ok {{/ok}}, {{}}'
+            ],
+            'single blacklist multiple matches 1' => [
+                'blacklist' => ['js'],
+                'input' => 'core, {{#js}} some nasty JS {{/js}}, {{#js}} some nasty JS {{/js}}',
+                'expected' => 'core, {{}}'
+            ],
+            'single blacklist multiple matches 2' => [
+                'blacklist' => ['js'],
+                'input' => 'core, {{ # js }} some nasty JS {{ /  js }}, {{ # js }} some nasty JS {{ /  js }}',
+                'expected' => 'core, {{}}'
+            ],
+            'single blacklist multiple matches nested 1' => [
+                'blacklist' => ['js'],
+                'input' => 'core, move, {{#js}} some nasty JS {{#js}} some nasty JS {{/js}} {{/js}}',
+                'expected' => 'core, move, {{}}'
+            ],
+            'single blacklist multiple matches nested 2' => [
+                'blacklist' => ['js'],
+                'input' => 'core, move, {{ # js }} some nasty JS {{ # js }} some nasty JS {{ /  js }}{{ /  js }}',
+                'expected' => 'core, move, {{}}'
+            ],
+            'multiple blacklist 1' => [
+                'blacklist' => ['js', 'foo'],
+                'input' => 'core, move, {{#js}} some nasty JS {{/js}}',
+                'expected' => 'core, move, {{}}'
+            ],
+            'multiple blacklist 2' => [
+                'blacklist' => ['js', 'foo'],
+                'input' => 'core, {{#foo}} blah {{/foo}}, {{#js}} js {{/js}}',
+                'expected' => 'core, {{}}, {{}}'
+            ],
+            'multiple blacklist 3' => [
+                'blacklist' => ['js', 'foo'],
+                'input' => '{{#foo}} blah {{/foo}}, {{#foo}} blah {{/foo}}, {{#js}} js {{/js}}',
+                'expected' => '{{}}, {{}}'
+            ],
+            'multiple blacklist 4' => [
+                'blacklist' => ['js', 'foo'],
+                'input' => '{{#foo}} blah {{/foo}}, {{#js}} js {{/js}}, {{#foo}} blah {{/foo}}',
+                'expected' => '{{}}'
+            ],
+            'multiple blacklist 4' => [
+                'blacklist' => ['js', 'foo'],
+                'input' => 'core, move, {{#js}} JS {{#foo}} blah {{/foo}} {{/js}}',
+                'expected' => 'core, move, {{}}'
+            ],
+        ];
+    }
+
+    /**
+     * Test that the mustache_helper_collection class correctly strips
+     * @dataProvider get_strip_blacklisted_helpers_testcases()
+     * @param string[] $blacklist The list of helpers to strip
+     * @param string $input The input string for the helper
+     * @param string $expected The expected output of the string after blacklist strip
+     */
+    public function test_strip_blacklisted_helpers($blacklist, $input, $expected) {
+        $collection = new mustache_helper_collection(null, $blacklist);
+        $this->assertEquals($expected, $collection->strip_blacklisted_helpers($blacklist, $input));
+    }
+
+    /**
+     * Test that the blacklisted helpers are disabled during the execution of other
+     * helpers.
+     *
+     * Any non-blacklisted helper should still be available to call during the
+     * execution of a helper.
+     */
+    public function test_blacklisted_helpers_disabled_during_execution() {
+        $engine = new \Mustache_Engine();
+        $context = new \Mustache_Context();
+        $lambdahelper = new \Mustache_LambdaHelper($engine, $context);
+        $blacklist = ['bad'];
+        $collection = new mustache_helper_collection(null, $blacklist);
+        $badcalled = false;
+        $goodcalled = false;
+
+        $badhelper = function() use (&$badcalled) {
+            $badcalled = true;
+            return '';
+        };
+        $goodhelper = function() use (&$goodcalled) {
+            $goodcalled = true;
+            return '';
+        };
+        // A test helper that just returns the text without modifying it.
+        $testhelper = function($text, $lambda) use ($collection) {
+            $collection->get('good')($text, $lambda);
+            $collection->get('bad')($text, $lambda);
+            return $text;
+        };
+        $collection->add('bad', $badhelper);
+        $collection->add('good', $goodhelper);
+        $collection->add('test', $testhelper);
+
+        $this->assertEquals('success output', $collection->get('test')('success output', $lambdahelper));
+        $this->assertTrue($goodcalled);
+        $this->assertFalse($badcalled);
+    }
+}
index 0de9db9..2e503f2 100644 (file)
@@ -40,7 +40,7 @@ Feature: To be able to block users that we are able to or to see a message if we
     And I select "Teacher 1" user in messaging
     And I open contact menu
     When I click on "Block" "link" in the "[data-region='header-container']" "css_element"
-    Then I should see "You are unable to block Teacher 1"
+    Then I should see "You can't block Teacher 1"
 
   Scenario: Block a user who then gets an elevated role
     Given I log in as "student1"
@@ -64,4 +64,4 @@ Feature: To be able to block users that we are able to or to see a message if we
     And I select "Student 2" user in messaging
     And I open contact menu
     When I click on "Block" "link" in the "[data-region='header-container']" "css_element"
-    Then I should see "You are unable to block Student 2"
+    Then I should see "You can't block Student 2"
index 92b6b8b..0c1836c 100644 (file)
@@ -84,7 +84,11 @@ $string['attempthistory'] = 'Previous attempts';
 $string['attemptnumber'] = 'Attempt number';
 $string['attemptsettings'] = 'Attempt settings';
 $string['attemptreopenmethod'] = 'Attempts reopened';
-$string['attemptreopenmethod_help'] = 'Determines how student submission attempts are reopened. The available options are: <ul><li>Never - The submission cannot be reopened.</li><li>Manually - The submission can be reopened by a teacher.</li><li>Automatically until pass - The submission is automatically reopened until the student achieves the grade to pass value set in the gradebook for this assignment.</li></ul>';
+$string['attemptreopenmethod_help'] = 'Determines how student submission attempts are reopened. The available options are:
+
+* Never - The submission cannot be reopened.
+* Manually - The submission can be reopened by a teacher.
+* Automatically until pass - The submission is automatically reopened until the student achieves the grade to pass set in the gradebook for this assignment.';
 $string['attemptreopenmethod_manual'] = 'Manually';
 $string['attemptreopenmethod_none'] = 'Never';
 $string['attemptreopenmethod_untilpass'] = 'Automatically until pass';
index 7211b0d..fa4cdc2 100644 (file)
@@ -8121,14 +8121,14 @@ class assign {
 
                 // Will not apply update if user does not have permission to assign this workflow state.
                 if (!$gradingdisabled && $this->update_user_flags($flags)) {
-                    if ($state == ASSIGN_MARKING_WORKFLOW_STATE_RELEASED) {
-                        // Update Gradebook.
-                        $assign = clone $this->get_instance();
-                        $assign->cmidnumber = $this->get_course_module()->idnumber;
-                        // Set assign gradebook feedback plugin status.
-                        $assign->gradefeedbackenabled = $this->is_gradebook_feedback_enabled();
-                        assign_update_grades($assign, $userid);
-                    }
+                    // Update Gradebook.
+                    $grade = $this->get_user_grade($userid, true);
+                    $this->update_grade($grade);
+                    $assign = clone $this->get_instance();
+                    $assign->cmidnumber = $this->get_course_module()->idnumber;
+                    // Set assign gradebook feedback plugin status.
+                    $assign->gradefeedbackenabled = $this->is_gradebook_feedback_enabled();
+                    assign_update_grades($assign, $userid);
 
                     $user = $DB->get_record('user', array('id' => $userid), '*', MUST_EXIST);
                     \mod_assign\event\workflow_state_updated::create_from_user($this, $user, $state)->trigger();
index 33da942..3175903 100644 (file)
@@ -1,7 +1,8 @@
 @mod @mod_assign
-Feature: As a teacher in course with relative dates mode enabled
+Feature: Relative assignment due dates
+In order for students to be able to enter the course at any time and have a fixed period in which to submit the assignment
+As a teacher in course with relative dates mode enabled
 I should be able to create an assignment with a due date relative to the course start date
-So that students can enter the course at any time and have a fixed period in which to submit the assignment
 
   Scenario: As a student the due date for submitting my assignment is relative to my course start date
     Given the following config values are set as admin:
@@ -13,13 +14,13 @@ So that students can enter the course at any time and have a fixed period in whi
       | username | firstname | lastname | email |
       | teacher1 | Teacher | 1 | teacher1@example.com |
       | student1 | Student | 1 | student1@example.com |
-      | student2 | Student | 2 | student1@example.com |
+      | student2 | Student | 2 | student2@example.com |
     And the following "course enrolments" exist:
-     # Two students, one started 4 months ago and one this month.
+     # Two students, one started 4 months ago and one yesterday.
       | user | course | role | timestart |
       | teacher1 | C1 | editingteacher | ##first day of last month## |
-      | student1 | C1 | student | ##first day of -4 months##        |
-      | student2 | C1 | student | ##first day of this month##        |
+      | student1 | C1 | student | ##first day of -4 months## |
+      | student2 | C1 | student | ##yesterday## |
      # One assignment, valid for 2 months.
     And the following "activities" exist:
       | activity   | name                   | intro                         | course | idnumber    | assignsubmission_onlinetext_enabled | timeopen | duedate |
@@ -40,18 +41,18 @@ So that students can enter the course at any time and have a fixed period in whi
       | enablecourserelativedates | 1 |
     And the following "courses" exist:
       | fullname | shortname | category | groupmode | relativedatesmode | startdate |
-      | Course 1 | C1 | 0 | 1 | 1 | ##first day of 4 months ago##                     |
+      | Course 1 | C1 | 0 | 1 | 1 | ##first day of 4 months ago## |
     And the following "users" exist:
       | username | firstname | lastname | email |
       | teacher1 | Teacher | 1 | teacher1@example.com |
       | student1 | Student | 1 | student1@example.com |
-      | student2 | Student | 2 | student1@example.com |
+      | student2 | Student | 2 | student2@example.com |
     And the following "course enrolments" exist:
-     # Two students, one started 4 months ago and one this month.
+     # Two students, one started 4 months ago and one yesterday.
       | user | course | role | timestart |
       | teacher1 | C1 | editingteacher | ##first day of 4 months ago## |
-      | student1 | C1 | student | ##first day of 4 months ago##        |
-      | student2 | C1 | student | ##first day of this month##        |
+      | student1 | C1 | student | ##first day of 4 months ago## |
+      | student2 | C1 | student | ##yesterday## |
      # One assignment, valid for 2 months.
     And the following "activities" exist:
       | activity   | name                   | intro                         | course | idnumber    | assignsubmission_onlinetext_enabled | timeopen | duedate |
index c0f82dd..171a879 100644 (file)
@@ -433,24 +433,34 @@ class assign_events_testcase extends advanced_testcase {
         $assign->testable_process_set_batch_marking_workflow_state($student->id, ASSIGN_MARKING_WORKFLOW_STATE_INREVIEW);
 
         $events = $sink->get_events();
-        $this->assertCount(1, $events);
-        $event = reset($events);
-        $this->assertInstanceOf('\mod_assign\event\workflow_state_updated', $event);
-        $this->assertEquals($assign->get_context(), $event->get_context());
-        $this->assertEquals($assign->get_instance()->id, $event->objectid);
-        $this->assertEquals($student->id, $event->relateduserid);
-        $this->assertEquals($teacher->id, $event->userid);
-        $this->assertEquals(ASSIGN_MARKING_WORKFLOW_STATE_INREVIEW, $event->other['newstate']);
-        $expected = array(
-            $assign->get_course()->id,
-            'assign',
-            'set marking workflow state',
-            'view.php?id=' . $assign->get_course_module()->id,
-            get_string('setmarkingworkflowstateforlog', 'assign', array('id' => $student->id,
-                'fullname' => fullname($student), 'state' => ASSIGN_MARKING_WORKFLOW_STATE_INREVIEW)),
-            $assign->get_course_module()->id
-        );
-        $this->assertEventLegacyLogData($expected, $event);
+        $eventcount = 0;
+        foreach ($events as $event) {
+            if ($event instanceof \mod_assign\event\submission_graded) {
+                $eventcount++;
+                $this->assertInstanceOf('\mod_assign\event\submission_graded', $event);
+                $this->assertEquals($assign->get_context(), $event->get_context());
+            }
+            if ($event instanceof \mod_assign\event\workflow_state_updated) {
+                $eventcount++;
+                $this->assertInstanceOf('\mod_assign\event\workflow_state_updated', $event);
+                $this->assertEquals($assign->get_context(), $event->get_context());
+                $this->assertEquals($assign->get_instance()->id, $event->objectid);
+                $this->assertEquals($student->id, $event->relateduserid);
+                $this->assertEquals($teacher->id, $event->userid);
+                $this->assertEquals(ASSIGN_MARKING_WORKFLOW_STATE_INREVIEW, $event->other['newstate']);
+                $expected = array(
+                    $assign->get_course()->id,
+                    'assign',
+                    'set marking workflow state',
+                    'view.php?id=' . $assign->get_course_module()->id,
+                    get_string('setmarkingworkflowstateforlog', 'assign', array('id' => $student->id,
+                        'fullname' => fullname($student), 'state' => ASSIGN_MARKING_WORKFLOW_STATE_INREVIEW)),
+                    $assign->get_course_module()->id
+                );
+                $this->assertEventLegacyLogData($expected, $event);
+            }
+        }
+        $this->assertEquals(2, $eventcount);
         $sink->close();
 
         // Test setting workflow state in apply_grade_to_user.
index c96261a..a11c54e 100644 (file)
@@ -273,7 +273,7 @@ if (($mode == 'new') && (!empty($newtype)) && confirm_sesskey()) {          ///
             get_string('fielddescription', 'data'),
             get_string('action', 'data'),
         );
-        $table->align = array('left','left','left', 'center');
+        $table->align = array('left', 'left', 'left', 'left');
         $table->wrap = array(false,false,false,false);
 
         if ($fff = $DB->get_records('data_fields', array('dataid'=>$data->id),'id')){
index 0c5fbdb..8d8b8f9 100644 (file)
@@ -39,7 +39,7 @@ $mode           = optional_param('mode', null, PARAM_INT);     // The forum's su
 $user           = optional_param('user', 0, PARAM_INT);        // The userid of the user to subscribe, defaults to $USER.
 $discussionid   = optional_param('d', null, PARAM_INT);        // The discussionid to subscribe.
 $sesskey        = optional_param('sesskey', null, PARAM_RAW);
-$returnurl      = optional_param('returnurl', null, PARAM_RAW);
+$returnurl      = optional_param('returnurl', null, PARAM_LOCALURL);
 
 $url = new moodle_url('/mod/forum/subscribe.php', array('id'=>$id));
 if (!is_null($mode)) {
index 32e795f..a81112d 100644 (file)
@@ -903,7 +903,7 @@ $string['shuffledrandomly'] = 'Shuffled randomly';
 $string['shufflequestions'] = 'Shuffle';
 $string['shufflequestions_help'] = 'If enabled, every time the quiz is attempted, the order of the questions in this section will be shuffled into a different random order.
 
-This can make it harder for students to share answers, but it also makes it harder for students discuss a particular question with the teacher.';
+This can make it harder for students to share answers, but it also makes it harder for students to discuss a particular question with the teacher.';
 $string['shufflewithin'] = 'Shuffle within questions';
 $string['shufflewithin_help'] = 'If enabled, the parts making up each question will be randomly shuffled each time a student attempts the quiz, provided the option is also enabled in the question settings. This setting only applies to questions that have multiple parts, such as multiple choice or matching questions.';
 $string['singleanswer'] = 'Choose one answer.';
index fa84cd6..aa03139 100644 (file)
@@ -859,7 +859,9 @@ class mod_scorm_external extends external_api {
      * @throws moodle_exception
      */
     public static function launch_sco($scormid, $scoid = 0) {
-        global $DB;
+        global $DB, $CFG;
+
+        require_once($CFG->libdir . '/completionlib.php');
 
         $params = self::validate_parameters(self::launch_sco_parameters(),
                                             array(
@@ -882,6 +884,10 @@ class mod_scorm_external extends external_api {
             throw new moodle_exception('cannotfindsco', 'scorm');
         }
 
+        // Mark module viewed.
+        $completion = new completion_info($course);
+        $completion->set_module_viewed($cm);
+
         list($sco, $scolaunchurl) = scorm_get_sco_and_launch_url($scorm, $params['scoid'], $context);
         // Trigger the SCO launched event.
         scorm_launch_sco($scorm, $sco, $cm, $context, $scolaunchurl);
index 5978297..b6ac0fd 100644 (file)
@@ -46,13 +46,15 @@ class mod_scorm_external_testcase extends externallib_advanced_testcase {
      * Set up for every test
      */
     public function setUp() {
-        global $DB;
+        global $DB, $CFG;
         $this->resetAfterTest();
         $this->setAdminUser();
 
+        $CFG->enablecompletion = 1;
         // Setup test data.
-        $this->course = $this->getDataGenerator()->create_course();
-        $this->scorm = $this->getDataGenerator()->create_module('scorm', array('course' => $this->course->id));
+        $this->course = $this->getDataGenerator()->create_course(array('enablecompletion' => 1));
+        $this->scorm = $this->getDataGenerator()->create_module('scorm', array('course' => $this->course->id),
+            array('completion' => 2, 'completionview' => 1));
         $this->context = context_module::instance($this->scorm->cmid);
         $this->cm = get_coursemodule_from_instance('scorm', $this->scorm->id);
 
@@ -849,8 +851,8 @@ class mod_scorm_external_testcase extends externallib_advanced_testcase {
         $result = external_api::clean_returnvalue(mod_scorm_external::launch_sco_returns(), $result);
 
         $events = $sink->get_events();
-        $this->assertCount(1, $events);
-        $event = array_shift($events);
+        $this->assertCount(3, $events);
+        $event = array_pop($events);
 
         // Checking that the event contains the expected values.
         $this->assertInstanceOf('\mod_scorm\event\sco_launched', $event);
@@ -860,6 +862,14 @@ class mod_scorm_external_testcase extends externallib_advanced_testcase {
         $this->assertEventContextNotUsed($event);
         $this->assertNotEmpty($event->get_name());
 
+        $event = array_shift($events);
+        $this->assertInstanceOf('\core\event\course_module_completion_updated', $event);
+
+        // Check completion status.
+        $completion = new completion_info($this->course);
+        $completiondata = $completion->get_data($this->cm);
+        $this->assertEquals(COMPLETION_VIEWED, $completiondata->completionstate);
+
         // Invalid SCO.
         try {
             mod_scorm_external::launch_sco($this->scorm->id, -1);
index 3ea9bcd..1603e53 100644 (file)
       "dev": true
     },
     "jshint": {
-      "version": "0.9.1",
-      "resolved": "https://registry.npmjs.org/jshint/-/jshint-0.9.1.tgz",
-      "integrity": "sha1-/zLsfwn4QAH3SY7q/WPJ5Puy3A4=",
-      "dev": true,
-      "requires": {
-        "cli": "0.4.3",
-        "minimatch": "0.0.x"
+      "version": "2.10.2",
+      "resolved": "https://registry.npmjs.org/jshint/-/jshint-2.10.2.tgz",
+      "integrity": "sha512-e7KZgCSXMJxznE/4WULzybCMNXNAd/bf5TSrvVEq78Q/K8ZwFpmBqQeDtNiHc3l49nV4E/+YeHU/JZjSUIrLAA==",
+      "dev": true,
+      "requires": {
+        "cli": "~1.0.0",
+        "console-browserify": "1.1.x",
+        "exit": "0.1.x",
+        "htmlparser2": "3.8.x",
+        "lodash": "~4.17.11",
+        "minimatch": "~3.0.2",
+        "shelljs": "0.3.x",
+        "strip-json-comments": "1.0.x"
       },
       "dependencies": {
         "cli": {
-          "version": "0.4.3",
-          "resolved": "https://registry.npmjs.org/cli/-/cli-0.4.3.tgz",
-          "integrity": "sha1-5oGcjV+qlX9k+Y9mqFBiaMHR8X0=",
+          "version": "1.0.1",
+          "resolved": "https://registry.npmjs.org/cli/-/cli-1.0.1.tgz",
+          "integrity": "sha1-IoF1NPJL+klQw01TLUjsvGIbjBQ=",
           "dev": true,
           "requires": {
-            "glob": ">= 3.1.4"
+            "exit": "0.1.2",
+            "glob": "^7.1.1"
           }
         },
-        "lru-cache": {
-          "version": "1.0.6",
-          "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-1.0.6.tgz",
-          "integrity": "sha1-qlD5cEdCKsclQ72hd6nJ0BjZhFI=",
+        "strip-json-comments": {
+          "version": "1.0.4",
+          "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz",
+          "integrity": "sha1-HhX7ysl9Pumb8tc7TGVrCCu6+5E=",
           "dev": true
-        },
-        "minimatch": {
-          "version": "0.0.5",
-          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.0.5.tgz",
-          "integrity": "sha1-lrtJC707poNrv6wRGt91MBsVhN4=",
-          "dev": true,
-          "requires": {
-            "lru-cache": "~1.0.2"
-          }
         }
       }
     },
         "yuitest-coverage": ">=0.0.5"
       },
       "dependencies": {
+        "cli": {
+          "version": "0.4.3",
+          "resolved": "https://registry.npmjs.org/cli/-/cli-0.4.3.tgz",
+          "integrity": "sha1-5oGcjV+qlX9k+Y9mqFBiaMHR8X0=",
+          "dev": true,
+          "requires": {
+            "glob": ">= 3.1.4"
+          }
+        },
+        "jshint": {
+          "version": "0.9.1",
+          "resolved": "https://registry.npmjs.org/jshint/-/jshint-0.9.1.tgz",
+          "integrity": "sha1-/zLsfwn4QAH3SY7q/WPJ5Puy3A4=",
+          "dev": true,
+          "requires": {
+            "cli": "0.4.3",
+            "minimatch": "0.0.x"
+          }
+        },
+        "lru-cache": {
+          "version": "1.0.6",
+          "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-1.0.6.tgz",
+          "integrity": "sha1-qlD5cEdCKsclQ72hd6nJ0BjZhFI=",
+          "dev": true
+        },
+        "minimatch": {
+          "version": "0.0.5",
+          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.0.5.tgz",
+          "integrity": "sha1-lrtJC707poNrv6wRGt91MBsVhN4=",
+          "dev": true,
+          "requires": {
+            "lru-cache": "~1.0.2"
+          }
+        },
         "progress": {
           "version": "0.1.0",
           "resolved": "https://registry.npmjs.org/progress/-/progress-0.1.0.tgz",
index 341ed30..036d3ee 100644 (file)
@@ -27,6 +27,7 @@
     "grunt-eslint": "20.1.0",
     "grunt-sass": "2.1.0",
     "grunt-stylelint": "0.6.0",
+    "jshint": "^2.10.2",
     "semver": "5.3.0",
     "shifter": "0.5.0",
     "stylelint": "8.0.0",
index 5e19c26..0ae8a6c 100644 (file)
@@ -146,18 +146,20 @@ class core_rating_external extends external_api {
                     $rating->rating = $maxrating;
                 }
 
-                // The rating object has all the required fields for generating the picture url.
-                $userpicture = new user_picture($rating);
-                $userpicture->size = 1; // Size f1.
-                $profileimageurl = $userpicture->get_url($PAGE)->out(false);
-
                 $result = array();
                 $result['id'] = $rating->id;
                 $result['userid'] = $rating->userid;
-                $result['userpictureurl'] = $profileimageurl;
                 $result['userfullname'] = fullname($rating);
                 $result['rating'] = $scalemenu[$rating->rating];
                 $result['timemodified'] = $rating->timemodified;
+
+                // The rating object has all the required fields for generating the picture url.
+                // Undo the aliasing of the user id column from user_picture::fields().
+                $rating->id = $rating->userid;
+                $userpicture = new user_picture($rating);
+                $userpicture->size = 1; // Size f1.
+                $result['userpictureurl'] = $userpicture->get_url($PAGE)->out(false);
+
                 $results[] = $result;
             }
         }
index 5b2cd7c..58a8575 100644 (file)
@@ -244,17 +244,17 @@ M.core_user.init_user_selector = function (Y, name, hash, extrafields, lastsearc
          */
         output_group : function(groupname, users, selectedusers, processsingle) {
             var optgroup = Y.Node.create('<optgroup></optgroup>');
+            this.listbox.append(optgroup);
+
             var count = 0;
             for (var key in users) {
                 var user = users[key];
                 var option = Y.Node.create('<option value="' + user.id + '">' + user.name + '</option>');
                 if (user.disabled) {
-                    option.set('disabled', true);
+                    option.setAttribute('disabled', 'disabled');
                 } else if (selectedusers === true || selectedusers[user.id]) {
-                    option.set('selected', true);
+                    option.setAttribute('selected', 'selected');
                     delete selectedusers[user.id];
-                } else {
-                    option.set('selected', false);
                 }
                 optgroup.append(option);
                 if (user.infobelow) {
@@ -268,13 +268,12 @@ M.core_user.init_user_selector = function (Y, name, hash, extrafields, lastsearc
             if (count > 0) {
                 optgroup.set('label', groupname + ' (' + count + ')');
                 if (processsingle && count === 1 && this.get_option('autoselectunique') && option.get('disabled') == false) {
-                    option.set('selected', true);
+                    option.setAttribute('selected', 'selected');
                 }
             } else {
                 optgroup.set('label', groupname);
                 optgroup.append(Y.Node.create('<option disabled="disabled">\u00A0</option>'));
             }
-            this.listbox.append(optgroup);
         },
         /**
          * Replace
index 750ca5c..bb3fc59 100644 (file)
@@ -23,35 +23,35 @@ Feature: Set the site home page and dashboard as the default home page
       | Page contexts | Display throughout the entire site |
     And I press "Save changes"
     And I navigate to "Appearance > Navigation" in site administration
-    And I set the field "Default home page for users" to "User preference"
+    And I set the field "Home page for users" to "User preference"
     And I press "Save changes"
     And I am on site homepage
-    And I follow "Make this my default home page"
-    And I should not see "Make this my default home page"
+    And I follow "Make this my home page"
+    And I should not see "Make this my home page"
     And I am on "Course 1" course homepage
     And "Home" "text" should exist in the ".breadcrumb" "css_element"
     And I am on site homepage
     And I follow "Dashboard"
-    And I follow "Make this my default home page"
-    And I should not see "Make this my default home page"
+    And I follow "Make this my home page"
+    And I should not see "Make this my home page"
     And I am on "Course 1" course homepage
     Then "Dashboard" "text" should exist in the ".breadcrumb" "css_element"
 
   Scenario: User cannot configure their preferred default home page unless allowed by admin
     Given I log in as "user1"
     When I follow "Preferences" in the user menu
-    Then I should not see "Default home page"
+    Then I should not see "Home page"
 
   Scenario Outline: User can configure their preferred default home page when allowed by admin
     Given I log in as "admin"
     And I navigate to "Appearance > Navigation" in site administration
-    And I set the field "Default home page for users" to "User preference"
+    And I set the field "Home page for users" to "User preference"
     And I press "Save changes"
     And I log out
     When I log in as "user1"
     And I follow "Preferences" in the user menu
-    And I follow "Default home page"
-    And I set the field "Default home page" to "<preference>"
+    And I follow "Home page"
+    And I set the field "Home page" to "<preference>"
     And I press "Save changes"
     Then "<breadcrumb>" "text" should exist in the ".breadcrumb" "css_element"
     Examples:
index 7167b3c..e8e11de 100644 (file)
@@ -207,6 +207,9 @@ class core_webservice_external extends external_api {
             $siteinfo['usercalendartype'] = $USER->calendartype;
         }
 
+        // User key, to avoid using the WS token for fetching assets.
+        $siteinfo['userprivateaccesskey'] = get_user_key('core_files', $USER->id);
+
         // Current theme.
         $siteinfo['theme'] = clean_param($PAGE->theme->name, PARAM_THEME);  // We always clean to avoid problem with old sites.
 
@@ -272,6 +275,8 @@ class core_webservice_external extends external_api {
                 'userhomepage' => new external_value(PARAM_INT,
                                                         'the default home page for the user: 0 for the site home, 1 for dashboard',
                                                         VALUE_OPTIONAL),
+                'userprivateaccesskey'  => new external_value(PARAM_ALPHANUM, 'Private user access key for fetching files.',
+                    VALUE_OPTIONAL),
                 'siteid'  => new external_value(PARAM_INT, 'Site course ID', VALUE_OPTIONAL),
                 'sitecalendartype'  => new external_value(PARAM_PLUGIN, 'Calendar type set in the site.', VALUE_OPTIONAL),
                 'usercalendartype'  => new external_value(PARAM_PLUGIN, 'Calendar typed used by the user.', VALUE_OPTIONAL),
index b8c72bb..92fe1e4 100644 (file)
@@ -122,6 +122,8 @@ class core_webservice_externallib_testcase extends externallib_advanced_testcase
         // covered below for admin user. This test is for user not allowed to ignore limits.
         $this->assertEquals(get_max_upload_file_size($maxbytes), $siteinfo['usermaxuploadfilesize']);
         $this->assertEquals(true, $siteinfo['usercanmanageownfiles']);
+        $userkey = get_user_key('core_files', $USER->id);
+        $this->assertEquals($userkey, $siteinfo['userprivateaccesskey']);
 
         $this->assertEquals(HOMEPAGE_MY, $siteinfo['userhomepage']);
         $this->assertEquals($CFG->calendartype, $siteinfo['sitecalendartype']);
index 407c12a..14ce75e 100644 (file)
@@ -9,6 +9,9 @@ This information is intended for authors of webservices, not people writing webs
   is passed and the web service call does not require the user to be logged in we will attempt to use GET for the
   request. This allows for things like proxy caching on URLs. The cache key must be changed if we do not want to
   retrieve what has been cached and want to perform the request again.
+* External function core_webservice_external::get_site_info() now returns the user private access key "userprivateaccesskey".
+  This key could be used for fetching files via the tokenpluginfile.php script instead webservice/pluginfile.php to avoid
+  multiple GET requests that include the WS token as a visible parameter.
 
 === 3.7 ===