Merge branch 'MDL-54797' of git://github.com/aolley/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Tue, 14 Jun 2016 05:24:47 +0000 (13:24 +0800)
committerDan Poltawski <dan@moodle.com>
Thu, 16 Jun 2016 08:04:07 +0000 (09:04 +0100)
151 files changed:
.eslintrc [new file with mode: 0644]
.gitignore
Gruntfile.js
admin/roles/classes/preset.php
admin/settings/courses.php
admin/tool/lp/amd/build/user_competency_course_navigation.min.js
admin/tool/lp/amd/src/competency_rule_points.js
admin/tool/lp/amd/src/user_competency_course_navigation.js
admin/tool/lp/lib.php
admin/tool/lp/pix/competency.png [deleted file]
admin/tool/lp/pix/competency.svg [deleted file]
admin/tool/lp/templates/user_competency_summary.mustache
admin/tool/lp/templates/user_competency_summary_in_course.mustache
admin/tool/lp/upgrade.txt [new file with mode: 0644]
admin/user/user_bulk.php
admin/user/user_bulk_enrol.php [deleted file]
admin/user/user_bulk_forms.php
backup/moodle2/backup_stepslib.php
backup/moodle2/restore_qtype_plugin.class.php
backup/moodle2/restore_stepslib.php
backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/1.expectation [new file with mode: 0644]
backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/1.test [new file with mode: 0644]
backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/2.expectation [new file with mode: 0644]
backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/2.test [new file with mode: 0644]
backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/3.expectation [new file with mode: 0644]
backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/3.test [new file with mode: 0644]
backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/4.expectation [new file with mode: 0644]
backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/4.test [new file with mode: 0644]
backup/moodle2/tests/restore_gradebook_structure_step_test.php [new file with mode: 0644]
blocks/blog_recent/tests/behat/block_blog_recent.feature [new file with mode: 0644]
blocks/blog_recent/tests/behat/block_blog_recent_activity.feature [new file with mode: 0644]
blocks/blog_recent/tests/behat/block_blog_recent_course.feature [new file with mode: 0644]
blocks/blog_recent/tests/behat/block_blog_recent_frontpage.feature [new file with mode: 0644]
blocks/comments/tests/behat/block_comment_dashboard.feature [new file with mode: 0644]
blocks/course_overview/tests/behat/quiz_overview.feature [new file with mode: 0644]
blocks/glossary_random/backup/moodle2/restore_glossary_random_block_task.class.php
blocks/glossary_random/block_glossary_random.php
blocks/glossary_random/tests/behat/glossary_random_global.feature [new file with mode: 0644]
blocks/messages/tests/behat/block_messages_course.feature [new file with mode: 0644]
blocks/messages/tests/behat/block_messages_dashboard.feature [new file with mode: 0644]
blocks/messages/tests/behat/block_messages_frontpage.feature [new file with mode: 0644]
blocks/navigation/amd/build/navblock.min.js
blocks/navigation/amd/src/navblock.js
blocks/navigation/block_navigation.php
blocks/navigation/styles.css
blocks/news_items/block_news_items.php
blocks/online_users/tests/behat/block_online_users_course.feature [new file with mode: 0644]
blocks/online_users/tests/behat/block_online_users_dashboard.feature [new file with mode: 0644]
blocks/online_users/tests/behat/block_online_users_frontpage.feature [new file with mode: 0644]
blocks/private_files/tests/behat/block_private_files_activity.feature [new file with mode: 0644]
blocks/private_files/tests/behat/block_private_files_course.feature [new file with mode: 0644]
blocks/private_files/tests/behat/block_private_files_dashboard.feature [new file with mode: 0644]
blocks/private_files/tests/behat/block_private_files_frontpage.feature [new file with mode: 0644]
blocks/private_files/tests/fixtures/testfile.txt [new file with mode: 0644]
blocks/settings/amd/build/settingsblock.min.js
blocks/settings/amd/src/settingsblock.js
blocks/settings/block_settings.php
blocks/settings/styles.css
comment/comment_post.php
course/externallib.php
course/format/lib.php
course/yui/build/moodle-course-toolboxes/moodle-course-toolboxes-debug.js
course/yui/build/moodle-course-toolboxes/moodle-course-toolboxes-min.js
course/yui/build/moodle-course-toolboxes/moodle-course-toolboxes.js
course/yui/src/toolboxes/js/resource.js
enrol/manual/ajax.php
enrol/manual/lib.php
enrol/manual/manage.php
enrol/manual/yui/quickenrolment/quickenrolment.js
grade/lib.php
install/lang/ca/admin.php
install/lang/ca/install.php
install/lang/he/admin.php
install/lang/he/install.php
install/lang/he/moodle.php
install/lang/pt/install.php
lang/en/error.php
lang/en/question.php
lib/amd/src/localstorage.js
lib/amd/src/yui.js
lib/blocklib.php
lib/classes/session/memcached.php
lib/editor/atto/autosave-ajax.php
lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-debug.js
lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-min.js
lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor.js
lib/editor/atto/yui/src/editor/build.json
lib/editor/atto/yui/src/editor/js/autosave-io.js [new file with mode: 0644]
lib/editor/atto/yui/src/editor/js/autosave.js
lib/editor/atto/yui/src/editor/meta/editor.json
lib/form/tags.php
lib/myprofilelib.php
lib/outputrenderers.php
lib/questionlib.php
lib/tests/behat/behat_hooks.php
lib/tests/questionlib_test.php
lib/tests/weblib_format_text_test.php
lib/upgrade.txt
lib/weblib.php
lib/yui/build/moodle-core-dock/moodle-core-dock-debug.js
lib/yui/build/moodle-core-dock/moodle-core-dock-min.js
lib/yui/build/moodle-core-dock/moodle-core-dock.js
lib/yui/build/moodle-core-event/moodle-core-event-debug.js
lib/yui/build/moodle-core-event/moodle-core-event-min.js
lib/yui/build/moodle-core-event/moodle-core-event.js
lib/yui/src/dock/js/dock.js
lib/yui/src/dock/meta/dock.json
lib/yui/src/event/js/event.js
message/externallib.php
message/lib.php
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-debug.js
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-min.js
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor.js
mod/assign/feedback/editpdf/yui/src/editor/js/annotationstamp.js
mod/assign/feedback/editpdf/yui/src/editor/js/comment.js
mod/assign/feedback/editpdf/yui/src/editor/js/editor.js
mod/assign/lib.php
mod/assign/locallib.php
mod/assign/mod_form.php
mod/assign/tests/base_test.php
mod/assign/tests/locallib_test.php
mod/chat/lib.php
mod/choice/lib.php
mod/data/classes/search/activity.php [new file with mode: 0644]
mod/data/data.js
mod/data/lang/en/data.php
mod/forum/lib.php
mod/lti/locallib.php
mod/quiz/lib.php
mod/quiz/report/overview/report.php
mod/quiz/tests/lib_test.php
mod/wiki/create_form.php
mod/workshop/lib.php
mod/workshop/mod_form.php
my/lib.php
npm-shrinkwrap.json
package.json
pix/i/competencies.png [new file with mode: 0644]
pix/i/competencies.svg [new file with mode: 0644]
portfolio/download/lib.php
question/category.php
question/format/gift/format.php
question/format/gift/tests/giftformat_test.php
question/type/multianswer/backup/moodle2/restore_qtype_multianswer_plugin.class.php
report/competency/amd/build/user_course_navigation.min.js
report/competency/amd/src/user_course_navigation.js
tag/classes/output/tagareacollection.php
tag/classes/tag.php
theme/index.php
user/profile/field/datetime/field.class.php
version.php

diff --git a/.eslintrc b/.eslintrc
new file mode 100644 (file)
index 0000000..31bd8ab
--- /dev/null
+++ b/.eslintrc
@@ -0,0 +1,164 @@
+{
+  'env': {
+    'browser': true,
+    'amd': true
+  },
+  'globals': {
+    'M': true,
+    'Y': true
+  },
+  'rules': {
+    // See http://eslint.org/docs/rules/ for all rules and explanations of all
+    // rules. Commented out rules with 'DEFINE POLICY' are rules Dan P has flagged
+    // for discussion and possible enable soon.
+    // === Possible Errors ===
+    // DEFINE POLICY: 'comma-dangle': ['off', 'always'],
+    'no-cond-assign': 'error',
+    'no-console': 'error',
+    'no-constant-condition': 'error',
+    'no-control-regex': 'error',
+    'no-debugger': 'error',
+    'no-dupe-args': 'error',
+    'no-dupe-keys': 'error',
+    'no-duplicate-case': 'error',
+    // Disabled for YUI rollups, enabled by grunt for AMD: 'no-empty': 'error',
+    'no-empty-character-class': 'error',
+    'no-ex-assign': 'error',
+    'no-extra-boolean-cast': 'error',
+    'no-extra-parens': 'off',
+    'no-extra-semi': 'error',
+    'no-func-assign': 'error',
+    'no-inner-declarations': 'error',
+    'no-invalid-regexp': 'error',
+    'no-irregular-whitespace': 'error',
+    'no-negated-in-lhs': 'error',
+    'no-obj-calls': 'error',
+    'no-prototype-builtins': 'off',
+    'no-regex-spaces': 'error',
+    'no-sparse-arrays': 'error',
+    'no-unexpected-multiline': 'error',
+    'no-unreachable': 'warn',
+    'no-unsafe-finally': 'error',
+    'use-isnan': 'error',
+    'valid-jsdoc': ['warn', { 'requireReturn': false }],
+    'valid-typeof': 'error',
+
+    // === Best Practices ===
+    // (these mostly match our jshint config)
+    'curly': 'error',
+    'dot-notation': 'warn',
+    'no-alert': 'warn',
+    'no-caller': 'error',
+    'no-case-declarations': 'error',
+    'no-empty-pattern': 'error',
+    'no-empty-function': 'warn',
+    //DEFINE POLICY: 'no-eq-null': 'warn',
+    'no-eval': 'error',
+    //DEFINE POLICY: 'no-extra-bind': 'warn',
+    'no-fallthrough': 'error',
+    //DEFINE POLICY: 'no-implicit-globals': 'warn',
+    'no-implied-eval': 'error',
+    'no-invalid-this': 'error',
+    'no-iterator': 'error',
+    'no-labels': 'error',
+    'no-loop-func': 'error',
+    'no-multi-spaces': 'warn',
+    'no-multi-str': 'error',
+    'no-native-reassign': 'warn',
+    'no-new-func': 'error',
+    'no-new-wrappers': 'error',
+    // DEFINE POLICY: no-octal: "error"
+    // DEFINE POLICY: no-octal-escape: "error"
+    'no-proto': 'error',
+    'no-redeclare': 'warn',
+    'no-return-assign': 'error',
+    'no-script-url': 'error',
+    'no-self-assign': 'error',
+    'no-self-compare': 'error',
+    'no-unmodified-loop-condition': 'error',
+    // Disabled for YUI rollups, enabled by grunt for AMD: 'no-unused-expressions': 'error',
+    'no-unused-labels': 'error',
+    //DEFINE POLICY: 'no-useless-call': 'error',
+    'no-useless-escape': 'warn',
+    //DEFINE POLICY: 'no-with': 'error',
+    'wrap-iife': ['error', 'any'],
+
+    // === Variables ===
+    'no-delete-var': 'error',
+    // Disabled for YUI rollups, enabled by grunt for AMD: 'no-undef': 'off',
+    //DEFINE POLICY: 'no-undef-init': 'error',
+    // Disabled for YUI rollups, enabled by grunt for AMD: 'no-unused-vars': 'error',
+
+    // === Stylistic Issues ===
+    'array-bracket-spacing': 'warn',
+    'block-spacing': 'warn',
+    'brace-style': ['warn', '1tbs'],
+    'camelcase': 'warn',
+    'comma-spacing': ['warn', { 'before': false, 'after': true }],
+    'comma-style': ['warn', 'last'],
+    'computed-property-spacing': 'error',
+    'consistent-this': 'off',
+    'eol-last': 'off',
+    'func-names': 'off',
+    'func-style': 'off',
+    // indent currently not doing well with our wrapping style, so disabled.
+    'indent': ['off', 4, { 'SwitchCase': 1 }],
+    'key-spacing': ['warn', { 'beforeColon': false, 'afterColon': true, 'mode': minimum }],
+    'keyword-spacing': 'warn',
+    'linebreak-style': ['error', 'unix'],
+    'lines-around-comment': 'off',
+    'max-len': ['error', 132],
+    'max-lines': 'off',
+    // DEFINE POLICY: turn on some of these max values?
+    'max-depth': 'off',
+    'max-nested-callbacks': 'off',
+    'max-params': 'off',
+    'max-statements': 'off',
+    'max-statements-per-line': 'off',
+    'new-cap': 'warn',
+    'new-parens': 'warn',
+    'newline-after-var': 'off',
+    'newline-before-return': 'off',
+    // REVIST POLICY: 'newline-per-chained-call': 'warn',
+    'no-array-constructor': 'off',
+    'no-bitwise': 'error',
+    'no-continue': 'off',
+    'no-inline-comments': 'off',
+    'no-lonely-if': 'off',
+    'no-mixed-operators': 'off',
+    'no-mixed-spaces-and-tabs': 'error',
+    'no-multiple-empty-lines': 'warn',
+    'no-negated-condition': 'off',
+    'no-nested-ternary': 'warn',
+    'no-new-object': 'off',
+    'no-plusplus': 'off',
+    'no-spaced-func': 'warn',
+    'no-ternary': 'off',
+    'no-trailing-spaces': 'error',
+    'no-underscore-dangle': 'off',
+    // DEFINE POLICY: 'no-unneeded-ternary': 'off',
+    'no-whitespace-before-property': 'warn',
+    // DEFINE POLICY: 'object-curly-newline': 'off,
+    // DEFINE POLICY: 'object-curly-spacing': 'off',
+    // DEFINE POLICY: 'object-property-newline': 'off',
+    'one-var': 'off',
+    // DEFINE POLICY: 'one-var-declaration-per-line': 'off',
+    'operator-assignment': 'off',
+    'operator-linebreak': 'off',
+    'padded-blocks': 'off',
+    // DEFINE POLICY: 'quote-props': 'off',
+    'quotes': 'off',
+    'require-jsdoc': 'warn',
+    'semi': 'error',
+    'semi-spacing': ['warn', {'before': false, 'after': true}],
+    'sort-vars': 'off',
+    'space-before-blocks': 'warn',
+    'space-before-function-paren': ['warn', 'never'],
+    'space-in-parens': 'warn',
+    'space-infix-ops': 'warn',
+    'space-unary-ops': 'warn',
+    'spaced-comment': 'warn',
+    'unicode-bom': 'error',
+    'wrap-regex': 'off',
+  }
+}
index 1dab0f7..71a7224 100644 (file)
@@ -36,3 +36,4 @@ composer.phar
 /lib/yuilib/*/*/*-coverage.js
 atlassian-ide-plugin.xml
 /node_modules/
+.eslintignore
index 7412970..a1d525e 100644 (file)
@@ -13,6 +13,7 @@
 // You should have received a copy of the GNU General Public License
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 /* jshint node: true, browser: false */
+/* eslint-env node */
 
 /**
  * @copyright  2014 Andrew Nicols
 module.exports = function(grunt) {
     var path = require('path'),
         tasks = {},
-        cwd = process.env.PWD || process.cwd();
+        cwd = process.env.PWD || process.cwd(),
+        async = require('async'),
+        DOMParser = require('xmldom').DOMParser,
+        xpath = require('xpath');
 
     // Windows users can't run grunt in a subdirectory, so allow them to set
     // the root by passing --root=path/to/dir.
@@ -34,9 +38,9 @@ module.exports = function(grunt) {
         var root = grunt.option('root');
         if (grunt.file.exists(__dirname, root)) {
             cwd = path.join(__dirname, root);
-            grunt.log.ok('Setting root to '+cwd);
+            grunt.log.ok('Setting root to ' + cwd);
         } else {
-            grunt.fail.fatal('Setting root to '+root+' failed - path does not exist');
+            grunt.fail.fatal('Setting root to ' + root + ' failed - path does not exist');
         }
     }
 
@@ -55,25 +59,93 @@ module.exports = function(grunt) {
      * @param {String} srcPath the  matched src path
      * @return {String} The rewritten destination path.
      */
-    var uglify_rename = function (destPath, srcPath) {
+    var uglifyRename = function(destPath, srcPath) {
         destPath = srcPath.replace('src', 'build');
         destPath = destPath.replace('.js', '.min.js');
         destPath = path.resolve(cwd, destPath);
         return destPath;
     };
 
+    /**
+     * Find thirdpartylibs.xml and generate an array of paths contained within
+     * them (used to generate ignore files and so on).
+     *
+     * @return {array} The list of thirdparty paths.
+     */
+    var getThirdPartyPathsFromXML = function() {
+        var thirdpartyfiles = grunt.file.expand('*/**/thirdpartylibs.xml');
+        var libs = ['node_modules/', 'vendor/'];
+
+        thirdpartyfiles.forEach(function(file) {
+          var dirname = path.dirname(file);
+
+          var doc = new DOMParser().parseFromString(grunt.file.read(file));
+          var nodes = xpath.select("/libraries/library/location/text()", doc);
+
+          nodes.forEach(function(node) {
+            var lib = path.join(dirname, node.toString());
+            if (grunt.file.isDir(lib)) {
+                // Ensure trailing slash on dirs.
+                lib = lib.replace(/\/?$/, '/');
+            }
+
+            // Look for duplicate paths before adding to array.
+            if (libs.indexOf(lib) === -1) {
+                libs.push(lib);
+            }
+          });
+        });
+        return libs;
+    };
+
+    // An array of paths to third party directories.
+    var thirdPartyPaths = getThirdPartyPathsFromXML();
+
+    /**
+     * Determine if the file is a Moodle file, or its listed in
+     * the thirdpartylibs.xml file paths as a third party file.
+     *
+     * @param {string} file The file path to determine if thirdparty
+     * @return {bool} false If thid party file.
+     */
+    var isMoodleFile = function(file) {
+      if (grunt.file.isMatch(thirdPartyPaths, file)) {
+        return false;
+      }
+      return true;
+    };
+
     // Project configuration.
     grunt.initConfig({
         jshint: {
             options: {jshintrc: '.jshintrc'},
             amd: { src: amdSrc }
         },
+        eslint: {
+            // Even though warnings dont stop the build we don't display warnings by default because
+            // at this moment we've got too many core warnings.
+            options: { quiet: !grunt.option('show-lint-warnings') },
+            // Check AMD files. We add some stricter rules which we can't apply to the default configuration due
+            // to YUI rollups.
+            amd: {
+              src: amdSrc,
+              filter: isMoodleFile,
+              options: {
+                  rules: {'no-undef': 'error', 'no-unused-vars': 'error', 'no-empty': 'error', 'no-unused-expressions': 'error'}
+              }
+            },
+            // Check YUI module source files.
+            yui: {
+               src: ['**/yui/src/**/*.js', '!*/**/yui/src/*/meta/*.js'],
+               filter: isMoodleFile
+            }
+        },
         uglify: {
             amd: {
                 files: [{
                     expand: true,
                     src: amdSrc,
-                    rename: uglify_rename
+                    rename: uglifyRename
                 }]
             }
         },
@@ -102,7 +174,7 @@ module.exports = function(grunt) {
             },
             yui: {
                 files: ['**/yui/src/**/*.js'],
-                tasks: ['shifter']
+                tasks: ['yui']
             },
         },
         shifter: {
@@ -113,6 +185,15 @@ module.exports = function(grunt) {
         }
     });
 
+    /**
+     * Generate ignore files (utilising thirdpartylibs.xml data)
+     */
+    tasks.ignorefiles = function() {
+      // Generate .eslintignore.
+      var eslintIgnores = ['# Generated by "grunt ignorefiles"', '*/**/yui/src/*/meta/', '*/**/build/'].concat(thirdPartyPaths);
+      grunt.file.write('.eslintignore', eslintIgnores.join('\n'));
+    };
+
     /**
      * Shifter task. Is configured with a path to a specific file or a directory,
      * in the case of a specific file it will work out the right module to be built.
@@ -121,14 +202,13 @@ module.exports = function(grunt) {
      * so be careful to to call done().
      */
     tasks.shifter = function() {
-        var async = require('async'),
-            done = this.async(),
+        var done = this.async(),
             options = grunt.config('shifter.options');
 
         // Run the shifter processes one at a time to avoid confusing output.
-        async.eachSeries(options.paths, function (src, filedone) {
+        async.eachSeries(options.paths, function(src, filedone) {
             var args = [];
-            args.push( path.normalize(__dirname + '/node_modules/shifter/bin/shifter'));
+            args.push(path.normalize(__dirname + '/node_modules/shifter/bin/shifter'));
 
             // Always ignore the node_modules directory.
             args.push('--excludes', 'node_modules');
@@ -176,7 +256,7 @@ module.exports = function(grunt) {
                     cmd: "node",
                     args: args,
                     opts: {cwd: src, stdio: 'inherit', env: process.env}
-                }, function (error, result, code) {
+                }, function(error, result, code) {
                     if (code) {
                         grunt.fail.fatal('Shifter failed with code: ' + code);
                     } else {
@@ -205,7 +285,7 @@ module.exports = function(grunt) {
     tasks.startup = function() {
         // Are we in a YUI directory?
         if (path.basename(path.resolve(cwd, '../../')) == 'yui') {
-            grunt.task.run('shifter');
+            grunt.task.run('yui');
         // Are we in an AMD directory?
         } else if (inAMD) {
             grunt.task.run('amd');
@@ -223,7 +303,7 @@ module.exports = function(grunt) {
     var onChange = grunt.util._.debounce(function() {
           var files = Object.keys(changedFiles);
           grunt.config('jshint.amd.src', files);
-          grunt.config('uglify.amd.files', [{ expand: true, src: files, rename: uglify_rename }]);
+          grunt.config('uglify.amd.files', [{ expand: true, src: files, rename: uglifyRename }]);
           grunt.config('shifter.options.paths', files);
           changedFiles = Object.create(null);
     }, 200);
@@ -238,11 +318,14 @@ module.exports = function(grunt) {
     grunt.loadNpmTasks('grunt-contrib-jshint');
     grunt.loadNpmTasks('grunt-contrib-less');
     grunt.loadNpmTasks('grunt-contrib-watch');
+    grunt.loadNpmTasks('grunt-eslint');
 
     // Register JS tasks.
     grunt.registerTask('shifter', 'Run Shifter against the current directory', tasks.shifter);
-    grunt.registerTask('amd', ['jshint', 'uglify']);
-    grunt.registerTask('js', ['amd', 'shifter']);
+    grunt.registerTask('ignorefiles', 'Generate ignore files for linters', tasks.ignorefiles);
+    grunt.registerTask('yui', ['eslint:yui', 'shifter']);
+    grunt.registerTask('amd', ['eslint:amd', 'jshint', 'uglify']);
+    grunt.registerTask('js', ['amd', 'yui']);
 
     // Register CSS taks.
     grunt.registerTask('css', ['less:bootstrapbase']);
index 85904eb..256f635 100644 (file)
@@ -71,8 +71,9 @@ class core_role_preset {
         $dom->appendChild($top);
 
         $top->appendChild($dom->createElement('shortname', $role->shortname));
-        $top->appendChild($dom->createElement('name', $role->name));
-        $top->appendChild($dom->createElement('description', $role->description));
+        $top->appendChild($dom->createElement('name', htmlspecialchars($role->name, ENT_COMPAT | ENT_HTML401, 'UTF-8')));
+        $top->appendChild($dom->createElement('description', htmlspecialchars($role->description, ENT_COMPAT | ENT_HTML401,
+                'UTF-8')));
         $top->appendChild($dom->createElement('archetype', $role->archetype));
 
         $contextlevels = $dom->createElement('contextlevels');
index 21ecab7..2d0dd1b 100644 (file)
@@ -72,13 +72,13 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
         $formcourseformats[$courseformat] = new lang_string('pluginname', "format_$courseformat");
     }
     $temp->add(new admin_setting_configselect('moodlecourse/format', new lang_string('format'), new lang_string('coursehelpformat'),
-        'weeks',$formcourseformats));
+        'topics', $formcourseformats));
 
     $temp->add(new admin_setting_configtext('moodlecourse/maxsections', new lang_string('maxnumberweeks'),
         new lang_string('maxnumberweeks_desc'), 52));
 
     $temp->add(new admin_settings_num_course_sections('moodlecourse/numsections', new lang_string('numberweeks'),
-        new lang_string('coursehelpnumberweeks'), 10));
+        new lang_string('coursehelpnumberweeks'), 4));
 
     $choices = array();
     $choices['0'] = new lang_string('hiddensectionscollapsed');
index e11b466..674172f 100644 (file)
Binary files a/admin/tool/lp/amd/build/user_competency_course_navigation.min.js and b/admin/tool/lp/amd/build/user_competency_course_navigation.min.js differ
index 470bb5d..db4bfe4 100644 (file)
@@ -126,6 +126,7 @@ define(['jquery',
             try {
                 config = JSON.parse(self._competency.ruleconfig);
             } catch (e) {
+                // eslint-disable-line no-empty
             }
         }
 
index a7a43ba..bf61fe5 100644 (file)
@@ -75,8 +75,6 @@ define(['jquery'], function($) {
     UserCompetencyCourseNavigation.prototype._courseId = null;
     /** @type {String} Plugin base url. */
     UserCompetencyCourseNavigation.prototype._baseUrl = null;
-    /** @type {Boolean} Ignore the first change event for users. */
-    UserCompetencyCourseNavigation.prototype._ignoreFirstUser = null;
     /** @type {Boolean} Ignore the first change event for competencies. */
     UserCompetencyCourseNavigation.prototype._ignoreFirstCompetency = null;
 
index b2e894d..870104e 100644 (file)
@@ -44,7 +44,7 @@ function tool_lp_extend_navigation_course($navigation, $course, $coursecontext)
                                             navigation_node::TYPE_SETTING,
                                             null,
                                             null,
-                                            new pix_icon('competency', '', 'tool_lp'));
+                                            new pix_icon('i/competencies', ''));
     if (isset($settingsnode)) {
         $navigation->add_node($settingsnode);
     }
@@ -129,7 +129,7 @@ function tool_lp_extend_navigation_category_settings($navigation, $coursecategor
                                                 navigation_node::TYPE_SETTING,
                                                 null,
                                                 null,
-                                                new pix_icon('competency', '', 'tool_lp'));
+                                                new pix_icon('i/competencies', ''));
         if (isset($settingsnode)) {
             $navigation->add_node($settingsnode);
         }
@@ -144,7 +144,7 @@ function tool_lp_extend_navigation_category_settings($navigation, $coursecategor
                                                 navigation_node::TYPE_SETTING,
                                                 null,
                                                 null,
-                                                new pix_icon('competency', '', 'tool_lp'));
+                                                new pix_icon('i/competencies', ''));
         if (isset($settingsnode)) {
             $navigation->add_node($settingsnode);
         }
diff --git a/admin/tool/lp/pix/competency.png b/admin/tool/lp/pix/competency.png
deleted file mode 100644 (file)
index 52fe956..0000000
Binary files a/admin/tool/lp/pix/competency.png and /dev/null differ
diff --git a/admin/tool/lp/pix/competency.svg b/admin/tool/lp/pix/competency.svg
deleted file mode 100644 (file)
index e5f670e..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<!-- Created with Inkscape (http://www.inkscape.org/) -->
-
-<svg
-   xmlns:dc="http://purl.org/dc/elements/1.1/"
-   xmlns:cc="http://creativecommons.org/ns#"
-   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
-   xmlns:svg="http://www.w3.org/2000/svg"
-   xmlns="http://www.w3.org/2000/svg"
-   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
-   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
-   width="32"
-   height="32"
-   id="svg2"
-   version="1.1"
-   inkscape:version="0.48.4 r9939"
-   sodipodi:docname="New document 1" preserveAspectRatio="xMinYMid meet">
-  <defs
-     id="defs4" />
-  <sodipodi:namedview
-     id="base"
-     pagecolor="#ffffff"
-     bordercolor="#666666"
-     borderopacity="1.0"
-     inkscape:pageopacity="0.0"
-     inkscape:pageshadow="2"
-     inkscape:zoom="5.6"
-     inkscape:cx="32.609372"
-     inkscape:cy="35.818362"
-     inkscape:document-units="px"
-     inkscape:current-layer="layer1"
-     showgrid="false"
-     width="33px"
-     inkscape:window-width="1916"
-     inkscape:window-height="981"
-     inkscape:window-x="0"
-     inkscape:window-y="72"
-     inkscape:window-maximized="0" />
-  <metadata
-     id="metadata7">
-    <rdf:RDF>
-      <cc:Work
-         rdf:about="">
-        <dc:format>image/svg+xml</dc:format>
-        <dc:type
-           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
-        <dc:title></dc:title>
-      </cc:Work>
-    </rdf:RDF>
-  </metadata>
-  <g
-     inkscape:label="Layer 1"
-     inkscape:groupmode="layer"
-     id="layer1"
-     transform="translate(0,-1020.3622)">
-    <g
-       id="g3050"
-       transform="matrix(0.92121849,0,0,0.92121849,-35.507391,91.120599)">
-      <path
-         style="fill:#fafafa"
-         d="m 38.892857,1026.2908 0,-17 17,0 17,0 0,17 0,17 -17,0 -17,0 0,-17 z"
-         id="path3056"
-         inkscape:connector-curvature="0" />
-      <path
-         style="fill:#989898"
-         d="m 40.954049,1037.3439 c -3.465234,-3.0054 -1.687876,-4.0531 6.875853,-4.0531 6.919599,0 7.985143,0.2502 9.263277,2.1752 0.947236,1.4266 2.021632,1.9536 3.121972,1.5314 1.37559,-0.5279 1.677706,-2.0754 1.677706,-8.5934 0,-4.3723 0.273145,-8.6614 0.606989,-9.5314 0.509481,-1.3277 -0.303204,-1.5818 -5.059017,-1.5818 -3.522693,0 -5.432219,-0.3783 -5.047972,-1 1.028329,-1.6639 14.737848,-1.2221 17.277494,0.5567 3.593622,2.5171 2.823056,4.4433 -1.777494,4.4433 l -4,0 0,7.345 c 0,9.8743 -0.892111,10.655 -12.175731,10.655 -7.005509,0 -8.916904,-0.3458 -10.763077,-1.9469 z m 3.938808,-10.6589 c 0,-2.9538 0.324365,-3.3942 2.5,-3.3942 2.153991,0 2.499902,0.4498 2.499294,3.25 -6e-4,2.7611 -0.376693,3.2717 -2.5,3.3942 -2.203157,0.1271 -2.499294,-0.2579 -2.499294,-3.25 z m 7.428571,-1.3942 c 0,-1.6931 0.666667,-2 4.344732,-2 5.000845,0 4.927087,1.9191 -0.08232,2.1419 -3.011559,0.1339 -2.966069,0.1822 0.809017,0.8581 l 4,0.7162 -4.535714,0.1419 c -3.91635,0.1225 -4.535715,-0.1312 -4.535715,-1.8581 z m -6.761904,-3.6667 c -1.167788,-1.1678 -0.718106,-4.0457 0.833333,-5.3333 1.846181,-1.5322 4.5,0.2461 4.5,3.0155 0,2.2918 -3.735778,3.9154 -5.333333,2.3178 z m 6.833333,-0.3333 c 0.339919,-0.55 2.139919,-1 4,-1 1.860081,0 3.660081,0.45 4,1 0.377944,0.6115 -1.175955,1 -4,1 -2.824045,0 -4.377944,-0.3885 -4,-1 z"
-         id="path3054"
-         inkscape:connector-curvature="0" />
-      <path
-         style="fill:#5e5e5e"
-         d="m 40.892857,1036.2908 c -1.027328,-1.9196 -0.76049,-2 6.636076,-2 5.863885,0 8.157677,0.4084 9.593719,1.708 1.429812,1.2939 2.297278,1.4506 3.578741,0.6463 1.318167,-0.8273 1.801811,-3.0713 2.191464,-10.1678 l 0.5,-9.1062 -5.5,-0.351 c -4.40263,-0.2811 -3.893849,-0.3889 2.55,-0.5402 5.16365,-0.1213 8.480263,0.2412 9.25,1.0109 2.011735,2.0117 1.364232,2.8 -2.3,2.8 l -3.5,0 0,7.0657 c 0,10.7479 -0.204518,10.9343 -12,10.9343 -8.906177,0 -10.039956,-0.2061 -11,-2 z m 5.078947,-9.0357 c -0.06465,-2.8954 0.283272,-3.5357 1.921053,-3.5357 1.608782,0 2,0.6666 2,3.4081 0,2.8111 -0.245749,3.2042 -1.402754,2.244 -1.077175,-0.894 -1.523051,-0.8644 -1.921053,0.1276 -0.285064,0.7104 -0.553825,-0.2994 -0.597246,-2.244 z m 8.682712,-0.6716 c 1.243913,-0.2392 3.043913,-0.2301 4,0.02 0.956088,0.2503 -0.06166,0.446 -2.261659,0.4349 -2.2,-0.011 -2.982253,-0.2159 -1.738341,-0.4551 z m 0,-3 c 1.243913,-0.2392 3.043913,-0.2301 4,0.02 0.956088,0.2503 -0.06166,0.446 -2.261659,0.4349 -2.2,-0.011 -2.982253,-0.2159 -1.738341,-0.4551 z m 0,-2 c 1.243913,-0.2392 3.043913,-0.2301 4,0.02 0.956088,0.2503 -0.06166,0.446 -2.261659,0.4349 -2.2,-0.011 -2.982253,-0.2159 -1.738341,-0.4551 z m -9.205886,-1.2025 c -0.810187,-1.3109 0.447721,-4.0902 1.851216,-4.0902 1.828446,0 3.499178,2.081 2.907877,3.6219 -0.592966,1.5452 -3.892838,1.87 -4.759093,0.4683 z"
-         id="path3052"
-         inkscape:connector-curvature="0" />
-    </g>
-  </g>
-</svg>
index 4be2d6f..9097caa 100644 (file)
@@ -64,7 +64,7 @@
         <dt>{{#str}}rating, tool_lp{{/str}}</dt>
         <dd>{{gradename}}
             {{#cangrade}}
-                <buttn class="btn" id="rate_{{uniqid}}">{{#str}}rate, tool_lp{{/str}}</button>
+                <button class="btn" id="rate_{{uniqid}}">{{#str}}rate, tool_lp{{/str}}</button>
             {{/cangrade}}
         </dd>
         {{#js}}
index 0d05efc..03f63e4 100644 (file)
@@ -75,7 +75,7 @@
         <dt>{{#str}}rating, tool_lp{{/str}}</dt>
         <dd>{{gradename}}
             {{#cangrade}}
-                <buttn class="btn" id="rate_{{uniqid}}">{{#str}}rate, tool_lp{{/str}}</button>
+                <button class="btn" id="rate_{{uniqid}}">{{#str}}rate, tool_lp{{/str}}</button>
             {{/cangrade}}
         </dd>
         {{/usercompetencycourse}}
diff --git a/admin/tool/lp/upgrade.txt b/admin/tool/lp/upgrade.txt
new file mode 100644 (file)
index 0000000..4e41971
--- /dev/null
@@ -0,0 +1,10 @@
+This files describes changes in /admin/tool/lp/* - plugins,
+information provided here is intended especially for developers.
+
+=== 3.2 ===
+
+* The icon 'competency.png/svg' has been removed, please use i/competencies instead.
+
+=== 3.1.1 ===
+
+* The plugin icon 'competency.png/svg' will be removed in the future use i/competencies instead.
index 62a4d29..751a75b 100644 (file)
@@ -24,7 +24,6 @@ if ($data = $action_form->get_data()) {
         case 3: redirect($CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk_delete.php');
         case 4: redirect($CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk_display.php');
         case 5: redirect($CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk_download.php');
-        //case 6: redirect($CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk_enrol.php'); //TODO: MDL-24064
         case 7: redirect($CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk_forcepasswordchange.php');
         case 8: redirect($CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk_cohortadd.php');
     }
diff --git a/admin/user/user_bulk_enrol.php b/admin/user/user_bulk_enrol.php
deleted file mode 100644 (file)
index 21ad105..0000000
+++ /dev/null
@@ -1,131 +0,0 @@
-<?php
-/**
-* script for bulk user multi enrol operations
-*/
-
-die('this needs to be rewritten to use new enrol framework, sorry');  //TODO: MDL-24064
-
-require_once('../../config.php');
-require_once($CFG->libdir.'/adminlib.php');
-$processed = optional_param('processed', '', PARAM_BOOL);
-$sort = optional_param('sort', 'fullname', PARAM_ALPHA); //Sort by full name
-$dir  = optional_param('dir', 'asc', PARAM_ALPHA);       //Order to sort (ASC)
-
-require_login();
-admin_externalpage_setup('userbulk');
-require_capability('moodle/role:assign', context_system::instance()); //TODO: use some enrol cap
-$return = $CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk.php';
-//If no users selected then return to user_bulk.php
-if (empty($SESSION->bulk_users)) {
-    redirect($return);
-}
-$users = $SESSION->bulk_users; //Get users to display
-$usertotal = get_users(false); //Total number of users registered
-$usercount = count($users);    //number of users
-
-echo $OUTPUT->header();
-
-//take user info
-foreach ($users as $key => $id) {
-    $user = $DB->get_record('user', array('id'=>$id));
-    $user->fullname = fullname($user, true);
-    unset($user->firstname);
-    unset($user->lastname);
-    $users[$key] = $user;
-}
-
-// Need to sort by date
-function sort_compare($a, $b) {
-    global $sort, $dir;
-    if($sort == 'lastaccess') {
-        $rez = $b->lastaccess - $a->lastaccess;
-    } else {
-        $rez = strcasecmp(@$a->$sort, @$b->$sort);
-    }
-    return $dir == 'desc' ? -$rez : $rez;
-}
-usort($users, 'sort_compare');
-
-//Take courses data (id, shortname, and fullname)
-$courses = get_courses_page(1, 'c.sortorder ASC', 'c.id,c.shortname,c.fullname,c.visible', $totalcount);
-$table = new html_table();
-$table->width = "95%";
-$columns = array('fullname');
-foreach ($courses as $v)
-{
-    $columns[] = $v->shortname;
-}
-
-//Print columns headers from table
-foreach ($columns as $column) {
-    $strtitle = $column;
-    if ($sort != $column) {
-        $columnicon = '';
-        $columndir = 'asc';
-    } else {
-        $columndir = ($dir == 'asc') ? 'desc' : 'asc';
-        $columnicon = ' <img src="'.$OUTPUT->pix_url('t/'.($dir == 'asc' ? 'down' : 'up' )).'" alt="" />';
-    }
-    $table->head[] = '<a href="user_bulk_enrol.php?sort='.$column.'&amp;dir='.$columndir.'">'.$strtitle.'</a>'.$columnicon;
-    $table->align[] = 'left';
-}
-
-// process data submitting
-if(!empty($processed)) {
-    //Process data form here
-    $total = count($courses) * count($users);
-
-    for ( $i = 0; $i < $total; $i++ )
-    {
-        $param = "selected".$i;
-        $info = optional_param($param, '', PARAM_SEQUENCE);
-        /**
-         * user id:    ids[0]
-         * course id:  ids[1]
-         * enrol stat: ids[2]
-         */
-        $ids = explode(',', $info);
-        if(!empty($ids[2])) {
-            $context = context_course::instance($ids[1]);
-            role_assign(5, $ids[0], $context->id); //TODO: horrible!!
-        } else {
-            if( empty($ids[1] ) ) {
-                continue;
-            }
-            $context = context_course::instance($ids[1]);
-            role_unassign(5, $ids[0], $context->id);
-        }
-    }
-    redirect($return, get_string('changessaved')); //TODO: horrible!!
-}
-
-//Form beginning
-echo '<form id="multienrol" name="multienrol" method="post" action="user_bulk_enrol.php">';
-echo '<input type="hidden" name="processed" value="yes" />';
-$count = 0;
-foreach($users as $user) {
-    $temparray = array (
-        '<a href="'.$CFG->wwwroot.'/user/view.php?id='.$user->id.'&amp;course='.SITEID.'">'.$user->fullname.'</a>'
-    );
-    $mycourses = enrol_get_users_courses($user->id, false);
-    foreach($courses as $acourse) {
-        $state = '';
-        if (isset($mycourses[$acourse->id])) {
-            $state = 'checked="checked"';
-        }
-        $temparray[] = '<input type="hidden" name="selected' . $count .
-                '" value="' . $user->id . ',' . $acourse->id . ',0" />' .
-                '<input type="checkbox" name="selected' . $count .
-                '" value="' . $user->id . ',' . $acourse->id . ',1" ' . $state . '/>';
-        $count++;
-    }
-    $table->data[] = $temparray;
-}
-echo $OUTPUT->heading("$usercount / $usertotal ".get_string('users'));
-echo html_writer::table($table);
-echo '<div class="continuebutton">';
-echo '<input type="submit" name="multienrolsubmit" value="save changes" />';
-echo '</div>';
-echo '</form>';
-
-echo $OUTPUT->footer();
index 0f7c25c..dd410f4 100644 (file)
@@ -24,10 +24,6 @@ class user_bulk_action_form extends moodleform {
         if (has_capability('moodle/user:update', $syscontext)) {
             $actions[5] = get_string('download', 'admin');
         }
-        if (has_capability('moodle/role:assign', $syscontext)){
-             //TODO: MDL-24064
-            //$actions[6] = get_string('enrolmultipleusers', 'admin');
-        }
         if (has_capability('moodle/user:update', $syscontext)) {
             $actions[7] = get_string('forcepasswordchange');
         }
index da219a1..ac786ed 100644 (file)
@@ -950,8 +950,11 @@ class backup_gradebook_structure_step extends backup_structure_step {
         $grade_setting = new backup_nested_element('grade_setting', 'id', array(
             'name', 'value'));
 
+        $gradebook_attributes = new backup_nested_element('attributes', null, array('calculations_freeze'));
 
         // Build the tree
+        $gradebook->add_child($gradebook_attributes);
+
         $gradebook->add_child($grade_categories);
         $grade_categories->add_child($grade_category);
 
@@ -966,14 +969,15 @@ class backup_gradebook_structure_step extends backup_structure_step {
         $gradebook->add_child($grade_settings);
         $grade_settings->add_child($grade_setting);
 
+        // Define sources
+
         // Add attribute with gradebook calculation freeze date if needed.
+        $attributes = new stdClass();
         $gradebookcalculationfreeze = get_config('core', 'gradebook_calculations_freeze_' . $this->get_courseid());
         if ($gradebookcalculationfreeze) {
-            $gradebook->add_attributes(array('calculations_freeze'));
-            $gradebook->get_attribute('calculations_freeze')->set_value($gradebookcalculationfreeze);
+            $attributes->calculations_freeze = $gradebookcalculationfreeze;
         }
-
-        // Define sources
+        $gradebook_attributes->set_source_array([$attributes]);
 
         //Include manual, category and the course grade item
         $grade_items_sql ="SELECT * FROM {grade_items}
index 60f599a..7520aa3 100644 (file)
@@ -181,7 +181,7 @@ abstract class restore_qtype_plugin extends restore_plugin {
                 $info = new stdClass();
                 $info->filequestionid = $oldquestionid;
                 $info->dbquestionid   = $newquestionid;
-                $info->answer         = $data->answertext;
+                $info->answer         = s($data->answertext);
                 throw new restore_step_exception('error_question_answers_missing_in_db', $info);
             }
             $newitemid = $this->questionanswercache[$data->answertext];
index 158249b..e7e1ee2 100644 (file)
@@ -121,6 +121,21 @@ class restore_gradebook_structure_step extends restore_structure_step {
             return false;
         }
 
+        // Identify the backup we're dealing with.
+        $backuprelease = floatval($this->get_task()->get_info()->backup_release); // The major version: 2.9, 3.0, ...
+        $backupbuild = 0;
+        preg_match('/(\d{8})/', $this->get_task()->get_info()->moodle_release, $matches);
+        if (!empty($matches[1])) {
+            $backupbuild = (int) $matches[1]; // The date of Moodle build at the time of the backup.
+        }
+
+        // On older versions the freeze value has to be converted.
+        // We do this from here as it is happening right before the file is read.
+        // This only targets the backup files that can contain the legacy freeze.
+        if ($backupbuild > 20150618 && ($backuprelease < 3.0 || $backupbuild < 20160527)) {
+            $this->rewrite_step_backup_file_for_legacy_freeze($fullpath);
+        }
+
         // Arrived here, execute the step
         return true;
      }
@@ -129,7 +144,7 @@ class restore_gradebook_structure_step extends restore_structure_step {
         $paths = array();
         $userinfo = $this->task->get_setting_value('users');
 
-        $paths[] = new restore_path_element('gradebook', '/gradebook');
+        $paths[] = new restore_path_element('attributes', '/gradebook/attributes');
         $paths[] = new restore_path_element('grade_category', '/gradebook/grade_categories/grade_category');
         $paths[] = new restore_path_element('grade_item', '/gradebook/grade_items/grade_item');
         if ($userinfo) {
@@ -141,7 +156,7 @@ class restore_gradebook_structure_step extends restore_structure_step {
         return $paths;
     }
 
-    protected function process_gradebook($data) {
+    protected function process_attributes($data) {
         // For non-merge restore types:
         // Unset 'gradebook_calculations_freeze_' in the course and replace with the one from the backup.
         $target = $this->get_task()->get_target();
@@ -581,6 +596,85 @@ class restore_gradebook_structure_step extends restore_structure_step {
             }
         }
     }
+
+    /**
+     * Rewrite step definition to handle the legacy freeze attribute.
+     *
+     * In previous backups the calculations_freeze property was stored as an attribute of the
+     * top level node <gradebook>. The backup API, however, do not process grandparent nodes.
+     * It only processes definitive children, and their parent attributes.
+     *
+     * We had:
+     *
+     * <gradebook calculations_freeze="20160511">
+     *   <grade_categories>
+     *     <grade_category id="10">
+     *       <depth>1</depth>
+     *       ...
+     *     </grade_category>
+     *   </grade_categories>
+     *   ...
+     * </gradebook>
+     *
+     * And this method will convert it to:
+     *
+     * <gradebook >
+     *   <attributes>
+     *     <calculations_freeze>20160511</calculations_freeze>
+     *   </attributes>
+     *   <grade_categories>
+     *     <grade_category id="10">
+     *       <depth>1</depth>
+     *       ...
+     *     </grade_category>
+     *   </grade_categories>
+     *   ...
+     * </gradebook>
+     *
+     * Note that we cannot just load the XML file in memory as it could potentially be huge.
+     * We can also completely ignore if the node <attributes> is already in the backup
+     * file as it never existed before.
+     *
+     * @param string $filepath The absolute path to the XML file.
+     * @return void
+     */
+    protected function rewrite_step_backup_file_for_legacy_freeze($filepath) {
+        $foundnode = false;
+        $newfile = make_request_directory(true) . DIRECTORY_SEPARATOR . 'file.xml';
+        $fr = fopen($filepath, 'r');
+        $fw = fopen($newfile, 'w');
+        if ($fr && $fw) {
+            while (($line = fgets($fr, 4096)) !== false) {
+                if (!$foundnode && strpos($line, '<gradebook ') === 0) {
+                    $foundnode = true;
+                    $matches = array();
+                    $pattern = '@calculations_freeze=.([0-9]+).@';
+                    if (preg_match($pattern, $line, $matches)) {
+                        $freeze = $matches[1];
+                        $line = preg_replace($pattern, '', $line);
+                        $line .= "  <attributes>\n    <calculations_freeze>$freeze</calculations_freeze>\n  </attributes>\n";
+                    }
+                }
+                fputs($fw, $line);
+            }
+            if (!feof($fr)) {
+                throw new restore_step_exception('Error while attempting to rewrite the gradebook step file.');
+            }
+            fclose($fr);
+            fclose($fw);
+            if (!rename($newfile, $filepath)) {
+                throw new restore_step_exception('Error while attempting to rename the gradebook step file.');
+            }
+        } else {
+            if ($fr) {
+                fclose($fr);
+            }
+            if ($fw) {
+                fclose($fw);
+            }
+        }
+    }
+
 }
 
 /**
diff --git a/backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/1.expectation b/backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/1.expectation
new file mode 100644 (file)
index 0000000..99e8d85
--- /dev/null
@@ -0,0 +1,10 @@
+<gradebook >
+  <attributes>
+    <calculations_freeze>20160511</calculations_freeze>
+  </attributes>
+  <grade_categories>
+    <grade_category id="10">
+      <depth>1</depth>
+    </grade_category>
+  </grade_categories>
+</gradebook>
diff --git a/backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/1.test b/backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/1.test
new file mode 100644 (file)
index 0000000..830eca6
--- /dev/null
@@ -0,0 +1,7 @@
+<gradebook calculations_freeze="20160511">
+  <grade_categories>
+    <grade_category id="10">
+      <depth>1</depth>
+    </grade_category>
+  </grade_categories>
+</gradebook>
diff --git a/backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/2.expectation b/backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/2.expectation
new file mode 100644 (file)
index 0000000..b4f21d4
--- /dev/null
@@ -0,0 +1,10 @@
+<gradebook some_other_value="false" >
+  <attributes>
+    <calculations_freeze>20160511</calculations_freeze>
+  </attributes>
+  <grade_categories>
+    <grade_category id="10">
+      <depth>1</depth>
+    </grade_category>
+  </grade_categories>
+</gradebook>
diff --git a/backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/2.test b/backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/2.test
new file mode 100644 (file)
index 0000000..04f3b63
--- /dev/null
@@ -0,0 +1,7 @@
+<gradebook some_other_value="false" calculations_freeze="20160511">
+  <grade_categories>
+    <grade_category id="10">
+      <depth>1</depth>
+    </grade_category>
+  </grade_categories>
+</gradebook>
diff --git a/backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/3.expectation b/backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/3.expectation
new file mode 100644 (file)
index 0000000..c61f19d
--- /dev/null
@@ -0,0 +1,10 @@
+<gradebook some_other_value="false"  and_another_value="42">
+  <attributes>
+    <calculations_freeze>20160511</calculations_freeze>
+  </attributes>
+  <grade_categories>
+    <grade_category id="10">
+      <depth>1</depth>
+    </grade_category>
+  </grade_categories>
+</gradebook>
diff --git a/backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/3.test b/backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/3.test
new file mode 100644 (file)
index 0000000..39c46bc
--- /dev/null
@@ -0,0 +1,7 @@
+<gradebook some_other_value="false" calculations_freeze="20160511" and_another_value="42">
+  <grade_categories>
+    <grade_category id="10">
+      <depth>1</depth>
+    </grade_category>
+  </grade_categories>
+</gradebook>
diff --git a/backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/4.expectation b/backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/4.expectation
new file mode 100644 (file)
index 0000000..71070aa
--- /dev/null
@@ -0,0 +1,7 @@
+<gradebookplugin some_other_value="false" calculations_freeze="20160511" and_another_value="42">
+  <grade_categories>
+    <grade_category id="10">
+      <depth>1</depth>
+    </grade_category>
+  </grade_categories>
+</gradebookplugin>
diff --git a/backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/4.test b/backup/moodle2/tests/fixtures/rewrite_step_backup_file_for_legacy_freeze/4.test
new file mode 100644 (file)
index 0000000..71070aa
--- /dev/null
@@ -0,0 +1,7 @@
+<gradebookplugin some_other_value="false" calculations_freeze="20160511" and_another_value="42">
+  <grade_categories>
+    <grade_category id="10">
+      <depth>1</depth>
+    </grade_category>
+  </grade_categories>
+</gradebookplugin>
diff --git a/backup/moodle2/tests/restore_gradebook_structure_step_test.php b/backup/moodle2/tests/restore_gradebook_structure_step_test.php
new file mode 100644 (file)
index 0000000..26856de
--- /dev/null
@@ -0,0 +1,92 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Test for restore_stepslib.
+ *
+ * @package core_backup
+ * @copyright 2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+require_once($CFG->libdir . '/completionlib.php');
+
+/**
+ * Test for restore_stepslib.
+ *
+ * @package core_backup
+ * @copyright 2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_backup_restore_gradebook_structure_step_testcase extends advanced_testcase {
+
+    /**
+     * Provide tests for rewrite_step_backup_file_for_legacy_freeze based upon fixtures.
+     *
+     * @return array
+     */
+    public function rewrite_step_backup_file_for_legacy_freeze_provider() {
+        $fixturesdir = realpath(__DIR__ . '/fixtures/rewrite_step_backup_file_for_legacy_freeze/');
+        $tests = [];
+        $iterator = new \RecursiveIteratorIterator(
+                new \RecursiveDirectoryIterator($fixturesdir),
+                \RecursiveIteratorIterator::LEAVES_ONLY);
+
+        foreach ($iterator as $sourcefile) {
+            $pattern = '/\.test$/';
+            if (!preg_match($pattern, $sourcefile)) {
+                continue;
+            }
+
+            $expectfile = preg_replace($pattern, '.expectation', $sourcefile);
+            $test = array($sourcefile, $expectfile);
+            $tests[basename($sourcefile)] = $test;
+        }
+
+        return $tests;
+    }
+
+    /**
+     * @dataProvider rewrite_step_backup_file_for_legacy_freeze_provider
+     * @param   string  $source     The source file to test
+     * @param   string  $expected   The expected result of the transformation
+     */
+    public function test_rewrite_step_backup_file_for_legacy_freeze($source, $expected) {
+        $restore = $this->getMockBuilder('\restore_gradebook_structure_step')
+            ->setMethods(null)
+            ->disableOriginalConstructor()
+            ->getMock()
+            ;
+
+        // Copy the file somewhere as the rewrite_step_backup_file_for_legacy_freeze will write the file.
+        $dir = make_request_directory(true);
+        $filepath = $dir . DIRECTORY_SEPARATOR . 'file.xml';
+        copy($source, $filepath);
+
+        $rc = new \ReflectionClass('\restore_gradebook_structure_step');
+        $rcm = $rc->getMethod('rewrite_step_backup_file_for_legacy_freeze');
+        $rcm->setAccessible(true);
+        $rcm->invoke($restore, $filepath);
+
+        // Check the result.
+        $this->assertFileEquals($expected, $filepath);
+    }
+}
diff --git a/blocks/blog_recent/tests/behat/block_blog_recent.feature b/blocks/blog_recent/tests/behat/block_blog_recent.feature
new file mode 100644 (file)
index 0000000..6c3f73b
--- /dev/null
@@ -0,0 +1,34 @@
+@block @block_blog_recent
+Feature: Feature: Users can use the recent blog entries block to view recent blog entries.
+  In order to enable the recent blog entries in a course
+  As a teacher
+  I can add recent blog entries block to a course
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email | idnumber |
+      | teacher1 | Teacher | 1 | teacher1@example.com | T1 |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+
+  Scenario: Add the recent blogs block to a course when blogs are disabled
+    Given I log in as "admin"
+    And the following config values are set as admin:
+      | enableblogs | 0 |
+    And I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    When I add the "Recent blog entries" block
+    Then I should see "Blogging is disabled!" in the "Recent blog entries" "block"
+
+  Scenario: Add the recent blogs block to a course when there are not any blog posts
+    Given I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    When I add the "Recent blog entries" block
+    Then I should see "No recent entries" in the "Recent blog entries" "block"
diff --git a/blocks/blog_recent/tests/behat/block_blog_recent_activity.feature b/blocks/blog_recent/tests/behat/block_blog_recent_activity.feature
new file mode 100644 (file)
index 0000000..fae3f4a
--- /dev/null
@@ -0,0 +1,117 @@
+@block @block_blog_menu @mod_assign @block_blog_recent
+Feature: Students can use the recent blog entries block to view recent entries on an activity page
+  In order to enable the recent blog entries block an activity page
+  As a teacher
+  I can add the recent blog entries block to an activity page
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email | idnumber |
+      | teacher1 | Teacher | 1 | teacher1@example.com | T1 |
+      | student1 | Student | 1 | student1@example.com | S1 |
+      | student2 | Student | 2 | student2@example.com | S2 |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+      | student2 | C1 | student |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name | Test assignment 1 |
+      | Description | Offline text |
+      | assignsubmission_file_enabled | 0 |
+    And I follow "Test assignment 1"
+    And I add the "Blog menu" block
+    And I add the "Recent blog entries" block
+    And I log out
+
+  Scenario: Students use the recent blog entries block to view blogs
+    Given I log in as "student1"
+    And I follow "Course 1"
+    And I follow "Test assignment 1"
+    And I follow "Add an entry about this Assignment"
+    When I set the following fields to these values:
+      | Entry title | S1 First Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    Then I should see "S1 First Blog"
+    And I should see "This is my awesome blog!"
+    And I follow "Test assignment 1"
+    And I should see "S1 First Blog"
+    And I follow "S1 First Blog"
+    And I should see "This is my awesome blog!"
+
+  Scenario: Students only see a few entries in the recent blog entries block
+    Given I log in as "student1"
+    And I follow "Course 1"
+    And I follow "Test assignment 1"
+    And I follow "Add an entry about this Assignment"
+    # Blog 1 of 5
+    And I set the following fields to these values:
+      | Entry title | S1 First Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    And I wait "1" seconds
+    And I follow "Test assignment 1"
+    And I follow "Add an entry about this Assignment"
+    # Blog 2 of 5
+    And I set the following fields to these values:
+      | Entry title | S1 Second Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    And I wait "1" seconds
+    And I should see "S1 Second Blog"
+    And I should see "This is my awesome blog!"
+    And I follow "Test assignment 1"
+    And I follow "Add an entry about this Assignment"
+    # Blog 3 of 5
+    And I set the following fields to these values:
+      | Entry title | S1 Third Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    And I wait "1" seconds
+    And I should see "S1 Third Blog"
+    And I should see "This is my awesome blog!"
+    And I follow "Test assignment 1"
+    And I follow "Add an entry about this Assignment"
+    # Blog 4 of 5
+    And I set the following fields to these values:
+      | Entry title | S1 Fourth Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    And I wait "1" seconds
+    And I should see "S1 Fourth Blog"
+    And I should see "This is my awesome blog!"
+    And I follow "Test assignment 1"
+    And I follow "Add an entry about this Assignment"
+    # Blog 5 of 5
+    And I set the following fields to these values:
+      | Entry title | S1 Fifth Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    And I should see "S1 Fifth Blog"
+    And I should see "This is my awesome blog!"
+    When I follow "Test assignment 1"
+    And I should not see "S1 First Blog"
+    And I should see "S1 Second Blog"
+    And I should see "S1 Third Blog"
+    And I should see "S1 Fourth Blog"
+    And I should see "S1 Fifth Blog"
+    And I follow "S1 Fifth Blog"
+    And I should see "This is my awesome blog!"
+    Then I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I follow "Test assignment 1"
+    And I configure the "Recent blog entries" block
+    And I set the following fields to these values:
+      | id_config_numberofrecentblogentries | 2 |
+    And I press "Save changes"
+    And I should see "S1 Fourth Blog"
+    And I should see "S1 Fifth Blog"
diff --git a/blocks/blog_recent/tests/behat/block_blog_recent_course.feature b/blocks/blog_recent/tests/behat/block_blog_recent_course.feature
new file mode 100644 (file)
index 0000000..f06fad3
--- /dev/null
@@ -0,0 +1,107 @@
+@block @block_blog_menu @block_blog_recent
+Feature: Students can use the recent blog entries block to view recent entries on a course page
+  In order to enable the recent blog entries block a course page
+  As a teacher
+  I can add the recent blog entries block to a course page
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email | idnumber |
+      | student1 | Student | 1 | student1@example.com | S1 |
+      | teacher1 | Teacher | 1 | teacher1@example.com | T1 |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add the "Blog menu" block
+    And I add the "Recent blog entries" block
+    And I log out
+
+  Scenario: Students use the recent blog entries block to view blogs
+    Given I log in as "student1"
+    And I follow "Course 1"
+    And I follow "Add an entry about this course"
+    When I set the following fields to these values:
+      | Entry title | S1 First Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    Then I should see "S1 First Blog"
+    And I should see "This is my awesome blog!"
+    And I follow "C1"
+    And I should see "S1 First Blog"
+    And I follow "S1 First Blog"
+    And I should see "This is my awesome blog!"
+
+  Scenario: Students only see a few entries in the recent blog entries block
+    Given I log in as "student1"
+    And I follow "Course 1"
+    And I follow "Add an entry about this course"
+    # Blog 1 of 5
+    And I set the following fields to these values:
+      | Entry title | S1 First Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    And I wait "1" seconds
+    And I follow "C1"
+    And I follow "Add an entry about this course"
+    # Blog 2 of 5
+    And I set the following fields to these values:
+      | Entry title | S1 Second Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    And I wait "1" seconds
+    And I should see "S1 Second Blog"
+    And I should see "This is my awesome blog!"
+    And I follow "C1"
+    And I follow "Add an entry about this course"
+    # Blog 3 of 5
+    And I set the following fields to these values:
+      | Entry title | S1 Third Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    And I wait "1" seconds
+    And I should see "S1 Third Blog"
+    And I should see "This is my awesome blog!"
+    And I follow "C1"
+    And I follow "Add an entry about this course"
+    # Blog 4 of 5
+    And I set the following fields to these values:
+      | Entry title | S1 Fourth Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    And I wait "1" seconds
+    And I should see "S1 Fourth Blog"
+    And I should see "This is my awesome blog!"
+    And I follow "C1"
+    And I follow "Add an entry about this course"
+    # Blog 5 of 5
+    And I set the following fields to these values:
+      | Entry title | S1 Fifth Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    And I should see "S1 Fifth Blog"
+    And I should see "This is my awesome blog!"
+    When I follow "C1"
+    And I should not see "S1 First Blog"
+    And I should see "S1 Second Blog"
+    And I should see "S1 Third Blog"
+    And I should see "S1 Fourth Blog"
+    And I should see "S1 Fifth Blog"
+    And I follow "S1 Fifth Blog"
+    And I should see "This is my awesome blog!"
+    Then I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I configure the "Recent blog entries" block
+    And I set the following fields to these values:
+      | id_config_numberofrecentblogentries | 2 |
+    And I press "Save changes"
+    And I should see "S1 Fourth Blog"
+    And I should see "S1 Fifth Blog"
diff --git a/blocks/blog_recent/tests/behat/block_blog_recent_frontpage.feature b/blocks/blog_recent/tests/behat/block_blog_recent_frontpage.feature
new file mode 100644 (file)
index 0000000..2f5a7d5
--- /dev/null
@@ -0,0 +1,96 @@
+@block @block_blog_recent
+Feature: Feature: Students can use the recent blog entries block to view recent entries on the frontpage
+  In order to enable the recent blog entries block on the frontpage
+  As an admin
+  I can add the recent blog entries block to the frontpage
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email | idnumber |
+      | student1 | Student | 1 | student1@example.com | S1 |
+    And I log in as "admin"
+    And I am on site homepage
+    And I navigate to "Turn editing on" node in "Front page settings"
+    And I add the "Recent blog entries" block
+    And I log out
+
+  Scenario: Students use the recent blog entries block to view blogs
+    Given I log in as "student1"
+    And I am on site homepage
+    And I navigate to "Site blogs" node in "Site pages"
+    And I follow "Add a new entry"
+    When I set the following fields to these values:
+      | Entry title | S1 First Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    Then I should see "S1 First Blog"
+    And I should see "This is my awesome blog!"
+    And I am on site homepage
+    And I should see "S1 First Blog"
+    And I follow "S1 First Blog"
+    And I should see "This is my awesome blog!"
+
+  Scenario: Students only see a few entries in the recent blog entries block
+    Given I log in as "student1"
+    And I am on site homepage
+    And I navigate to "Site blogs" node in "Site pages"
+    And I follow "Add a new entry"
+    # Blog 1 of 5
+    And I set the following fields to these values:
+      | Entry title | S1 First Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    And I wait "1" seconds
+    And I follow "Add a new entry"
+    # Blog 2 of 5
+    And I set the following fields to these values:
+      | Entry title | S1 Second Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    And I wait "1" seconds
+    And I should see "S1 Second Blog"
+    And I should see "This is my awesome blog!"
+    And I follow "Add a new entry"
+    # Blog 3 of 5
+    And I set the following fields to these values:
+      | Entry title | S1 Third Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    And I wait "1" seconds
+    And I should see "S1 Third Blog"
+    And I should see "This is my awesome blog!"
+    And I follow "Add a new entry"
+    # Blog 4 of 5
+    And I set the following fields to these values:
+      | Entry title | S1 Fourth Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    And I wait "1" seconds
+    And I should see "S1 Fourth Blog"
+    And I should see "This is my awesome blog!"
+    And I follow "Add a new entry"
+    # Blog 5 of 5
+    And I set the following fields to these values:
+      | Entry title | S1 Fifth Blog |
+      | Blog entry body | This is my awesome blog! |
+    And I press "Save changes"
+    And I should see "S1 Fifth Blog"
+    And I should see "This is my awesome blog!"
+    When I am on site homepage
+    And I should not see "S1 First Blog"
+    And I should see "S1 Second Blog"
+    And I should see "S1 Third Blog"
+    And I should see "S1 Fourth Blog"
+    And I should see "S1 Fifth Blog"
+    And I follow "S1 Fifth Blog"
+    And I should see "This is my awesome blog!"
+    Then I log out
+    And I log in as "admin"
+    And I am on site homepage
+    And I navigate to "Turn editing on" node in "Front page settings"
+    And I configure the "Recent blog entries" block
+    And I set the following fields to these values:
+      | id_config_numberofrecentblogentries | 2 |
+    And I press "Save changes"
+    And I should see "S1 Fourth Blog"
+    And I should see "S1 Fifth Blog"
diff --git a/blocks/comments/tests/behat/block_comment_dashboard.feature b/blocks/comments/tests/behat/block_comment_dashboard.feature
new file mode 100644 (file)
index 0000000..66e49c6
--- /dev/null
@@ -0,0 +1,29 @@
+@block @block_comments
+Feature: Enable Block comments on the dashboard and view comments
+  In order to enable the comments block on a the dashboard
+  As a teacher
+  I can add the comments block to my dashboard
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | Frist | teacher1@example.com |
+
+  Scenario: Add the comments block on the dashboard and add comments with Javascript disabled
+    When I log in as "teacher1"
+    And I press "Customise this page"
+    And I add the "Comments" block
+    And I follow "Show comments"
+    And I add "I'm a comment from the teacher" comment to comments block
+    Then I should see "I'm a comment from the teacher"
+
+  @javascript
+  Scenario: Add the comments block on the dashboard and add comments with Javascript enabled
+    When I log in as "teacher1"
+    And I press "Customise this page"
+    And I add the "Comments" block
+    And I add "I'm a comment from the teacher" comment to comments block
+    Then I should see "I'm a comment from the teacher"
diff --git a/blocks/course_overview/tests/behat/quiz_overview.feature b/blocks/course_overview/tests/behat/quiz_overview.feature
new file mode 100644 (file)
index 0000000..238591f
--- /dev/null
@@ -0,0 +1,94 @@
+@block @block_course_overview @mod_quiz
+Feature: View the quiz being due
+  In order to know what quizzes are due
+  As a student
+  I can visit my dashboard
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | student1 | Student | 1 | student1@example.com |
+      | student2 | Student | 2 | student2@example.com |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname |
+      | Course 1 | C1        |
+      | Course 2 | C2        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | student1 | C1     | student        |
+      | student2 | C2     | student        |
+      | teacher1 | C1     | editingteacher |
+      | teacher1 | C2     | editingteacher |
+    And the following "activities" exist:
+      | activity | course | idnumber | name                    | timeclose  |
+      | quiz     | C1     | Q1A      | Quiz 1A No deadline     | 0          |
+      | quiz     | C1     | Q1B      | Quiz 1B Past deadline   | 1337       |
+      | quiz     | C1     | Q1C      | Quiz 1C Future deadline | 9000000000 |
+      | quiz     | C1     | Q1D      | Quiz 1D Future deadline | 9000000000 |
+      | quiz     | C1     | Q1E      | Quiz 1E Future deadline | 9000000000 |
+      | quiz     | C2     | Q2A      | Quiz 2A Future deadline | 9000000000 |
+    And the following "question categories" exist:
+      | contextlevel | reference | name           |
+      | Course       | C1        | Test questions |
+    And the following "questions" exist:
+      | qtype     | name           | questiontext              | questioncategory |
+      | truefalse | First question | Answer the first question | Test questions   |
+    And quiz "Quiz 1A No deadline" contains the following questions:
+      | question       | page |
+      | First question | 1    |
+    And quiz "Quiz 1B Past deadline" contains the following questions:
+      | question       | page |
+      | First question | 1    |
+    And quiz "Quiz 1C Future deadline" contains the following questions:
+      | question       | page |
+      | First question | 1    |
+    And quiz "Quiz 1D Future deadline" contains the following questions:
+      | question       | page |
+      | First question | 1    |
+    And quiz "Quiz 1E Future deadline" contains the following questions:
+      | question       | page |
+      | First question | 1    |
+    And quiz "Quiz 2A Future deadline" contains the following questions:
+      | question       | page |
+      | First question | 1    |
+
+  Scenario: View my quizzes that are due
+    Given I log in as "student1"
+    When I am on homepage
+    Then I should see "You have quizzes that are due" in the "Course overview" "block"
+    And I should see "Quiz 1C Future deadline" in the "Course overview" "block"
+    And I should see "Quiz 1D Future deadline" in the "Course overview" "block"
+    And I should see "Quiz 1E Future deadline" in the "Course overview" "block"
+    And I should not see "Quiz 1A No deadline" in the "Course overview" "block"
+    And I should not see "Quiz 1B Past deadline" in the "Course overview" "block"
+    And I should not see "Quiz 2A Future deadline" in the "Course overview" "block"
+    And I log out
+    And I log in as "student2"
+    And I should see "You have quizzes that are due" in the "Course overview" "block"
+    And I should not see "Quiz 1C Future deadline" in the "Course overview" "block"
+    And I should not see "Quiz 1D Future deadline" in the "Course overview" "block"
+    And I should not see "Quiz 1E Future deadline" in the "Course overview" "block"
+    And I should not see "Quiz 1A No deadline" in the "Course overview" "block"
+    And I should not see "Quiz 1B Past deadline" in the "Course overview" "block"
+    And I should see "Quiz 2A Future deadline" in the "Course overview" "block"
+
+  Scenario: View my quizzes that are due and never finished
+    Given I log in as "student1"
+    And I follow "Course 1"
+    And I follow "Quiz 1D Future deadline"
+    And I press "Attempt quiz now"
+    And I follow "Finish attempt ..."
+    And I press "Submit all and finish"
+    And I follow "Course 1"
+    And I follow "Quiz 1E Future deadline"
+    And I press "Attempt quiz now"
+    When I am on homepage
+    Then I should see "You have quizzes that are due" in the "Course overview" "block"
+    And I should see "Quiz 1C Future deadline" in the "Course overview" "block"
+    And I should see "Quiz 1E Future deadline" in the "Course overview" "block"
+    And I should not see "Quiz 1A No deadline" in the "Course overview" "block"
+    And I should not see "Quiz 1B Past deadline" in the "Course overview" "block"
+    And I should not see "Quiz 1D Future deadline" in the "Course overview" "block"
+    And I should not see "Quiz 2A Future deadline" in the "Course overview" "block"
+
index cfc5bc1..236351c 100644 (file)
@@ -60,19 +60,27 @@ class restore_glossary_random_block_task extends restore_block_task {
         if ($configdata = $DB->get_field('block_instances', 'configdata', array('id' => $blockid))) {
             $config = unserialize(base64_decode($configdata));
             if (!empty($config->glossary)) {
-                // Get glossary mapping and replace it in config
                 if ($glossarymap = restore_dbops::get_backup_ids_record($this->get_restoreid(), 'glossary', $config->glossary)) {
-                    $mappedglossary = $DB->get_record('glossary', array('id' => $glossarymap->newitemid),
-                        'id,course,globalglossary', MUST_EXIST);
-                    $config->glossary = $mappedglossary->id;
-                    $config->courseid = $mappedglossary->course;
-                    $config->globalglossary = $mappedglossary->globalglossary;
-                    $configdata = base64_encode(serialize($config));
-                    $DB->set_field('block_instances', 'configdata', $configdata, array('id' => $blockid));
+                    // Get glossary mapping and replace it in config
+                    $config->glossary = $glossarymap->newitemid;
+                } else if ($this->is_samesite()) {
+                    // We are restoring on the same site, check if glossary can be used in the block in this course.
+                    $glossaryid = $DB->get_field_sql("SELECT id FROM {glossary} " .
+                        "WHERE id = ? AND (course = ? OR globalglossary = 1)",
+                        [$config->glossary, $this->get_courseid()]);
+                    if (!$glossaryid) {
+                        unset($config->glossary);
+                    }
                 } else {
                     // The block refers to a glossary not present in the backup file.
-                    $DB->set_field('block_instances', 'configdata', '', array('id' => $blockid));
+                    unset($config->glossary);
                 }
+                // Unset config variables that are no longer used.
+                unset($config->globalglossary);
+                unset($config->courseid);
+                // Save updated config.
+                $configdata = base64_encode(serialize($config));
+                $DB->set_field('block_instances', 'configdata', $configdata, array('id' => $blockid));
             }
         }
     }
index 6ca3bb5..bf9f6a7 100644 (file)
@@ -29,6 +29,12 @@ define('BGR_NEXTALPHA',    '3');
 
 class block_glossary_random extends block_base {
 
+    /**
+     * @var cm_info|stdClass has properties 'id' (course module id) and 'uservisible'
+     *     (whether the glossary is visible to the current user)
+     */
+    protected $glossarycm = null;
+
     function init() {
         $this->title = get_string('pluginname','block_glossary_random');
     }
@@ -58,6 +64,11 @@ class block_glossary_random extends block_base {
         //check if it's time to put a new entry in cache
         if (time() > $this->config->nexttime) {
 
+            if (!($cm = $this->get_glossary_cm()) || !$cm->uservisible) {
+                // Skip generating of the cache if we can't display anything to the current user.
+                return false;
+            }
+
             // place glossary concept and definition in $pref->cache
             if (!$numberofentries = $DB->count_records('glossary_entries',
                                                        array('glossaryid'=>$this->config->glossary, 'approved'=>1))) {
@@ -65,20 +76,6 @@ class block_glossary_random extends block_base {
                 $this->instance_config_commit();
             }
 
-            // Get glossary instance, if not found then return without error, as this will be handled in get_content.
-            if (!$glossary = $DB->get_record('glossary', array('id' => $this->config->glossary))) {
-                return false;
-            }
-
-            $this->config->globalglossary = $glossary->globalglossary;
-
-            // Save course id in config, so we can get correct course module.
-            $this->config->courseid = $glossary->course;
-
-            // Get module and context, to be able to rewrite urls
-            if (! $cm = get_coursemodule_from_instance('glossary', $glossary->id, $this->config->courseid)) {
-                return false;
-            }
             $glossaryctx = context_module::instance($cm->id);
 
             $limitfrom = 0;
@@ -156,88 +153,103 @@ class block_glossary_random extends block_base {
         }
     }
 
-    function instance_allow_multiple() {
-    // Are you going to allow multiple instances of each block?
-    // If yes, then it is assumed that the block WILL USE per-instance configuration
-        return true;
+    /**
+     * Replace the instance's configuration data with those currently in $this->config;
+     */
+    function instance_config_commit($nolongerused = false) {
+        // Unset config variables that are no longer used.
+        unset($this->config->globalglossary);
+        unset($this->config->courseid);
+        parent::instance_config_commit($nolongerused);
     }
 
-    function get_content() {
-        global $USER, $CFG, $DB;
-
+    /**
+     * Checks if glossary is available - it should be either located in the same course or be global
+     *
+     * @return null|cm_info|stdClass object with properties 'id' (course module id) and 'uservisible'
+     */
+    protected function get_glossary_cm() {
+        global $DB;
         if (empty($this->config->glossary)) {
-            $this->content = new stdClass();
-            if ($this->user_can_edit()) {
-                $this->content->text = get_string('notyetconfigured','block_glossary_random');
-            } else {
-                $this->content->text = '';
-            }
-            $this->content->footer = '';
-            return $this->content;
+            // No glossary is configured.
+            return null;
         }
 
-        require_once($CFG->dirroot.'/course/lib.php');
+        if (!empty($this->glossarycm)) {
+            return $this->glossarycm;
+        }
 
-        // If $this->config->globalglossary is not set then get glossary info from db.
-        if (!isset($this->config->globalglossary)) {
-            if (!$glossary = $DB->get_record('glossary', array('id' => $this->config->glossary))) {
-                return '';
-            } else {
-                $this->config->courseid = $glossary->course;
-                $this->config->globalglossary = $glossary->globalglossary;
-                $this->instance_config_commit();
+        if (!empty($this->page->course->id)) {
+            // First check if glossary belongs to the current course (we don't need to make any DB queries to find it).
+            $modinfo = get_fast_modinfo($this->page->course);
+            if (isset($modinfo->instances['glossary'][$this->config->glossary])) {
+                $this->glossarycm = $modinfo->instances['glossary'][$this->config->glossary];
+                if ($this->glossarycm->uservisible) {
+                    // The glossary is in the same course and is already visible to the current user,
+                    // no need to check if it is global, save on DB query.
+                    return $this->glossarycm;
+                }
             }
         }
 
-        $modinfo = get_fast_modinfo($this->config->courseid);
-        // If deleted glossary or non-global glossary on different course page, then reset.
-        if (!isset($modinfo->instances['glossary'][$this->config->glossary])
-                || ((empty($this->config->globalglossary) && ($this->config->courseid != $this->page->course->id)))) {
+        // Find course module id for the given glossary, only if it is global.
+        $cm = $DB->get_record_sql("SELECT cm.id, cm.visible AS uservisible
+              FROM {course_modules} cm
+                   JOIN {modules} md ON md.id = cm.module
+                   JOIN {glossary} g ON g.id = cm.instance
+             WHERE g.id = :instance AND md.name = :modulename AND g.globalglossary = 1",
+            ['instance' => $this->config->glossary, 'modulename' => 'glossary']);
+
+        if ($cm) {
+            // This is a global glossary, create an object with properties 'id' and 'uservisible'. We don't need any
+            // other information so why bother retrieving it. Full access check is skipped for global glossaries for
+            // performance reasons.
+            $this->glossarycm = $cm;
+        } else if (empty($this->glossarycm)) {
+            // Glossary does not exist. Remove it in the config so we don't repeat this check again later.
             $this->config->glossary = 0;
-            $this->config->cache = '';
             $this->instance_config_commit();
+        }
 
-            $this->content = new stdClass();
-            if ($this->user_can_edit()) {
-                $this->content->text = get_string('notyetconfigured','block_glossary_random');
-            } else {
-                $this->content->text = '';
-            }
-            $this->content->footer = '';
+        return $this->glossarycm;
+    }
+
+    function instance_allow_multiple() {
+    // Are you going to allow multiple instances of each block?
+    // If yes, then it is assumed that the block WILL USE per-instance configuration
+        return true;
+    }
+
+    function get_content() {
+        if ($this->content !== null) {
             return $this->content;
         }
+        $this->content = (object)['text' => '', 'footer' => ''];
 
-        $cm = $modinfo->instances['glossary'][$this->config->glossary];
-        if (!has_capability('mod/glossary:view', context_module::instance($cm->id))) {
-            return '';
+        if (!$cm = $this->get_glossary_cm()) {
+            if ($this->user_can_edit()) {
+                $this->content->text = get_string('notyetconfigured', 'block_glossary_random');
+            }
+            return $this->content;
         }
 
         if (empty($this->config->cache)) {
             $this->config->cache = '';
         }
 
-        if ($this->content !== NULL) {
-            return $this->content;
-        }
-
-        $this->content = new stdClass();
-
-        // Show glossary if visible and place links in footer.
-        if ($cm->visible) {
+        if ($cm->uservisible) {
+            // Show glossary if visible and place links in footer.
             $this->content->text = $this->config->cache;
             if (has_capability('mod/glossary:write', context_module::instance($cm->id))) {
-                $this->content->footer = '<a href="'.$CFG->wwwroot.'/mod/glossary/edit.php?cmid='.$cm->id
-                .'" title="'.$this->config->addentry.'">'.$this->config->addentry.'</a><br />';
-            } else {
-                $this->content->footer = '';
+                $this->content->footer = html_writer::link(new moodle_url('/mod/glossary/edit.php', ['cmid' => $cm->id]),
+                    format_string($this->config->addentry)) . '<br/>';
             }
 
-            $this->content->footer .= '<a href="'.$CFG->wwwroot.'/mod/glossary/view.php?id='.$cm->id
-                .'" title="'.$this->config->viewglossary.'">'.$this->config->viewglossary.'</a>';
-
-        // Otherwise just place some text, no link.
+            $this->content->footer .= html_writer::link(new moodle_url('/mod/glossary/view.php', ['id' => $cm->id]),
+                format_string($this->config->viewglossary));
         } else {
-            $this->content->footer = $this->config->invisible;
+            // Otherwise just place some text, no link.
+            $this->content->footer = format_string($this->config->invisible);
         }
 
         return $this->content;
diff --git a/blocks/glossary_random/tests/behat/glossary_random_global.feature b/blocks/glossary_random/tests/behat/glossary_random_global.feature
new file mode 100644 (file)
index 0000000..2eea426
--- /dev/null
@@ -0,0 +1,84 @@
+@block @block_glossary_random
+Feature: Random glossary entry block linking to global glossary
+  In order to show the entries from glossary
+  As a teacher
+  I can add the random glossary entry to a course page
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname |
+      | Course 1 | C1        |
+      | Course 2 | C2        |
+    And the following "activities" exist:
+      | activity   | name             | intro                          | course               | idnumber  | globalglossary | defaultapproval |
+      | glossary   | Tips and Tricks  | Frontpage glossary description | C2 | glossary0 | 1              | 1               |
+    And the following "users" exist:
+      | username | firstname | lastname | email             |
+      | student1 | Sam1      | Student1 | student1@example.com |
+      | teacher1 | Terry1    | Teacher1 | teacher1@example.com |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | student1 | C1     | student        |
+      | teacher1 | C1     | editingteacher |
+
+  Scenario: View random (last) entry in the global glossary
+    When I log in as "admin"
+    And I am on site homepage
+    And I follow "Course 2"
+    And I follow "Tips and Tricks"
+    And I press "Add a new entry"
+    And I set the following fields to these values:
+      | Concept    | Never come late               |
+      | Definition | Come in time for your classes |
+    And I press "Save changes"
+    And I log out
+    # As a teacher add a block to the course page linking to the global glossary.
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add the "Random glossary entry" block
+    And I configure the "block_glossary_random" block
+    And I set the following fields to these values:
+      | Title                           | Tip of the day      |
+      | Take entries from this glossary | Tips and Tricks     |
+      | How a new entry is chosen       | Last modified entry |
+    And I press "Save changes"
+    Then I should see "Never come late" in the "Tip of the day" "block"
+    And I should not see "Add a new entry" in the "Tip of the day" "block"
+    And I should see "View all entries" in the "Tip of the day" "block"
+    And I log out
+    # Student who can't see the module is still able to view entries in this block (because the glossary was marked as global)
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I should see "Never come late" in the "Tip of the day" "block"
+    And I should not see "Add a new entry" in the "Tip of the day" "block"
+    And I should see "View all entries" in the "Tip of the day" "block"
+    And I log out
+
+  Scenario: Removing the global glossary that is used in random glossary block
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add the "Random glossary entry" block
+    And I configure the "block_glossary_random" block
+    And I set the following fields to these values:
+      | Title                           | Tip of the day      |
+      | Take entries from this glossary | Tips and Tricks     |
+      | How a new entry is chosen       | Last modified entry |
+    And I press "Save changes"
+    And I log out
+    And I log in as "admin"
+    And I am on site homepage
+    And I follow "Course 2"
+    And I follow "Tips and Tricks"
+    And I follow "Edit settings"
+    And I set the field "globalglossary" to "0"
+    And I press "Save and return to course"
+    And I am on site homepage
+    And I follow "Course 1"
+    Then I should see "Please configure this block using the edit icon." in the "Tip of the day" "block"
+    And I log out
+    And I log in as "student1"
+    And I follow "Course 1"
+    And "Tip of the day" "block" should not exist
+    And I log out
diff --git a/blocks/messages/tests/behat/block_messages_course.feature b/blocks/messages/tests/behat/block_messages_course.feature
new file mode 100644 (file)
index 0000000..068b0e0
--- /dev/null
@@ -0,0 +1,58 @@
+@block @block_messages
+Feature: The messages block allows users to list new messages an a course
+  In order to enable the messages block in a course
+  As a teacher
+  I can add the messages block to a course and view my messages
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email | idnumber |
+      | teacher1 | Teacher | 1 | teacher1@example.com | T1 |
+      | student1 | Student | 1 | student1@example.com | S1 |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+
+  Scenario: View the block by a user with messaging disabled.
+    Given the following config values are set as admin:
+      | messaging       | 0 |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    When I turn editing mode on
+    And I add the "Messages" block
+    Then I should see "Messaging is disabled on this site" in the "Messages" "block"
+
+  Scenario: View the block by a user who does not have any messages.
+    Given I log in as "teacher1"
+    And I follow "Course 1"
+    When I turn editing mode on
+    And I add the "Messages" block
+    Then I should see "No messages waiting" in the "Messages" "block"
+
+  Scenario: View the block by a user who has messages.
+    Given I log in as "student1"
+    And I follow "Messages" in the user menu
+    And I send "This is message 1" message to "Teacher 1" user
+    And I send "This is message 2" message to "Teacher 1" user
+    And I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    When I turn editing mode on
+    And I add the "Messages" block
+    Then I should see "Student 1" in the "Messages" "block"
+
+  Scenario: Use the block to send a message to a user.
+    Given I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add the "Messages" block
+    And I follow "Messages"
+    And I send "This is message 1" message to "Student 1" user
+    And I log out
+    When I log in as "student1"
+    And I follow "Course 1"
+    Then I should see "Teacher 1" in the "Messages" "block"
diff --git a/blocks/messages/tests/behat/block_messages_dashboard.feature b/blocks/messages/tests/behat/block_messages_dashboard.feature
new file mode 100644 (file)
index 0000000..8eb1918
--- /dev/null
@@ -0,0 +1,48 @@
+@block @block_messages
+Feature: The messages block allows users to list new messages on the dashboard
+  In order to enable the messages block on the dashboard
+  As a user
+  I can add the messages block to a my dashboard and view my messages
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email | idnumber |
+      | teacher1 | Teacher | 1 | teacher1@example.com | T1 |
+      | student1 | Student | 1 | student1@example.com | S1 |
+
+  Scenario: View the block by a user with messaging disabled.
+    Given the following config values are set as admin:
+      | messaging       | 0 |
+    And I log in as "teacher1"
+    And I press "Customise this page"
+    When I add the "Messages" block
+    Then I should see "Messaging is disabled on this site" in the "Messages" "block"
+
+  Scenario: View the block by a user who does not have any messages.
+    Given I log in as "teacher1"
+    And I press "Customise this page"
+    When I add the "Messages" block
+    Then I should see "No messages waiting" in the "Messages" "block"
+
+  Scenario: View the block by a user who has messages.
+    Given I log in as "student1"
+    And I follow "Messages" in the user menu
+    And I send "This is message 1" message to "Teacher 1" user
+    And I send "This is message 2" message to "Teacher 1" user
+    And I log out
+    When I log in as "teacher1"
+    And I press "Customise this page"
+    And I add the "Messages" block
+    Then I should see "Student 1" in the "Messages" "block"
+
+  Scenario: Use the block to send a message to a user.
+    Given I log in as "teacher1"
+    And I press "Customise this page"
+    And I add the "Messages" block
+    And I follow "Messages"
+    And I send "This is message 1" message to "Student 1" user
+    And I log out
+    When I log in as "student1"
+    And I press "Customise this page"
+    And I add the "Messages" block
+    Then I should see "Teacher 1" in the "Messages" "block"
diff --git a/blocks/messages/tests/behat/block_messages_frontpage.feature b/blocks/messages/tests/behat/block_messages_frontpage.feature
new file mode 100644 (file)
index 0000000..df00991
--- /dev/null
@@ -0,0 +1,56 @@
+@block @block_messages
+Feature: The messages block allows users to list new messages on the frontpage
+  In order to enable the messages block on the frontpage
+  As an admin
+  I can add the messages block to a the frontpage and view my messages
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email | idnumber |
+      | teacher1 | Teacher | 1 | teacher1@example.com | T1 |
+      | student1 | Student | 1 | student1@example.com | S1 |
+    And I log in as "admin"
+    And I am on site homepage
+    And I navigate to "Turn editing on" node in "Front page settings"
+    And I add the "Messages" block
+    And I log out
+
+  Scenario: View the block by a user with messaging disabled.
+    Given the following config values are set as admin:
+      | messaging       | 0 |
+    And I log in as "admin"
+    And I am on site homepage
+    When I navigate to "Turn editing on" node in "Front page settings"
+    And I should see "Messaging is disabled on this site" in the "Messages" "block"
+    Then I navigate to "Turn editing off" node in "Front page settings"
+    And I should not see "Messaging is disabled on this site"
+
+  Scenario: View the block by a user who does not have any messages.
+    Given I log in as "teacher1"
+    When I am on site homepage
+    Then I should see "No messages waiting" in the "Messages" "block"
+
+  Scenario: Try to view the block as a guest user.
+    Given I log in as "guest"
+    When I am on site homepage
+    Then I should not see "Messages"
+
+  Scenario: View the block by a user who has messages.
+    Given I log in as "student1"
+    And I follow "Messages" in the user menu
+    And I send "This is message 1" message to "Teacher 1" user
+    And I send "This is message 2" message to "Teacher 1" user
+    And I log out
+    When I log in as "teacher1"
+    And I am on site homepage
+    Then I should see "Student 1" in the "Messages" "block"
+
+  Scenario: Use the block to send a message to a user.
+    Given I log in as "teacher1"
+    And I am on site homepage
+    And I follow "Messages"
+    And I send "This is message 1" message to "Student 1" user
+    And I log out
+    When I log in as "student1"
+    And I am on site homepage
+    Then I should see "Teacher 1" in the "Messages" "block"
index 5a5d7b9..2025554 100644 (file)
Binary files a/blocks/navigation/amd/build/navblock.min.js and b/blocks/navigation/amd/build/navblock.min.js differ
index 14b14bb..40e8678 100644 (file)
  */
 define(['jquery', 'core/tree'], function($, Tree) {
     return {
-        init: function() {
-            new Tree(".block_navigation .block_tree");
+        init: function(instanceid) {
+            var navTree = new Tree(".block_navigation .block_tree");
+            navTree.finishExpandingGroup = function(item) {
+                Tree.prototype.finishExpandingGroup.call(this, item);
+                Y.use('moodle-core-event', function() {
+                    Y.Global.fire(M.core.globalEvents.BLOCK_CONTENT_UPDATED, {
+                        instanceid: instanceid
+                    });
+                });
+            };
+            navTree.collapseGroup = function(item) {
+                Tree.prototype.collapseGroup.call(this, item);
+                Y.use('moodle-core-event', function() {
+                    Y.Global.fire(M.core.globalEvents.BLOCK_CONTENT_UPDATED, {
+                        instanceid: instanceid
+                    });
+                });
+            };
         }
     };
 });
index 31a3fc8..e7a5c11 100644 (file)
@@ -108,8 +108,11 @@ class block_navigation extends block_base {
      */
     function get_required_javascript() {
         parent::get_required_javascript();
+        $arguments = array(
+            'instanceid' => $this->instance->id
+        );
         $this->page->requires->string_for_js('viewallcourses', 'moodle');
-        $this->page->requires->js_call_amd('block_navigation/navblock', 'init', array());
+        $this->page->requires->js_call_amd('block_navigation/navblock', 'init', $arguments);
     }
 
     /**
index 47ef1c2..140ed42 100644 (file)
@@ -3,6 +3,7 @@
 .block_navigation .block_tree ul {margin-left: 18px;}
 .block_navigation .block_tree p.hasicon {text-indent: -21px; padding-left: 21px;}
 .block_navigation .block_tree p.hasicon img {width: 16px; height: 16px; margin-top: 3px; margin-right: 5px; vertical-align: top;}
+.block_navigation .block_tree p.hasicon.visibleifjs {display: block;}
 
 .block_navigation .block_tree .tree_item {cursor:pointer; padding-left: 0;margin:3px 0px; background-position: 0 50%; background-repeat: no-repeat;}
 .block_navigation .block_tree .tree_item.branch {padding-left: 21px;}
index c8b7f2c..9cd9406 100644 (file)
@@ -81,7 +81,6 @@ class block_news_items extends block_base {
             $groupmode    = groups_get_activity_groupmode($cm);
             $currentgroup = groups_get_activity_group($cm, true);
 
-
             if (forum_user_can_post_discussion($forum, $currentgroup, $groupmode, $cm, $context)) {
                 $text .= '<div class="newlink"><a href="'.$CFG->wwwroot.'/mod/forum/post.php?forum='.$forum->id.'">'.
                           get_string('addanewtopic', 'forum').'</a>...</div>';
@@ -96,7 +95,8 @@ class block_news_items extends block_base {
             // This sort will ignore pinned posts as we want the most recent.
             $sort = forum_get_default_sort_order(true, 'p.modified', 'd', false);
             if (! $discussions = forum_get_discussions($cm, $sort, false,
-                                                       $currentgroup, $this->page->course->newsitems) ) {
+                                                        -1, $this->page->course->newsitems,
+                                                        false, -1, 0, FORUM_POSTS_ALL_USER_GROUPS) ) {
                 $text .= '('.get_string('nonews', 'forum').')';
                 $this->content->text = $text;
                 return $this->content;
diff --git a/blocks/online_users/tests/behat/block_online_users_course.feature b/blocks/online_users/tests/behat/block_online_users_course.feature
new file mode 100644 (file)
index 0000000..f202235
--- /dev/null
@@ -0,0 +1,41 @@
+@block @block_online_users
+Feature: The online users block allow you to see who is currently online
+  In order to enable the online users block on an course page
+  As a teacher
+  I can add the online users block to a course page
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+      | student2 | Student   | 2        | student2@example.com |
+
+    And the following "course enrolments" exist:
+      | user | course | role           |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student        |
+
+  Scenario: Add the online users on course page and see myself
+    Given I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    When I add the "Online users" block
+    Then I should see "Teacher 1" in the "Online users" "block"
+
+  Scenario: Add the online users on course page and see other logged in users
+    Given I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add the "Online users" block
+    And I log out
+    And I log in as "student2"
+    And I log out
+    When I log in as "student1"
+    And I follow "Course 1"
+    Then I should see "Teacher 1" in the "Online users" "block"
+    And I should see "Student 1" in the "Online users" "block"
+    And I should not see "Student 2" in the "Online users" "block"
diff --git a/blocks/online_users/tests/behat/block_online_users_dashboard.feature b/blocks/online_users/tests/behat/block_online_users_dashboard.feature
new file mode 100644 (file)
index 0000000..ecde2a0
--- /dev/null
@@ -0,0 +1,26 @@
+@block @block_online_users
+Feature: The online users block allow you to see who is currently online
+  In order to use the online users block on the dashboard
+  As a user
+  I can view the online users block on my dashboard
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+      | student2 | Student   | 2        | student2@example.com |
+
+  Scenario: View the online users block on the dashboard and see myself
+    Given I log in as "teacher1"
+    Then I should see "Teacher 1" in the "Online users" "block"
+
+  Scenario: View the online users block on the dashboard and see other logged in users
+    Given I log in as "student2"
+    And I log out
+    And I log in as "student1"
+    And I log out
+    When  I log in as "teacher1"
+    Then I should see "Teacher 1" in the "Online users" "block"
+    And I should see "Student 1" in the "Online users" "block"
+    And I should see "Student 2" in the "Online users" "block"
diff --git a/blocks/online_users/tests/behat/block_online_users_frontpage.feature b/blocks/online_users/tests/behat/block_online_users_frontpage.feature
new file mode 100644 (file)
index 0000000..3e4b56d
--- /dev/null
@@ -0,0 +1,48 @@
+@block @block_online_users
+Feature: The online users block allow you to see who is currently online
+  In order to enable the online users block on the front page page
+  As an admin
+  I can add the online users block to the front page page
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | 1        | student1@example.com |
+      | student2 | Student   | 2        | student2@example.com |
+
+  Scenario: View the online users block on the front page and see myself
+    Given I log in as "admin"
+    And I am on site homepage
+    And I navigate to "Turn editing on" node in "Front page settings"
+    When I add the "Online users" block
+    Then I should see "Admin User" in the "Online users" "block"
+
+  Scenario: View the online users block on the front page as a logged in user
+    Given I log in as "admin"
+    And I am on site homepage
+    And I navigate to "Turn editing on" node in "Front page settings"
+    And I add the "Online users" block
+    And I log out
+    And I log in as "student2"
+    And I log out
+    When I log in as "student1"
+    And I am on site homepage
+    Then I should see "Admin User" in the "Online users" "block"
+    And I should see "Student 1" in the "Online users" "block"
+    And I should see "Student 2" in the "Online users" "block"
+
+  Scenario: View the online users block on the front page as a guest
+    Given I log in as "admin"
+    And I am on site homepage
+    And I navigate to "Turn editing on" node in "Front page settings"
+    And I add the "Online users" block
+    And I log out
+    And I log in as "student2"
+    And I log out
+    And I log in as "student1"
+    And I log out
+    When I log in as "guest"
+    And I am on site homepage
+    Then I should see "Admin User" in the "Online users" "block"
+    And I should see "Student 1" in the "Online users" "block"
+    And I should see "Student 2" in the "Online users" "block"
diff --git a/blocks/private_files/tests/behat/block_private_files_activity.feature b/blocks/private_files/tests/behat/block_private_files_activity.feature
new file mode 100644 (file)
index 0000000..ef48e37
--- /dev/null
@@ -0,0 +1,29 @@
+@block @block_private_files @file_upload @javascript
+Feature: The private files block allows users to store files privately in moodle
+  In order to store a private file in moodle
+  As a teacher
+  I can upload the file to my private files area using the private files block in an activity
+
+  Scenario: Upload a file to the private files block in an activity
+    Given the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+    And the following "activities" exist:
+      | activity | course | idnumber | name           | intro                 |
+      | page    | C1      | page1    | Test page name | Test page description |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I follow "Test page name"
+    And I add the "Private files" block
+    And I should see "No files available" in the "Private files" "block"
+    When I follow "Manage private files..."
+    And I upload "blocks/private_files/tests/fixtures/testfile.txt" file to "Files" filemanager
+    And I press "Save changes"
+    Then I should see "testfile.txt" in the "Private files" "block"
diff --git a/blocks/private_files/tests/behat/block_private_files_course.feature b/blocks/private_files/tests/behat/block_private_files_course.feature
new file mode 100644 (file)
index 0000000..8ed28b3
--- /dev/null
@@ -0,0 +1,25 @@
+@block @block_private_files @file_upload @javascript
+Feature: The private files block allows users to store files privately in moodle
+  In order to store a private file in moodle
+  As a teacher
+  I can upload the file to my private files area using the private files block in a course
+
+  Scenario: Upload a file to the private files block from a course
+    Given the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add the "Private files" block
+    And I should see "No files available" in the "Private files" "block"
+    When I follow "Manage private files..."
+    And I upload "blocks/private_files/tests/fixtures/testfile.txt" file to "Files" filemanager
+    And I press "Save changes"
+    Then I should see "testfile.txt" in the "Private files" "block"
diff --git a/blocks/private_files/tests/behat/block_private_files_dashboard.feature b/blocks/private_files/tests/behat/block_private_files_dashboard.feature
new file mode 100644 (file)
index 0000000..976ae98
--- /dev/null
@@ -0,0 +1,17 @@
+@block @block_private_files @file_upload @javascript
+Feature: The private files block allows users to store files privately in moodle
+  In order to store a private file in moodle
+  As a user
+  I can upload the file to my private files area using the private files block on the dashboard
+
+  Scenario: Upload a file to the private files block from the dashboard
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And I log in as "teacher1"
+    And "Private files" "block" should exist
+    And I should see "No files available" in the "Private files" "block"
+    When I follow "Manage private files..."
+    And I upload "blocks/private_files/tests/fixtures/testfile.txt" file to "Files" filemanager
+    And I press "Save changes"
+    Then I should see "testfile.txt" in the "Private files" "block"
diff --git a/blocks/private_files/tests/behat/block_private_files_frontpage.feature b/blocks/private_files/tests/behat/block_private_files_frontpage.feature
new file mode 100644 (file)
index 0000000..77d5756
--- /dev/null
@@ -0,0 +1,34 @@
+@block @block_private_files @file_upload
+Feature: The private files block allows users to store files privately in moodle
+  In order to store a private file in moodle
+  As a teacher
+  I can upload the file to my private files area using the private files block from the front page
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And I log in as "admin"
+    And I am on site homepage
+    And I navigate to "Turn editing on" node in "Front page settings"
+    And I add the "Private files" block
+    And I log out
+
+  Scenario: Try to view the private files block as a guest
+    Given I log in as "guest"
+    When I am on site homepage
+    Then "Private files" "block" should not exist
+
+  @javascript
+  Scenario: Upload a file to the private files block from the frontpage
+    Given I log in as "teacher1"
+    And I am on site homepage
+    And "Private files" "block" should exist
+    And I should see "No files available" in the "Private files" "block"
+    When I follow "Manage private files..."
+    And I upload "blocks/private_files/tests/fixtures/testfile.txt" file to "Files" filemanager
+    And I press "Save changes"
+    Then I should see "testfile.txt" in the "Private files" "block"
diff --git a/blocks/private_files/tests/fixtures/testfile.txt b/blocks/private_files/tests/fixtures/testfile.txt
new file mode 100644 (file)
index 0000000..9f4b6d8
--- /dev/null
@@ -0,0 +1 @@
+This is a test file
index 91aa6a5..8a8e746 100644 (file)
Binary files a/blocks/settings/amd/build/settingsblock.min.js and b/blocks/settings/amd/build/settingsblock.min.js differ
index 965aec5..bdccda5 100644 (file)
 /**
  * Load the settings block tree javscript
  *
- * @module     block_navigation/navblock
+ * @module     block_settings/settingsblock
  * @package    core
  * @copyright  2015 John Okely <john@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 define(['jquery', 'core/tree'], function($, Tree) {
     return {
-        init: function(siteAdminNodeId) {
+        init: function(instanceid, siteAdminNodeId) {
             var adminTree = new Tree(".block_settings .block_tree");
             if (siteAdminNodeId) {
                 var siteAdminNode = adminTree.treeRoot.find('#' + siteAdminNodeId);
                 var siteAdminLink = siteAdminNode.children('a').first();
                 siteAdminLink.replaceWith('<span tabindex="0">' + siteAdminLink.html() + '</span>');
             }
+            adminTree.finishExpandingGroup = function(item) {
+                Tree.prototype.finishExpandingGroup.call(this, item);
+                Y.use('moodle-core-event', function() {
+                    Y.Global.fire(M.core.globalEvents.BLOCK_CONTENT_UPDATED, {
+                        instanceid: instanceid
+                    });
+                });
+            };
+            adminTree.collapseGroup = function(item) {
+                Tree.prototype.collapseGroup.call(this, item);
+                Y.Global.fire(M.core.globalEvents.BLOCK_CONTENT_UPDATED, {
+                    instanceid: instanceid
+                });
+                Y.use('moodle-core-event', function() {
+                    Y.Global.fire(M.core.globalEvents.BLOCK_CONTENT_UPDATED, {
+                        instanceid: instanceid
+                    });
+                });
+            };
         }
     };
 });
index beaa87f..1ed9409 100644 (file)
@@ -91,18 +91,13 @@ class block_settings extends block_base {
 
     function get_required_javascript() {
         global $PAGE;
-        $adminnodeid = null;
         $adminnode = $PAGE->settingsnav->find('siteadministration', navigation_node::TYPE_SITE_ADMIN);
-        if (!empty($adminnode)) {
-            $adminnodeid = $adminnode->id;
-        }
         parent::get_required_javascript();
         $arguments = array(
-            'id' => $this->instance->id,
-            'instance' => $this->instance->id,
-            'candock' => $this->instance_can_be_docked()
+            'instanceid' => $this->instance->id,
+            'adminnodeid' => $adminnode ? $adminnode->id : null
         );
-        $this->page->requires->js_call_amd('block_settings/settingsblock', 'init', array($adminnodeid));
+        $this->page->requires->js_call_amd('block_settings/settingsblock', 'init', $arguments);
     }
 
     /**
index 31b27ec..e9c0e10 100644 (file)
@@ -1,6 +1,7 @@
 .block_settings .block_tree ul {margin-left: 18px;}
 .block_settings .block_tree p.hasicon {text-indent: -21px; padding-left: 21px;}
 .block_settings .block_tree p.hasicon img {width: 16px; height: 16px; margin-top: 3px; margin-right: 5px; vertical-align: top;}
+.block_settings .block_tree p.hasicon.visibleifjs {display: block;}
 
 .block_settings .block_tree .tree_item.branch {padding-left: 21px;}
 .block_settings .block_tree .tree_item {cursor:pointer; margin:3px 0px; background-position: 0 50%; background-repeat: no-repeat;}
index a960293..bfe717b 100644 (file)
@@ -34,6 +34,11 @@ list($context, $course, $cm) = get_context_info_array($contextid);
 require_login($course, true, $cm);
 require_sesskey();
 
+if (!$course) {
+    // Require_login() does not set context if called without a $course, do it manually.
+    $PAGE->set_context($context);
+}
+
 $action    = optional_param('action',    '',  PARAM_ALPHA);
 $area      = optional_param('area',      '',  PARAM_AREA);
 $content   = optional_param('content',   '',  PARAM_RAW);
@@ -48,7 +53,9 @@ if ($action !== 'add') {
 
 $cmt = new stdClass;
 $cmt->contextid = $contextid;
-$cmt->courseid  = $course->id;
+if ($course) {
+    $cmt->courseid = $course->id;
+}
 $cmt->cm        = $cm;
 $cmt->area      = $area;
 $cmt->itemid    = $itemid;
index ea687c6..b348aa1 100644 (file)
@@ -2140,7 +2140,7 @@ class core_course_external extends external_api {
                 'perpage'       => new external_value(PARAM_INT, 'items per page', VALUE_DEFAULT, 0),
                 'requiredcapabilities' => new external_multiple_structure(
                     new external_value(PARAM_CAPABILITY, 'Capability string used to filter courses by permission'),
-                    VALUE_OPTIONAL
+                    'Optional list of required capabilities (used to filter the list)', VALUE_DEFAULT, array()
                 ),
                 'limittoenrolled' => new external_value(PARAM_BOOL, 'limit to enrolled courses', VALUE_DEFAULT, 0),
             )
index 208c188..20f39a4 100644 (file)
@@ -984,14 +984,14 @@ abstract class format_base {
         }
         if (!is_object($section)) {
             $section = $DB->get_record('course_sections', array('course' => $this->get_courseid(), 'section' => $section),
-                'id,section,sequence');
+                'id,section,sequence,summary');
         }
         if (!$section || !$section->section) {
             // Not possible to delete 0-section.
             return false;
         }
 
-        if (!$forcedeleteifnotempty && !empty($section->sequence)) {
+        if (!$forcedeleteifnotempty && (!empty($section->sequence) || !empty($section->summary))) {
             return false;
         }
 
index 75ef3b8..212e98d 100644 (file)
Binary files a/course/yui/build/moodle-course-toolboxes/moodle-course-toolboxes-debug.js and b/course/yui/build/moodle-course-toolboxes/moodle-course-toolboxes-debug.js differ
index 290b316..9a0d330 100644 (file)
Binary files a/course/yui/build/moodle-course-toolboxes/moodle-course-toolboxes-min.js and b/course/yui/build/moodle-course-toolboxes/moodle-course-toolboxes-min.js differ
index 75ef3b8..212e98d 100644 (file)
Binary files a/course/yui/build/moodle-course-toolboxes/moodle-course-toolboxes.js and b/course/yui/build/moodle-course-toolboxes/moodle-course-toolboxes.js differ
index 3bb4e60..f475b89 100644 (file)
@@ -128,7 +128,6 @@ Y.extend(RESOURCETOOLBOX, TOOLBOX, {
                 break;
             case 'move':
             case 'update':
-            case 'duplicate':
             case 'assignroles':
                 break;
             default:
index 94ccd7b..ad454c4 100644 (file)
@@ -120,7 +120,7 @@ switch ($action) {
         }
 
         $roleid = optional_param('role', null, PARAM_INT);
-        $duration = optional_param('duration', 0, PARAM_INT);
+        $duration = optional_param('duration', 0, PARAM_FLOAT);
         $startdate = optional_param('startdate', 0, PARAM_INT);
         $recovergrades = optional_param('recovergrades', 0, PARAM_INT);
 
@@ -154,7 +154,7 @@ switch ($action) {
         if ($duration <= 0) {
             $timeend = 0;
         } else {
-            $timeend = $timestart + ($duration*24*60*60);
+            $timeend = $timestart + intval($duration*24*60*60);
         }
 
         $instances = $manager->get_enrolment_instances();
index cf131e6..f41dae3 100644 (file)
@@ -232,7 +232,7 @@ class enrol_manual_plugin extends enrol_plugin {
         $today = make_timestamp(date('Y', $now), date('m', $now), date('d', $now), 0, 0, 0);
         $startdateoptions[3] = get_string('today') . ' (' . userdate($today, $dateformat) . ')';
         $startdateoptions[4] = get_string('now', 'enrol_manual') . ' (' . userdate($now, get_string('strftimedatetimeshort')) . ')';
-        $defaultduration = $instance->enrolperiod > 0 ? $instance->enrolperiod / 86400 : '';
+        $defaultduration = $instance->enrolperiod > 0 ? $instance->enrolperiod / DAYSECS : '';
 
         $modules = array('moodle-enrol_manual-quickenrolment', 'moodle-enrol_manual-quickenrolment-skin');
         $arguments = array(
index 0b9ca4c..17627f8 100644 (file)
@@ -89,6 +89,9 @@ if ($extendperiod) {
 } else {
     $defaultperiod = $instance->enrolperiod;
 }
+if ($instance->enrolperiod > 0 && !isset($periodmenu[$instance->enrolperiod])) {
+    $periodmenu[$instance->enrolperiod] = format_time($instance->enrolperiod);
+}
 if (empty($extendbase)) {
     if (!$extendbase = get_config('enrol_manual', 'enrolstart')) {
         // Default to now if there is no system setting.
index b4132cc..5aeca32 100644 (file)
@@ -227,6 +227,7 @@ YUI.add('moodle-enrol_manual-quickenrolment', function(Y) {
         populateDuration : function() {
             var select = this.get(UEP.BASE).one('.'+CSS.ENROLMENTOPTION+'.'+CSS.DURATION+' select');
             var defaultvalue = this.get(UEP.DEFAULTDURATION);
+            var prefix = Math.round(defaultvalue) != defaultvalue ? '≈' : '';
             var index = 0, count = 0;
             var durationdays = M.util.get_string('durationdays', 'enrol', '{a}');
             for (var i = 1; i <= 365; i++) {
@@ -237,6 +238,11 @@ YUI.add('moodle-enrol_manual-quickenrolment', function(Y) {
                 }
                 select.append(option);
             }
+            if (!index && defaultvalue > 0) {
+                select.append(create('<option value="'+defaultvalue+'">'+durationdays.replace('{a}',
+                    prefix + (Math.round(defaultvalue * 100) / 100))+'</option>'));
+                index = ++count;
+            }
             select.set('selectedIndex', index);
         },
         getAssignableRoles : function(){
index 66cf003..1c0da1e 100644 (file)
@@ -1478,8 +1478,15 @@ class grade_structure {
                         $icon->pix = 'i/outcomes';
                         $icon->title = s(get_string('outcome', 'grades'));
                     } else {
-                        $icon->pix = 'icon';
-                        $icon->component = $element['object']->itemmodule;
+                        $modinfo = get_fast_modinfo($element['object']->courseid);
+                        $module = $element['object']->itemmodule;
+                        $instanceid = $element['object']->iteminstance;
+                        if (isset($modinfo->instances[$module][$instanceid])) {
+                            $icon->url = $modinfo->instances[$module][$instanceid]->get_icon_url();
+                        } else {
+                            $icon->pix = 'icon';
+                            $icon->component = $element['object']->itemmodule;
+                        }
                         $icon->title = s(get_string('modulename', $element['object']->itemmodule));
                     }
                 } else if ($element['object']->itemtype == 'manual') {
@@ -1504,6 +1511,8 @@ class grade_structure {
             if ($spacerifnone) {
                 $outputstr = $OUTPUT->spacer() . ' ';
             }
+        } else if (isset($icon->url)) {
+            $outputstr = html_writer::img($icon->url, $icon->title, $icon->attributes);
         } else {
             $outputstr = $OUTPUT->pix_icon($icon->pix, $icon->title, $icon->component, $icon->attributes);
         }
index b820f0e..05f7740 100644 (file)
@@ -42,3 +42,4 @@ L\'opció --help us orientarà.';
 $string['cliyesnoprompt'] = 'Escriu y (significa Sí) o n (significa No)';
 $string['environmentrequireinstall'] = 'cal instal·lar-lo i habilitar-lo';
 $string['environmentrequireversion'] = 'esteu executant la versió {$a->current} i es requereix la {$a->needed}';
+$string['upgradekeyset'] = 'Clau d\'actualització (deixeu-ho en blanc per no establir-ne cap)';
index 8e4178b..02b3354 100644 (file)
@@ -67,14 +67,11 @@ $string['pathserrcreatedataroot'] = 'L\'instal·lador no pot crear el directori
 $string['pathshead'] = 'Confirmeu els camins';
 $string['pathsrodataroot'] = 'No es pot escriure en el directori dataroot.';
 $string['pathsroparentdataroot'] = 'No es pot escriure en el directori pare ({$a->parent}). L\'instal·lador no pot crear el directori ({$a->dataroot}).';
-$string['pathssubadmindir'] = 'Alguns serveis d\'allotjament web (pocs) utilitzen un URL especial /admin p. ex. per a accedir a un tauler de control o quelcom semblant. Malauradament això entra en conflicte amb la ubicació estàndard de les pàgines d\'administració de Moodle. Podeu arreglar aquest problema canviant el nom del directori d\'administració de Moodle en la vostra instal·lació i posant el nou nom aquí. Per exemple <em>moodleadmin</em>. Això modificarà els enllaços d\'administració de Moodle.';
+$string['pathssubadmindir'] = 'Alguns serveis d\'allotjament web (pocs) utilitzen /admin com a URL especial perquè accediu a un tauler de control o quelcom semblant. Malauradament, això entra en conflicte amb la ubicació estàndard de les pàgines d\'administració de Moodle. Podeu arreglar aquest problema canviant el nom del directori d\'administració de Moodle en la vostra instal·lació i posant el nou nom aquí. Per exemple: <em>moodleadmin</em>. Això arreglarà els enllaços d\'administració de Moodle.';
 $string['pathssubdataroot'] = 'Necessiteu un espai on Moodle pugui desar els fitxers penjats. Aquest directori hauria de tenir permisos de lectura I ESCRIPTURA per a l\'usuari del servidor web (normalment \'nobody\' o \'apache\'), però no cal que sigui accessible directament via web. L\'instal·lador provarà de crear-lo si no existeix.';
 $string['pathssubdirroot'] = 'Camí complet del directori d\'instal·lació de Moodle.';
-$string['pathssubwwwroot'] = 'L\'adreça web completa on s\'accedirà a Moodle.
-No és possible accedir a Moodle en diferents adreces.
-Si el vostre lloc té múltiples adreces públiques haureu de configurar redireccions permanents per a totes excepte aquesta.
-Si el vostre lloc és accessible tant des d\'Internet com des d\'una intranet, utilitzeu aquí l\'adreça pública i configureu el DNS de manera que els usuaris de la intranet puguin utilitzar també l\'adreça pública.
-Si l\'adreça no és correcta, canvieu l\'URL en el vostre navegador per reiniciar la instal·lació amb un altre valor.';
+$string['pathssubwwwroot'] = '<p>L\'adreça web completa on s\'accedirà a Moodle; per exemple, l\'adreça que els usuaris introduiran a la barra d\'adreces del navegador per accedir a Moodle.</p> <p> No és possible accedir a Moodle utilitzant diferents adreces. Si el vostre lloc és accessible a través de diferents adreces, trieu-ne la més fàcil i configureu una redirecció permanent per a cadascuna de les altres adreces.</p> <p>
+Si el vostre lloc és accessible tant des d\'Internet com des d\'una xarxa interna (anomenada de vegades intranet), utilitzeu l\'adreça pública aquí.</p> <p>Si l\'adreça actual no és correcta, canvieu l\'URL a la barra d\'adreces del navegador i reinicieu la instal·lació.';
 $string['pathsunsecuredataroot'] = 'La ubicació del dataroot no és segura.';
 $string['pathswrongadmindir'] = 'No existeix el directori d\'administració';
 $string['phpextension'] = 'Extensió PHP {$a}';
@@ -84,7 +81,7 @@ $string['phpversionhelp'] = '<p>Moodle necessita una versió de PHP 4.3.0 o 5.1.
 <p>Us cal actualitzar el PHP o traslladar Moodle a un ordinador amb una versió de PHP més recent.<br />(Si esteu utilitzant la versió 5.0.x, alternativament també podríeu tornar enrere a la 4.4.x)</p>';
 $string['welcomep10'] = '{$a->installername} ({$a->installerversion})';
 $string['welcomep20'] = 'Esteu veient aquesta pàgina perquè heu instal·lat amb èxit i heu executat el paquet <strong>{$a->packname} {$a->packversion}</strong>. Felicitacions.';
-$string['welcomep30'] = 'Aquesta versió de <strong>{$a->installername}</strong> inclou les aplicacions necessàries per crear un entorn en el qual funcioni <strong>Moodle</strong>:';
+$string['welcomep30'] = 'Aquesta versió de <strong>{$a->installername}</strong> inclou les aplicacions necessàries per crear un entorn en el qual funcioni <strong>Moodle</strong>, concretament:';
 $string['welcomep40'] = 'El paquet inclou també <strong>Moodle {$a->moodlerelease} ({$a->moodleversion})</strong>.';
 $string['welcomep50'] = 'L\'ús de totes les aplicacions d\'aquest paquet és governat per les seves llicències respectives. El paquet <strong>{$a->installername}</strong> complet és
 <a href="http://www.opensource.org/docs/definition_plain.html">codi font obert</a> i es distribueix
index c9f613d..5374ba9 100644 (file)
@@ -42,4 +42,4 @@ $string['cliunknowoption'] = 'אפשרויות לא מוכרות :
 אנא השתמש באפשרות העזרה.';
 $string['cliyesnoprompt'] = 'רשום y (שפרושו כן) או n (שפרושו לא)';
 $string['environmentrequireinstall'] = 'נדרש להתקין/לאפשר זאת';
-$string['environmentrequireversion'] = '×\92×\99רסת {$a->needed} × ×\93רשת ×\95×\90ת×\94 ×\9eר×\99×¥ {$a->current}';
+$string['environmentrequireversion'] = '×\92×\99רסת {$a->needed} × ×\93רשת ×\90×\9a ×\94×\92×\99רס×\94 ×\94× ×\95×\9b×\97×\99ת ×\94×\99×\90 {$a->current}';
index 8dfd3a2..f58e8a3 100644 (file)
@@ -86,10 +86,10 @@ $string['pathsunsecuredataroot'] = 'ספריית המידע (Data Directory) ל
 $string['pathswrongadmindir'] = 'ספריית ה-admin לא קיימת';
 $string['phpextension'] = 'הרחבת PHP {$a}';
 $string['phpversion'] = 'גירסת PHP';
-$string['phpversionhelp'] = '<p>גרסת PHP חייבת להיות לפחות 4.3.0 או 5.1.0 (בגרסאות 5.0.x קיימות מספר בעיות ידועות) </p>
+$string['phpversionhelp'] = '<p>גרסת PHP חייבת להיות לפחות 4.3.0 או 5.1.0 (בגרסאות 5.0 קיימות מספר בעיות ידועות) </p>
 <p> במערכת שלך פועלת כרגע גרסת {$a} </p>
 <p> אתה חייב לשדרג את גרסת ה-PHP שלך או לעבור למחשב מארח עם עם גירסת PHP חדשה! <br/>
-(במקרים של גרסת 5.0.x תוכל גם לרדת בגרסה ל- 4.4.x)
+(במקרים של גרסת 5.0 תוכל גם לרדת בגרסה ל- 4.4)
 </p>';
 $string['welcomep10'] = '{$a->installername} ({$a->installerversion})';
 $string['welcomep20'] = 'הינך רואה את עמוד זה מפני שהתקנת והפעלת בהלכה את <strong>{$a->packname} {$a->packversion}</strong>
index 46b031b..7ce183a 100644 (file)
@@ -33,4 +33,4 @@ defined('MOODLE_INTERNAL') || die();
 $string['language'] = 'שפת ממשק';
 $string['next'] = 'הבא';
 $string['previous'] = 'קודם';
-$string['reload'] = '×\98×¢×\9f מחדש';
+$string['reload'] = '×\98×¢×\99× ×\94 מחדש';
index 12ae97a..a4b6a21 100644 (file)
@@ -54,7 +54,7 @@ $string['paths'] = 'Caminhos';
 $string['pathserrcreatedataroot'] = 'O programa de instalação não conseguiu criar a pasta de dados <b>{$a->dataroot}</b>.';
 $string['pathshead'] = 'Confirmar caminhos';
 $string['pathsrodataroot'] = 'A pasta de dados não tem permissões de escrita.';
-$string['pathsroparentdataroot'] = 'A pasta pai <b>{$a->parent}</b> não tem permissões de escrita. O programa de instalação não conseguiu criar a pasta <b>{$a->dataroot}</b>.';
+$string['pathsroparentdataroot'] = 'A pasta ascendente <b>{$a->parent}</b> não tem permissões de escrita. O programa de instalação não conseguiu criar a pasta <b>{$a->dataroot}</b>.';
 $string['pathssubadmindir'] = 'Alguns servidores Web utilizam a pasta <strong>admin</strong> em URLs especiais de acesso a funcionalidades especiais, como é o caso de painéis de controlo. Algumas situações podem criar conflitos com a localização normal das páginas de administração do Moodle. Estes problemas podem ser resolvidos renomeando a pasta <strong>admin</strong> na instalação do Moodle e indicando aqui o novo nome a utilizar. Por exemplo:<br /><br /><b>moodleadmin</b><br /><br />Esta ação resolverá os problemas de acesso das hiperligações para as funcionalidades de administração do Moodle.';
 $string['pathssubdataroot'] = '<p>Uma diretoria em que o Moodle irá armazenar todo o conteúdo de ficheiros enviados pelos utilizadores.</p>
 <p>Esta diretoria deve ser legível e gravável pelo utilizador do servidor web (geralmente \'www-data\', \'nobody\', ou \'apache\').</p>
index 6e8f415..93db2e5 100644 (file)
@@ -224,6 +224,7 @@ $string['duplicateroleshortname'] = 'There is already a role with this short nam
 $string['duplicateusername'] = 'Duplicate username - skipping record';
 $string['emailfail'] = 'Emailing failed';
 $string['error'] = 'Error occurred';
+$string['error_question_answers_missing_in_db'] = 'Failed to find an answer matching "{$a->answer}" in the question_answers database table. This occurred while restoring the question with id {$a->filequestionid} in the backup file, which has been matched to the existing question with id {$a->dbquestionid} in the database.';
 $string['errorprocessingarchive'] = 'Error processing archive file';
 $string['errorcleaningdirectory'] = 'Error cleaning directory "{$a}"';
 $string['errorcopyingfiles'] = 'Error copying files';
index 766167f..d245288 100644 (file)
@@ -65,7 +65,7 @@ $string['categorycurrent'] = 'Current category';
 $string['categorycurrentuse'] = 'Use this category';
 $string['categorydoesnotexist'] = 'This category does not exist';
 $string['categoryinfo'] = 'Category info';
-$string['categorymove'] = 'The category \'{$a->name}\' contains {$a->count} questions (some of them may be old, hidden, questions that are still in use in some existing quizzes). Please choose another category to move them to.';
+$string['categorymove'] = 'The category \'{$a->name}\' contains {$a->count} questions (some of them may be old, hidden, questions, or Random questions that are still in use in some existing quizzes). Please choose another category to move them to.';
 $string['categorymoveto'] = 'Save in category';
 $string['categorynamecantbeblank'] = 'The category name cannot be blank.';
 $string['clickflag'] = 'Flag question';
index 3ca51ec..e1e346a 100644 (file)
@@ -105,6 +105,7 @@ define(['core/config'], function(config) {
     var hashString = function(source) {
         // From http://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript-jquery.
         /* jshint bitwise: false */
+        /* eslint no-bitwise: "off" */
         var hash = 0, i, chr, len;
         if (source.length === 0) {
             return hash;
index af58326..94959f0 100644 (file)
@@ -26,5 +26,6 @@
 define(function() {
 
     // This module exposes only the global yui instance.
+    /* global Y */
     return /** @alias module:core/yui */ Y;
 });
index 9a7c996..23d99f5 100644 (file)
@@ -2073,21 +2073,31 @@ function blocks_delete_instance($instance, $nolongerused = false, $skipblockstab
 function blocks_delete_instances($instanceids) {
     global $DB;
 
-    $instances = $DB->get_recordset_list('block_instances', 'id', $instanceids);
-    foreach ($instances as $instance) {
-        blocks_delete_instance($instance, false, true);
+    $limit = 1000;
+    $count = count($instanceids);
+    $chunks = [$instanceids];
+    if ($count > $limit) {
+        $chunks = array_chunk($instanceids, $limit);
     }
-    $instances->close();
 
-    $DB->delete_records_list('block_positions', 'blockinstanceid', $instanceids);
-    $DB->delete_records_list('block_instances', 'id', $instanceids);
+    // Perform deletion for each chunk.
+    foreach ($chunks as $chunk) {
+        $instances = $DB->get_recordset_list('block_instances', 'id', $chunk);
+        foreach ($instances as $instance) {
+            blocks_delete_instance($instance, false, true);
+        }
+        $instances->close();
+
+        $DB->delete_records_list('block_positions', 'blockinstanceid', $chunk);
+        $DB->delete_records_list('block_instances', 'id', $chunk);
 
-    $preferences = array();
-    foreach ($instanceids as $instanceid) {
-        $preferences[] = 'block' . $instanceid . 'hidden';
-        $preferences[] = 'docked_block_instance_' . $instanceid;
+        $preferences = array();
+        foreach ($chunk as $instanceid) {
+            $preferences[] = 'block' . $instanceid . 'hidden';
+            $preferences[] = 'docked_block_instance_' . $instanceid;
+        }
+        $DB->delete_records_list('user_preferences', 'name', $preferences);
     }
-    $DB->delete_records_list('user_preferences', 'name', $preferences);
 }
 
 /**
index bf1678b..5a1fd4d 100644 (file)
@@ -136,7 +136,12 @@ class memcached extends handler {
         ini_set('memcached.sess_locking', '1'); // Locking is required!
 
         // Try to configure lock and expire timeouts - ignored if memcached is before version 2.2.0.
-        ini_set('memcached.sess_lock_max_wait', $this->acquiretimeout);
+        if (version_compare($version, '3.0.0-dev') >= 0) {
+            ini_set('memcached.sess_lock_wait_max', $this->acquiretimeout * 1000);
+        } else {
+            ini_set('memcached.sess_lock_max_wait', $this->acquiretimeout);
+        }
+
         ini_set('memcached.sess_lock_expire', $this->lockexpire);
     }
 
index 6626075..a31117c 100644 (file)
@@ -27,156 +27,195 @@ define('AJAX_SCRIPT', true);
 require_once(dirname(__FILE__) . '/../../../config.php');
 require_once($CFG->libdir . '/filestorage/file_storage.php');
 
-$contextid = required_param('contextid', PARAM_INT);
-$elementid = required_param('elementid', PARAM_ALPHANUMEXT);
-$pagehash = required_param('pagehash', PARAM_ALPHANUMEXT);
-$pageinstance = required_param('pageinstance', PARAM_ALPHANUMEXT);
+// Clean up actions.
+$actions = array_map(function($actionparams) {
+    $action = isset($actionparams['action']) ? $actionparams['action'] : null;
+    $params = [];
+    $keys = [
+        'action' => PARAM_ALPHA,
+        'contextid' => PARAM_INT,
+        'elementid' => PARAM_ALPHANUMEXT,
+        'pagehash' => PARAM_ALPHANUMEXT,
+        'pageinstance' => PARAM_ALPHANUMEXT
+    ];
+
+    if ($action == 'save') {
+        $keys['drafttext'] = PARAM_RAW;
+    } else if ($action == 'resume') {
+        $keys['draftid'] = PARAM_INT;
+    }
+
+    foreach ($keys as $key => $type) {
+        // Replicate required_param().
+        if (!isset($actionparams[$key])) {
+            print_error('missingparam', '', '', $key);
+        }
+        $params[$key] = clean_param($actionparams[$key], $type);
+    }
+
+    return $params;
+}, isset($_REQUEST['actions']) ? $_REQUEST['actions'] : []);
+
 $now = time();
 // This is the oldest time any autosave text will be recovered from.
 // This is so that there is a good chance the draft files will still exist (there are many variables so
 // this is impossible to guarantee).
 $before = $now - 60*60*24*4;
 
-list($context, $course, $cm) = get_context_info_array($contextid);
+$context = context_system::instance();
 $PAGE->set_url('/lib/editor/atto/autosave-ajax.php');
 $PAGE->set_context($context);
 
-require_login($course, false, $cm);
-require_sesskey();
-
+require_login();
 if (isguestuser()) {
     print_error('accessdenied', 'admin');
 }
+require_sesskey();
 
 if (!in_array('atto', explode(',', get_config('core', 'texteditors')))) {
     print_error('accessdenied', 'admin');
 }
 
-$action = required_param('action', PARAM_ALPHA);
+$responses = array();
+foreach ($actions as $actionparams) {
+
+    $action = $actionparams['action'];
+    $contextid = $actionparams['contextid'];
+    $elementid = $actionparams['elementid'];
+    $pagehash = $actionparams['pagehash'];
+    $pageinstance = $actionparams['pageinstance'];
+
+    if ($action === 'save') {
+        $drafttext = $actionparams['drafttext'];
+        $params = array('elementid' => $elementid,
+                        'userid' => $USER->id,
+                        'pagehash' => $pagehash,
+                        'contextid' => $contextid);
+
+        $record = $DB->get_record('editor_atto_autosave', $params);
+        if ($record && $record->pageinstance != $pageinstance) {
+            print_error('concurrent access from the same user is not supported');
+            die();
+        }
 
-$response = array();
+        if (!$record) {
+            $record = new stdClass();
+            $record->elementid = $elementid;
+            $record->userid = $USER->id;
+            $record->pagehash = $pagehash;
+            $record->contextid = $contextid;
+            $record->drafttext = $drafttext;
+            $record->pageinstance = $pageinstance;
+            $record->timemodified = $now;
 
-if ($action === 'save') {
-    $drafttext = required_param('drafttext', PARAM_RAW);
-    $params = array('elementid' => $elementid,
-                    'userid' => $USER->id,
-                    'pagehash' => $pagehash,
-                    'contextid' => $contextid);
+            $DB->insert_record('editor_atto_autosave', $record);
 
-    $record = $DB->get_record('editor_atto_autosave', $params);
-    if ($record && $record->pageinstance != $pageinstance) {
-        print_error('concurrent access from the same user is not supported');
-        die();
-    }
+            // No response means no error.
+            $responses[] = null;
+            continue;
+        } else {
+            $record->drafttext = $drafttext;
+            $record->timemodified = time();
+            $DB->update_record('editor_atto_autosave', $record);
 
-    if (!$record) {
-        $record = new stdClass();
-        $record->elementid = $elementid;
-        $record->userid = $USER->id;
-        $record->pagehash = $pagehash;
-        $record->contextid = $contextid;
-        $record->drafttext = $drafttext;
-        $record->pageinstance = $pageinstance;
-        $record->timemodified = $now;
-
-        $DB->insert_record('editor_atto_autosave', $record);
-
-        // No response means no error.
-        die();
-    } else {
-        $record->drafttext = $drafttext;
-        $record->timemodified = time();
-        $DB->update_record('editor_atto_autosave', $record);
-
-        // No response means no error.
-        die();
-    }
-} else if ($action == 'resume') {
-    $params = array('elementid' => $elementid,
-                    'userid' => $USER->id,
-                    'pagehash' => $pagehash,
-                    'contextid' => $contextid);
-
-    $newdraftid = required_param('draftid', PARAM_INT);
-
-    $record = $DB->get_record('editor_atto_autosave', $params);
-
-    if (!$record) {
-        $record = new stdClass();
-        $record->elementid = $elementid;
-        $record->userid = $USER->id;
-        $record->pagehash = $pagehash;
-        $record->contextid = $contextid;
-        $record->pageinstance = $pageinstance;
-        $record->pagehash = $pagehash;
-        $record->draftid = $newdraftid;
-        $record->timemodified = time();
-        $record->drafttext = '';
-
-        $DB->insert_record('editor_atto_autosave', $record);
-
-        // No response means no error.
-        die();
-    } else {
-        // Copy all draft files from the old draft area.
-        $usercontext = context_user::instance($USER->id);
-        $stale = $record->timemodified < $before;
-        require_once($CFG->libdir . '/filelib.php');
-
-        $fs = get_file_storage();
-        $files = $fs->get_directory_files($usercontext->id, 'user', 'draft', $newdraftid, '/', true, true);
-
-        $lastfilemodified = 0;
-        foreach ($files as $file) {
-            $lastfilemodified = max($lastfilemodified, $file->get_timemodified());
-        }
-        if ($record->timemodified < $lastfilemodified) {
-            $stale = true;
+            // No response means no error.
+            $responses[] = null;
+            continue;
         }
 
-        if (!$stale) {
-            // This function copies all the files in one draft area, to another area (in this case it's
-            // another draft area). It also rewrites the text to @@PLUGINFILE@@ links.
-            $newdrafttext = file_save_draft_area_files($record->draftid,
-                                                       $usercontext->id,
-                                                       'user',
-                                                       'draft',
-                                                       $newdraftid,
-                                                       array(),
-                                                       $record->drafttext);
-
-            // Final rewrite to the new draft area (convert the @@PLUGINFILES@@ again).
-            $newdrafttext = file_rewrite_pluginfile_urls($newdrafttext,
-                                                         'draftfile.php',
-                                                         $usercontext->id,
-                                                         'user',
-                                                         'draft',
-                                                         $newdraftid);
-            $record->drafttext = $newdrafttext;
+    } else if ($action == 'resume') {
+        $params = array('elementid' => $elementid,
+                        'userid' => $USER->id,
+                        'pagehash' => $pagehash,
+                        'contextid' => $contextid);
+
+        $newdraftid = $actionparams['draftid'];
+
+        $record = $DB->get_record('editor_atto_autosave', $params);
 
+        if (!$record) {
+            $record = new stdClass();
+            $record->elementid = $elementid;
+            $record->userid = $USER->id;
+            $record->pagehash = $pagehash;
+            $record->contextid = $contextid;
             $record->pageinstance = $pageinstance;
+            $record->pagehash = $pagehash;
             $record->draftid = $newdraftid;
             $record->timemodified = time();
-            $DB->update_record('editor_atto_autosave', $record);
+            $record->drafttext = '';
 
-            // A response means the draft has been restored and here is the auto-saved text.
-            $response['result'] = $record->drafttext;
-            echo json_encode($response);
-        } else {
-            $DB->delete_records('editor_atto_autosave', array('id' => $record->id));
+            $DB->insert_record('editor_atto_autosave', $record);
 
             // No response means no error.
+            $responses[] = null;
+            continue;
+
+        } else {
+            // Copy all draft files from the old draft area.
+            $usercontext = context_user::instance($USER->id);
+            $stale = $record->timemodified < $before;
+            require_once($CFG->libdir . '/filelib.php');
+
+            $fs = get_file_storage();
+            $files = $fs->get_directory_files($usercontext->id, 'user', 'draft', $newdraftid, '/', true, true);
+
+            $lastfilemodified = 0;
+            foreach ($files as $file) {
+                $lastfilemodified = max($lastfilemodified, $file->get_timemodified());
+            }
+            if ($record->timemodified < $lastfilemodified) {
+                $stale = true;
+            }
+
+            if (!$stale) {
+                // This function copies all the files in one draft area, to another area (in this case it's
+                // another draft area). It also rewrites the text to @@PLUGINFILE@@ links.
+                $newdrafttext = file_save_draft_area_files($record->draftid,
+                                                           $usercontext->id,
+                                                           'user',
+                                                           'draft',
+                                                           $newdraftid,
+                                                           array(),
+                                                           $record->drafttext);
+
+                // Final rewrite to the new draft area (convert the @@PLUGINFILES@@ again).
+                $newdrafttext = file_rewrite_pluginfile_urls($newdrafttext,
+                                                             'draftfile.php',
+                                                             $usercontext->id,
+                                                             'user',
+                                                             'draft',
+                                                             $newdraftid);
+                $record->drafttext = $newdrafttext;
+
+                $record->pageinstance = $pageinstance;
+                $record->draftid = $newdraftid;
+                $record->timemodified = time();
+                $DB->update_record('editor_atto_autosave', $record);
+
+                // A response means the draft has been restored and here is the auto-saved text.
+                $response = ['result' => $record->drafttext];
+                $responses[] = $response;
+
+            } else {
+                $DB->delete_records('editor_atto_autosave', array('id' => $record->id));
+
+                // No response means no error.
+                $responses[] = null;
+            }
+            continue;
         }
-        die();
+
+    } else if ($action == 'reset') {
+        $params = array('elementid' => $elementid,
+                        'userid' => $USER->id,
+                        'pagehash' => $pagehash,
+                        'contextid' => $contextid);
+
+        $DB->delete_records('editor_atto_autosave', $params);
+        $responses[] = null;
+        continue;
     }
-} else if ($action == 'reset') {
-    $params = array('elementid' => $elementid,
-                    'userid' => $USER->id,
-                    'pagehash' => $pagehash,
-                    'contextid' => $contextid);
-
-    $DB->delete_records('editor_atto_autosave', $params);
-    die();
 }
 
-print_error('invalidarguments');
+echo json_encode($responses);
index 09ba0b7..fbcfd8b 100644 (file)
Binary files a/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-debug.js and b/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-debug.js differ
index 912122e..c8557d0 100644 (file)
Binary files a/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-min.js and b/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-min.js differ
index 27f74d3..d2b7ea5 100644 (file)
Binary files a/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor.js and b/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor.js differ
index 3984d2f..cd310b4 100644 (file)
@@ -7,6 +7,7 @@
                 "notify.js",
                 "textarea.js",
                 "autosave.js",
+                "autosave-io.js",
                 "clean.js",
                 "commands.js",
                 "toolbar.js",
diff --git a/lib/editor/atto/yui/src/editor/js/autosave-io.js b/lib/editor/atto/yui/src/editor/js/autosave-io.js
new file mode 100644 (file)
index 0000000..a37352e
--- /dev/null
@@ -0,0 +1,244 @@
+// 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/>.
+
+/**
+ * A autosave function for the Atto editor.
+ *
+ * @module     moodle-editor_atto-autosave-io
+ * @submodule  autosave-io
+ * @package    editor_atto
+ * @copyright  2016 Frédéric Massart
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+var EditorAutosaveIoDispatcherInstance = null;
+
+function EditorAutosaveIoDispatcher() {
+    EditorAutosaveIoDispatcher.superclass.constructor.apply(this, arguments);
+    this._submitEvents = {};
+    this._queue = [];
+    this._throttle = null;
+}
+EditorAutosaveIoDispatcher.NAME = 'EditorAutosaveIoDispatcher';
+EditorAutosaveIoDispatcher.ATTRS = {
+
+    /**
+     * The relative path to the ajax script.
+     *
+     * @attribute autosaveAjaxScript
+     * @type String
+     * @default '/lib/editor/atto/autosave-ajax.php'
+     * @readOnly
+     */
+    autosaveAjaxScript: {
+        value: '/lib/editor/atto/autosave-ajax.php',
+        readOnly: true
+    },
+
+    /**
+     * The time buffer for the throttled requested.
+     *
+     * @attribute delay
+     * @type Number
+     * @default 50
+     * @readOnly
+     */
+    delay: {
+        value: 50,
+        readOnly: true
+    }
+
+};
+Y.extend(EditorAutosaveIoDispatcher, Y.Base, {
+
+    /**
+     * Dispatch an IO request.
+     *
+     * This method will put the requests in a queue in order to attempt to bulk them.
+     *
+     * @param  {Object} params    The parameters of the request.
+     * @param  {Object} context   The context in which the callbacks are called.
+     * @param  {Object} callbacks Object with 'success', 'complete', 'end', 'failure' and 'start' as
+     *                            optional keys defining the callbacks to call. Success and Complete
+     *                            functions will receive the response as parameter. Success and Complete
+     *                            may receive an object containing the error key, use this to confirm
+     *                            that no errors occured.
+     * @return {Void}
+     */
+    dispatch: function(params, context, callbacks) {
+        if (this._throttle) {
+            this._throttle.cancel();
+        }
+
+        this._throttle = Y.later(this.get('delay'), this, this._processDispatchQueue);
+        this._queue.push([params, context, callbacks]);
+    },
+
+    /**
+     * Dispatches the requests in the queue.
+     *
+     * @return {Void}
+     */
+    _processDispatchQueue: function() {
+        var queue = this._queue,
+            data = {};
+
+        this._queue = [];
+        if (queue.length < 1) {
+            return;
+        }
+
+        Y.Array.each(queue, function(item, index) {
+            data[index] = item[0];
+        });
+
+        Y.io(M.cfg.wwwroot + this.get('autosaveAjaxScript'), {
+            method: 'POST',
+            data: Y.QueryString.stringify({
+                actions: data,
+                sesskey: M.cfg.sesskey
+            }),
+            on: {
+                start: this._makeIoEventCallback('start', queue),
+                complete: this._makeIoEventCallback('complete', queue),
+                failure: this._makeIoEventCallback('failure', queue),
+                end: this._makeIoEventCallback('end', queue),
+                success: this._makeIoEventCallback('success', queue)
+            }
+        });
+    },
+
+    /**
+     * Creates a function that dispatches an IO response to callbacks.
+     *
+     * @param  {String} event The type of event.
+     * @param  {Array} queue The queue.
+     * @return {Function}
+     */
+    _makeIoEventCallback: function(event, queue) {
+        var noop = function() {};
+        return function() {
+            var response = arguments[1],
+                parsed = {};
+
+            if ((event == 'complete' || event == 'success') && (typeof response !== 'undefined'
+                    && typeof response.responseText !== 'undefined' && response.responseText !== '')) {
+
+                // Success and complete events need to parse the response.
+                parsed = JSON.parse(response.responseText) || {};
+            }
+
+            Y.Array.each(queue, function(item, index) {
+                var context = item[1],
+                    cb = (item[2] && item[2][event]) || noop,
+                    arg;
+
+                if (parsed && parsed.error) {
+                    // The response is an error, we send it to everyone.
+                    arg = parsed;
+                } else if (parsed) {
+                    // The response was parsed, we only communicate the relevant portion of the response.
+                    arg = parsed[index];
+                }
+
+                cb.apply(context, [arg]);
+            });
+        };
+    },
+
+    /**
+     * Form submit handler.
+     *
+     * @param  {EventFacade} e The event.
+     * @return {Void}
+     */
+    _onSubmit: function(e) {
+        var data = {},
+            id = e.currentTarget.generateID(),
+            params = this._submitEvents[id];
+
+        if (!params || params.ios.length < 1) {
+            return;
+        }
+
+        Y.Array.each(params.ios, function(param, index) {
+            data[index] = param;
+        });
+
+        Y.io(M.cfg.wwwroot + this.get('autosaveAjaxScript'), {
+            method: 'POST',
+            data: Y.QueryString.stringify({
+                actions: data,
+                sesskey: M.cfg.sesskey
+            }),
+            sync: true
+        });
+    },
+
+    /**
+     * Registers a request to be made on form submission.
+     *
+     * @param  {Node} node The forum node we will listen to.
+     * @param  {Object} params Parameters for the IO request.
+     * @return {Void}
+     */
+    whenSubmit: function(node, params) {
+        if (typeof this._submitEvents[node.generateID()] === 'undefined') {
+            this._submitEvents[node.generateID()] = {
+                event: node.on('submit', this._onSubmit, this),
+                ios: []
+            };
+        }
+        this._submitEvents[node.get('id')].ios.push([params]);
+    }
+
+});
+EditorAutosaveIoDispatcherInstance = new EditorAutosaveIoDispatcher();
+
+
+function EditorAutosaveIo() {}
+EditorAutosaveIo.prototype = {
+
+    /**
+     * Dispatch an IO request.
+     *
+     * This method will put the requests in a queue in order to attempt to bulk them.
+     *
+     * @param  {Object} params    The parameters of the request.
+     * @param  {Object} context   The context in which the callbacks are called.
+     * @param  {Object} callbacks Object with 'success', 'complete', 'end', 'failure' and 'start' as
+     *                            optional keys defining the callbacks to call. Success and Complete
+     *                            functions will receive the response as parameter. Success and Complete
+     *                            may receive an object containing the error key, use this to confirm
+     *                            that no errors occured.
+     * @return {Void}
+     */
+    autosaveIo: function(params, context, callbacks) {
+        EditorAutosaveIoDispatcherInstance.dispatch(params, context, callbacks);
+    },
+
+    /**
+     * Registers a request to be made on form submission.
+     *
+     * @param  {Node} form The forum node we will listen to.
+     * @param  {Object} params Parameters for the IO request.
+     * @return {Void}
+     */
+    autosaveIoOnSubmit: function(form, params) {
+        EditorAutosaveIoDispatcherInstance.whenSubmit(form, params);
+    }
+
+};
+Y.Base.mix(Y.M.editor_atto.Editor, [EditorAutosaveIo]);
index 4dd4ed4..27f227a 100644 (file)
@@ -65,19 +65,6 @@ EditorAutosave.ATTRS= {
     pageHash: {
         value: '',
         writeOnce: true
-    },
-
-    /**
-     * The relative path to the ajax script.
-     *
-     * @attribute autosaveAjaxScript
-     * @type String
-     * @default '/lib/editor/atto/autosave-ajax.php'
-     * @readOnly
-     */
-    autosaveAjaxScript: {
-        value: '/lib/editor/atto/autosave-ajax.php',
-        readOnly: true
     }
 };
 
@@ -118,8 +105,7 @@ EditorAutosave.prototype = {
             form,
             optiontype = null,
             options = this.get('filepickeroptions'),
-            params,
-            url;
+            params;
 
         if (!this.get('autosaveEnabled')) {
             // Autosave disabled for this instance.
@@ -135,99 +121,73 @@ EditorAutosave.prototype = {
 
         // First see if there are any saved drafts.
         // Make an ajax request.
-        url = M.cfg.wwwroot + this.get('autosaveAjaxScript');
         params = {
-            sesskey: M.cfg.sesskey,
             contextid: this.get('contextid'),
             action: 'resume',
-            drafttext: '',
             draftid: draftid,
             elementid: this.get('elementid'),
             pageinstance: this.autosaveInstance,
             pagehash: this.get('pageHash')
         };
 
-        Y.io(url, {
-            method: 'POST',
-            data: params,
-            context: this,
-            on: {
-                success: function(id,o) {
-                    var response_json;
-                    if (typeof o.responseText !== "undefined" && o.responseText !== "") {
-                        response_json = JSON.parse(o.responseText);
+        this.autosaveIo(params, this, {
+            success: function(response) {
+                if (response === null) {
+                    // This can happen when there is nothing to resume from.
+                    return;
+                } else if (!response) {
+                    Y.log('Invalid response received.', 'debug', LOGNAME_AUTOSAVE);
+                    return;
+                }
 
-                        // Revert untouched editor contents to an empty string.
-                        // Check for FF and Chrome.
-                        if (response_json.result === '<p></p>' || response_json.result === '<p><br></p>' ||
-                            response_json.result === '<br>') {
-                            response_json.result = '';
-                        }
+                // Revert untouched editor contents to an empty string.
+                // Check for FF and Chrome.
+                if (response.result === '<p></p>' || response.result === '<p><br></p>' ||
+                    response.result === '<br>') {
+                    response.result = '';
+                }
 
-                        // Check for IE 9 and 10.
-                        if (response_json.result === '<p>&nbsp;</p>' || response_json.result === '<p><br>&nbsp;</p>') {
-                            response_json.result = '';
-                        }
+                // Check for IE 9 and 10.
+                if (response.result === '<p>&nbsp;</p>' || response.result === '<p><br>&nbsp;</p>') {
+                    response.result = '';
+                }
 
-                        if (response_json.error || typeof response_json.result === 'undefined') {
-                            Y.log('Error occurred recovering draft text: ' + response_json.error, 'debug', LOGNAME_AUTOSAVE);
-                            this.showMessage(M.util.get_string('errortextrecovery', 'editor_atto'),
-                                    NOTIFY_WARNING, RECOVER_MESSAGE_TIMEOUT);
-                        } else if (response_json.result !== this.textarea.get('value') &&
-                                response_json.result !== '') {
-                            Y.log('Autosave text found - recover it.', 'debug', LOGNAME_AUTOSAVE);
-                            this.recoverText(response_json.result);
-                        }
-                        this._fireSelectionChanged();
-                    }
-                },
-                failure: function() {
+                if (response.error || typeof response.result === 'undefined') {
+                    Y.log('Error occurred recovering draft text: ' + response.error, 'debug', LOGNAME_AUTOSAVE);
                     this.showMessage(M.util.get_string('errortextrecovery', 'editor_atto'),
                             NOTIFY_WARNING, RECOVER_MESSAGE_TIMEOUT);
+                } else if (response.result !== this.textarea.get('value') &&
+                        response.result !== '') {
+                    Y.log('Autosave text found - recover it.', 'debug', LOGNAME_AUTOSAVE);
+                    this.recoverText(response.result);
                 }
+                this._fireSelectionChanged();
+
+            },
+            failure: function() {
+                this.showMessage(M.util.get_string('errortextrecovery', 'editor_atto'),
+                        NOTIFY_WARNING, RECOVER_MESSAGE_TIMEOUT);
             }
         });
 
         // Now setup the timer for periodic saves.
-
         var delay = parseInt(this.get('autosaveFrequency'), 10) * 1000;
         this.autosaveTimer = Y.later(delay, this, this.saveDraft, false, true);
 
         // Now setup the listener for form submission.
         form = this.textarea.ancestor('form');
         if (form) {
-            form.on('submit', this.resetAutosave, this);
+            this.autosaveIoOnSubmit(form, {
+                action: 'reset',
+                contextid: this.get('contextid'),
+                elementid: this.get('elementid'),
+                pageinstance: this.autosaveInstance,
+                pagehash: this.get('pageHash')
+            });
         }
         return this;
     },
 
-    /**
-     * Clear the autosave text because the form was submitted normally.
-     *
-     * @method resetAutosave
-     * @chainable
-     */
-    resetAutosave: function() {
-        // Make an ajax request to reset the autosaved text.
-        var url = M.cfg.wwwroot + this.get('autosaveAjaxScript');
-        var params = {
-            sesskey: M.cfg.sesskey,
-            contextid: this.get('contextid'),
-            action: 'reset',
-            elementid: this.get('elementid'),
-            pageinstance: this.autosaveInstance,
-            pagehash: this.get('pageHash')
-        };
-
-        Y.io(url, {
-            method: 'POST',
-            data: params,
-            sync: true
-        });
-        return this;
-    },
-
-
     /**
      * Recover a previous version of this text and show a message.
      *
@@ -283,29 +243,23 @@ EditorAutosave.prototype = {
             };
 
             // Reusable error handler - must be passed the correct context.
-            var ajaxErrorFunction = function(code, response) {
+            var ajaxErrorFunction = function(response) {
                 var errorDuration = parseInt(this.get('autosaveFrequency'), 10) * 1000;
-                Y.log('Error while autosaving text:' + code, 'warn', LOGNAME_AUTOSAVE);
+                Y.log('Error while autosaving text', 'warn', LOGNAME_AUTOSAVE);
                 Y.log(response, 'warn', LOGNAME_AUTOSAVE);
                 this.showMessage(M.util.get_string('autosavefailed', 'editor_atto'), NOTIFY_WARNING, errorDuration);
             };
 
-            Y.io(url, {
-                method: 'POST',
-                data: params,
-                context: this,
-                on: {
-                    error: ajaxErrorFunction,
-                    failure: ajaxErrorFunction,
-                    success: function(code, response) {
-                        if (response.responseText !== "") {
-                            Y.soon(Y.bind(ajaxErrorFunction, this, [code, response]));
-                        } else {
-                            // All working.
-                            this.lastText = newText;
-                            this.showMessage(M.util.get_string('autosavesucceeded', 'editor_atto'),
-                                    NOTIFY_INFO, SUCCESS_MESSAGE_TIMEOUT);
-                        }
+            this.autosaveIo(params, this, {
+                failure: ajaxErrorFunction,
+                success: function(response) {
+                    if (response && response.error) {
+                        Y.soon(Y.bind(ajaxErrorFunction, this, [response]));
+                    } else {
+                        // All working.
+                        this.lastText = newText;
+                        this.showMessage(M.util.get_string('autosavesucceeded', 'editor_atto'),
+                                NOTIFY_INFO, SUCCESS_MESSAGE_TIMEOUT);
                     }
                 }
             });
index a8f9ff3..65750e3 100644 (file)
@@ -15,7 +15,8 @@
             "moodle-core-notification-confirm",
             "moodle-editor_atto-rangy",
             "handlebars",
-            "timers"
+            "timers",
+            "querystring-stringify"
         ]
     },
     "moodle-editor_atto-plugin": {
index 3a66e69..7f9bab6 100644 (file)
@@ -128,6 +128,9 @@ class MoodleQuickForm_tags extends MoodleQuickForm_autocomplete {
      */
     public function onQuickFormEvent($event, $arg, &$caller) {
         if ($event === 'createElement') {
+            if (!is_array($arg[2])) {
+                $arg[2] = [];
+            }
             $arg[2] += array('itemtype' => '', 'component' => '');
         }
         return parent::onQuickFormEvent($event, $arg, $caller);
index e397770..119091a 100644 (file)
@@ -90,9 +90,9 @@ function core_myprofile_navigation(core_user\output\myprofile\tree $tree, $user,
                 $url = $userauthplugin->edit_profile_url();
                 if (empty($url)) {
                     if (empty($course)) {
-                        $url = new moodle_url('/user/edit.php', array('userid' => $user->id, 'returnto' => 'profile'));
+                        $url = new moodle_url('/user/edit.php', array('id' => $user->id, 'returnto' => 'profile'));
                     } else {
-                        $url = new moodle_url('/user/edit.php', array('userid' => $user->id, 'course' => $course->id,
+                        $url = new moodle_url('/user/edit.php', array('id' => $user->id, 'course' => $course->id,
                             'returnto' => 'profile'));
                     }
                 }
index af0c977..d0d5b23 100644 (file)
@@ -566,7 +566,7 @@ class core_renderer extends renderer_base {
     public function standard_top_of_body_html() {
         global $CFG;
         $output = $this->page->requires->get_top_of_body_code();
-        if (!empty($CFG->additionalhtmltopofbody)) {
+        if ($this->page->pagelayout !== 'embedded' && !empty($CFG->additionalhtmltopofbody)) {
             $output .= "\n".$CFG->additionalhtmltopofbody;
         }
         $output .= $this->maintenance_warning();
@@ -689,7 +689,7 @@ class core_renderer extends renderer_base {
         // but some of the content won't be known until later, so we return a placeholder
         // for now. This will be replaced with the real content in {@link core_renderer::footer()}.
         $output = '';
-        if (!empty($CFG->additionalhtmlfooter)) {
+        if ($this->page->pagelayout !== 'embedded' && !empty($CFG->additionalhtmlfooter)) {
             $output .= "\n".$CFG->additionalhtmlfooter;
         }
         $output .= $this->unique_end_html_token;
index 0179887..0962a24 100644 (file)
@@ -220,6 +220,37 @@ function match_grade_options($gradeoptionsfull, $grade, $matchgrades = 'error')
     }
 }
 
+/**
+ * Remove stale questions from a category.
+ *
+ * While questions should not be left behind when they are not used any more,
+ * it does happen, maybe via restore, or old logic, or uncovered scenarios. When
+ * this happens, the users are unable to delete the question category unless
+ * they move those stale questions to another one category, but to them the
+ * category is empty as it does not contain anything. The purpose of this function
+ * is to detect the questions that may have gone stale and remove them.
+ *
+ * You will typically use this prior to checking if the category contains questions.
+ *
+ * The stale questions (unused and hidden to the user) handled are:
+ * - hidden questions
+ * - random questions
+ *
+ * @param int $categoryid The category ID.
+ */
+function question_remove_stale_questions_from_category($categoryid) {
+    global $DB;
+
+    $select = 'category = :categoryid AND (qtype = :qtype OR hidden = :hidden)';
+    $params = ['categoryid' => $categoryid, 'qtype' => 'random', 'hidden' => 1];
+    $questions = $DB->get_recordset_select("question", $select, $params, '', 'id');
+    foreach ($questions as $question) {
+        // The function question_delete_question does not delete questions in use.
+        question_delete_question($question->id);
+    }
+    $questions->close();
+}
+
 /**
  * Category is about to be deleted,
  * 1/ All questions are deleted for this question category.
index c2a9862..0b95413 100644 (file)
@@ -429,8 +429,19 @@ class behat_hooks extends behat_base {
             return false;
         }
 
-        list ($dir, $filename) = $this->get_faildump_filename($scope, 'png');
-        $this->saveScreenshot($filename, $dir);
+        // Some drivers (e.g. chromedriver) may throw an exception while trying to take a screenshot.  If this isn't handled,
+        // the behat run dies.  We don't want to lose the information about the failure that triggered the screenshot,
+        // so let's log the exception message to a file (to explain why there's no screenshot) and allow the run to continue,
+        // handling the failure as normal.
+        try {
+            list ($dir, $filename) = $this->get_faildump_filename($scope, 'png');
+            $this->saveScreenshot($filename, $dir);
+        } catch (Exception $e) {
+            // Catching all exceptions as we don't know what the driver might throw.
+            list ($dir, $filename) = $this->get_faildump_filename($scope, 'txt');
+            $message = "Could not save screenshot due to an error\n" . $e->getMessage();
+            file_put_contents($dir . DIRECTORY_SEPARATOR . $filename, $message);
+        }
     }
 
     /**
@@ -442,9 +453,14 @@ class behat_hooks extends behat_base {
     protected function take_contentdump(AfterStepScope $scope) {
         list ($dir, $filename) = $this->get_faildump_filename($scope, 'html');
 
-        $fh = fopen($dir . DIRECTORY_SEPARATOR . $filename, 'w');
-        fwrite($fh, $this->getSession()->getPage()->getContent());
-        fclose($fh);
+        try {
+            // Driver may throw an exception during getContent(), so do it first to avoid getting an empty file.
+            $content = $this->getSession()->getPage()->getContent();
+        } catch (Exception $e) {
+            // Catching all exceptions as we don't know what the driver might throw.
+            $content = "Could not save contentdump due to an error\n" . $e->getMessage();
+        }
+        file_put_contents($dir . DIRECTORY_SEPARATOR . $filename, $content);
     }
 
     /**
index 969e487..b699f72 100644 (file)
@@ -393,4 +393,54 @@ class core_questionlib_testcase extends advanced_testcase {
         $criteria = array('category' => $qcat->id);
         $this->assertEquals(0, $DB->count_records('question', $criteria));
     }
+
+    public function test_question_remove_stale_questions_from_category() {
+        global $DB;
+        $this->resetAfterTest(true);
+        $dg = $this->getDataGenerator();
+        $course = $dg->create_course();
+        $quiz = $dg->create_module('quiz', ['course' => $course->id]);
+
+        $qgen = $dg->get_plugin_generator('core_question');
+        $context = context_system::instance();
+
+        $qcat1 = $qgen->create_question_category(['contextid' => $context->id]);
+        $q1a = $qgen->create_question('shortanswer', null, ['category' => $qcat1->id]);     // Will be hidden.
+        $q1b = $qgen->create_question('random', null, ['category' => $qcat1->id]);          // Will not be used.
+        $DB->set_field('question', 'hidden', 1, ['id' => $q1a->id]);
+
+        $qcat2 = $qgen->create_question_category(['contextid' => $context->id]);
+        $q2a = $qgen->create_question('shortanswer', null, ['category' => $qcat2->id]);     // Will be hidden.
+        $q2b = $qgen->create_question('shortanswer', null, ['category' => $qcat2->id]);     // Will be hidden but used.
+        $q2c = $qgen->create_question('random', null, ['category' => $qcat2->id]);          // Will not be used.
+        $q2d = $qgen->create_question('random', null, ['category' => $qcat2->id]);          // Will be used.
+        $DB->set_field('question', 'hidden', 1, ['id' => $q2a->id]);
+        $DB->set_field('question', 'hidden', 1, ['id' => $q2b->id]);
+        quiz_add_quiz_question($q2b->id, $quiz);
+        quiz_add_quiz_question($q2d->id, $quiz);
+
+        $this->assertEquals(2, $DB->count_records('question', ['category' => $qcat1->id]));
+        $this->assertEquals(4, $DB->count_records('question', ['category' => $qcat2->id]));
+
+        // Non-existing category, nothing will happen.
+        question_remove_stale_questions_from_category(0);
+        $this->assertEquals(2, $DB->count_records('question', ['category' => $qcat1->id]));
+        $this->assertEquals(4, $DB->count_records('question', ['category' => $qcat2->id]));
+
+        // First category, should be empty afterwards.
+        question_remove_stale_questions_from_category($qcat1->id);
+        $this->assertEquals(0, $DB->count_records('question', ['category' => $qcat1->id]));
+        $this->assertEquals(4, $DB->count_records('question', ['category' => $qcat2->id]));
+        $this->assertFalse($DB->record_exists('question', ['id' => $q1a->id]));
+        $this->assertFalse($DB->record_exists('question', ['id' => $q1b->id]));
+
+        // Second category, used questions should be left untouched.
+        question_remove_stale_questions_from_category($qcat2->id);
+        $this->assertEquals(0, $DB->count_records('question', ['category' => $qcat1->id]));
+        $this->assertEquals(2, $DB->count_records('question', ['category' => $qcat2->id]));
+        $this->assertFalse($DB->record_exists('question', ['id' => $q2a->id]));
+        $this->assertTrue($DB->record_exists('question', ['id' => $q2b->id]));
+        $this->assertFalse($DB->record_exists('question', ['id' => $q2c->id]));
+        $this->assertTrue($DB->record_exists('question', ['id' => $q2d->id]));
+    }
 }
index deecb6f..d0be09f 100644 (file)
@@ -95,4 +95,67 @@ class core_weblib_format_text_testcase extends advanced_testcase {
         $this->assertEquals('<div class="no-overflow"><p>:-)</p></div>',
                 format_text('<p>:-)</p>', FORMAT_HTML, array('overflowdiv' => true)));
     }
+
+    /**
+     * Test adding blank target attribute to links
+     *
+     * @dataProvider format_text_blanktarget_testcases
+     * @param string $link The link to add target="_blank" to
+     * @param string $expected The expected filter value
+     */
+    public function test_format_text_blanktarget($link, $expected) {
+        $actual = format_text($link, FORMAT_MOODLE, array('blanktarget' => true, 'filter' => false, 'noclean' => true));
+        $this->assertEquals($expected, $actual);
+    }
+
+    /**
+     * Data provider for the test_format_text_blanktarget testcase
+     *
+     * @return array of testcases
+     */
+    public function format_text_blanktarget_testcases() {
+        return [
+            'Simple link' => [
+                '<a href="https://www.youtube.com/watch?v=JeimE8Wz6e4">Hey, that\'s pretty good!</a>',
+                '<div class="text_to_html"><a href="https://www.youtube.com/watch?v=JeimE8Wz6e4" target="_blank"' .
+                    ' rel="noreferrer">Hey, that\'s pretty good!</a></div>'
+            ],
+            'Link with rel' => [
+                '<a href="https://www.youtube.com/watch?v=JeimE8Wz6e4" rel="nofollow">Hey, that\'s pretty good!</a>',
+                '<div class="text_to_html"><a href="https://www.youtube.com/watch?v=JeimE8Wz6e4" rel="nofollow noreferrer"' .
+                    ' target="_blank">Hey, that\'s pretty good!</a></div>'
+            ],
+            'Link with rel noreferrer' => [
+                '<a href="https://www.youtube.com/watch?v=JeimE8Wz6e4" rel="noreferrer">Hey, that\'s pretty good!</a>',
+                '<div class="text_to_html"><a href="https://www.youtube.com/watch?v=JeimE8Wz6e4" rel="noreferrer"' .
+                 ' target="_blank">Hey, that\'s pretty good!</a></div>'
+            ],
+            'Link with target' => [
+                '<a href="https://www.youtube.com/watch?v=JeimE8Wz6e4" target="_self">Hey, that\'s pretty good!</a>',
+                '<div class="text_to_html"><a href="https://www.youtube.com/watch?v=JeimE8Wz6e4" target="_self">' .
+                    'Hey, that\'s pretty good!</a></div>'
+            ],
+            'Link with target blank' => [
+                '<a href="https://www.youtube.com/watch?v=JeimE8Wz6e4" target="_blank">Hey, that\'s pretty good!</a>',
+                '<div class="text_to_html"><a href="https://www.youtube.com/watch?v=JeimE8Wz6e4" target="_blank"' .
+                    ' rel="noreferrer">Hey, that\'s pretty good!</a></div>'
+            ],
+            'Link with Frank\'s casket inscription' => [
+                '<a href="https://en.wikipedia.org/wiki/Franks_Casket">ᚠᛁᛋᚳ᛫ᚠᛚᚩᛞᚢ᛫ᚪᚻᚩᚠᚩᚾᚠᛖᚱᚷ ᛖᚾᛒᛖᚱᛁᚷ ᚹᚪᚱᚦᚷᚪ᛬ᛋᚱᛁᚳᚷᚱᚩᚱᚾᚦᚫᚱᚻᛖᚩᚾᚷᚱᛖᚢᛏᚷᛁᛋᚹᚩᛗ ᚻ' .
+                    'ᚱᚩᚾᚫᛋᛒᚪᚾ ᛗᚫᚷᛁᚠᛁᛋᚳ᛫ᚠᛚᚩᛞᚢ᛫ᚪᚻᚩᚠᚩᚾᚠᛖᚱᚷ ᛖᚾᛒᛖᚱᛁᚷ ᚹᚪᚱᚦᚷᚪ᛬ᛋᚱᛁᚳᚷᚱᚩᚱᚾᚦᚫᚱᚻᛖᚩᚾᚷᚱᛖᚢᛏᚷᛁᛋᚹᚩᛗ ᚻᚱᚩᚾᚫᛋᛒᚪᚾ ᛗᚫᚷᛁ</a>',
+                '<div class="text_to_html"><a href="https://en.wikipedia.org/wiki/Franks_Casket" target="_blank" ' .
+                    'rel="noreferrer">ᚠᛁᛋᚳ᛫ᚠᛚᚩᛞᚢ᛫ᚪᚻᚩᚠᚩᚾᚠᛖᚱᚷ ᛖᚾᛒᛖᚱᛁᚷ ᚹᚪᚱᚦᚷᚪ᛬ᛋᚱᛁᚳᚷᚱᚩᚱᚾᚦᚫᚱᚻᛖᚩᚾᚷᚱᛖᚢᛏᚷᛁᛋᚹᚩᛗ ᚻᚱᚩᚾᚫᛋᛒᚪᚾ ᛗᚫᚷᛁᚠᛁᛋᚳ᛫ᚠᛚᚩᛞᚢ᛫ᚪᚻᚩᚠᚩᚾᚠᛖᚱᚷ ᛖᚾ' .
+                    'ᛒᛖᚱᛁᚷ ᚹᚪᚱᚦᚷᚪ᛬ᛋᚱᛁᚳᚷᚱᚩᚱᚾᚦᚫᚱᚻᛖᚩᚾᚷᚱᛖᚢᛏᚷᛁᛋᚹᚩᛗ ᚻᚱᚩᚾᚫᛋᛒᚪᚾ ᛗᚫᚷᛁ</a></div>'
+             ],
+            'No link' => [
+                'Some very boring text written with the Latin script',
+                '<div class="text_to_html">Some very boring text written with the Latin script</div>'
+            ],
+            'No link with Thror\'s map runes' => [
+                'ᛋᛏᚫᚾᛞ ᛒᚣ ᚦᛖ ᚷᚱᛖᚣ ᛋᛏᚩᚾᛖ ᚻᚹᛁᛚᛖ ᚦᛖ ᚦᚱᚢᛋᚻ ᚾᚩᚳᛋ ᚫᚾᛞ ᚦᛖ ᛋᛖᛏᛏᛁᚾᚷ ᛋᚢᚾ ᚹᛁᚦ ᚦᛖ ᛚᚫᛋᛏ ᛚᛁᚷᚻᛏ ᚩᚠ ᛞᚢᚱᛁᚾᛋ ᛞᚫᚣ ᚹᛁᛚᛚ ᛋᚻᛁᚾᛖ ᚢᛈᚩᚾ ᚦᛖ ᚳᛖᚣᚻᚩᛚᛖ',
+                '<div class="text_to_html">ᛋᛏᚫᚾᛞ ᛒᚣ ᚦᛖ ᚷᚱᛖᚣ ᛋᛏᚩᚾᛖ ᚻᚹᛁᛚᛖ ᚦᛖ ᚦᚱᚢᛋᚻ ᚾᚩᚳᛋ ᚫᚾᛞ ᚦᛖ ᛋᛖᛏᛏᛁᚾᚷ ᛋᚢᚾ ᚹᛁᚦ ᚦᛖ ᛚᚫᛋᛏ ᛚᛁᚷᚻᛏ ᚩᚠ ᛞᚢᚱᛁᚾᛋ ᛞᚫᚣ ᚹ' .
+                'ᛁᛚᛚ ᛋᚻᛁᚾᛖ ᚢᛈᚩᚾ ᚦᛖ ᚳᛖᚣᚻᚩᛚᛖ</div>'
+            ]
+        ];
+    }
 }
index 3326bd0..7218a1e 100644 (file)
@@ -1,6 +1,10 @@
 This files describes API changes in core libraries and APIs,
 information provided here is intended especially for developers.
 
+=== 3.2 ===
+
+* New option 'blanktarget' added to format_text. This option adds target="_blank" to links
+
 === 3.1 ===
 
 * Webservice function core_course_search_courses accepts a new parameter 'limittoenrolled' to filter the results
index fe5b2c3..c86dc0e 100644 (file)
@@ -760,7 +760,6 @@ class moodle_url {
         if ($forcedownload) {
             $params['forcedownload'] = 1;
         }
-        $path = rtrim($path, '/');
         $url = new moodle_url($urlbase, $params);
         $url->set_slashargument($path);
         return $url;
@@ -1175,6 +1174,7 @@ function format_text_menu() {
  *                      with the class no-overflow before being returned. Default false.
  *      allowid     :   If true then id attributes will not be removed, even when
  *                      using htmlpurifier. Default false.
+ *      blanktarget :   If true all <a> tags will have target="_blank" added unless target is explicitly specified.
  * </pre>
  *
  * @staticvar array $croncache
@@ -1222,6 +1222,7 @@ function format_text($text, $format = FORMAT_MOODLE, $options = null, $courseidd
     if (!isset($options['overflowdiv'])) {
         $options['overflowdiv'] = false;
     }
+    $options['blanktarget'] = !empty($options['blanktarget']);
 
     // Calculate best context.
     if (empty($CFG->version) or $CFG->version < 2013051400 or during_initial_install()) {
@@ -1318,6 +1319,26 @@ function format_text($text, $format = FORMAT_MOODLE, $options = null, $courseidd
         $text = html_writer::tag('div', $text, array('class' => 'no-overflow'));
     }
 
+    if ($options['blanktarget']) {
+        $domdoc = new DOMDocument();
+        $domdoc->loadHTML('<?xml version="1.0" encoding="UTF-8" ?>' . $text);
+        foreach ($domdoc->getElementsByTagName('a') as $link) {
+            if ($link->hasAttribute('target') && strpos($link->getAttribute('target'), '_blank') === false) {
+                continue;
+            }
+            $link->setAttribute('target', '_blank');
+            if (strpos($link->getAttribute('rel'), 'noreferrer') === false) {
+                $link->setAttribute('rel', trim($link->getAttribute('rel') . ' noreferrer'));
+            }
+        }
+
+        // This regex is nasty and I don't like it. The correct way to solve this is by loading the HTML like so:
+        // $domdoc->loadHTML($text, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); however it seems like the libxml
+        // version that travis uses doesn't work properly and ends up leaving <html><body>, so I'm forced to use
+        // this regex to remove those tags.
+        $text = trim(preg_replace('~<(?:!DOCTYPE|/?(?:html|body))[^>]*>\s*~i', '', $domdoc->saveHTML($domdoc->documentElement)));
+    }
+
     return $text;
 }
 
index bd12367..6d060fb 100644 (file)
Binary files a/lib/yui/build/moodle-core-dock/moodle-core-dock-debug.js and b/lib/yui/build/moodle-core-dock/moodle-core-dock-debug.js differ
index 293b9c1..a6e9b29 100644 (file)
Binary files a/lib/yui/build/moodle-core-dock/moodle-core-dock-min.js and b/lib/yui/build/moodle-core-dock/moodle-core-dock-min.js differ
index 7e6f8cd..35bb1e3 100644 (file)
Binary files a/lib/yui/build/moodle-core-dock/moodle-core-dock.js and b/lib/yui/build/moodle-core-dock/moodle-core-dock.js differ
index ca46f73..75d33c1 100644 (file)
Binary files a/lib/yui/build/moodle-core-event/moodle-core-event-debug.js and b/lib/yui/build/moodle-core-event/moodle-core-event-debug.js differ
index ced026e..7a13d0d 100644 (file)
Binary files a/lib/yui/build/moodle-core-event/moodle-core-event-min.js and b/lib/yui/build/moodle-core-event/moodle-core-event-min.js differ
index 6a18fe6..a54d395 100644 (file)
Binary files a/lib/yui/build/moodle-core-event/moodle-core-event.js and b/lib/yui/build/moodle-core-event/moodle-core-event.js differ
index 8e03abc..e16d3e4 100644 (file)
@@ -62,6 +62,9 @@ M.core.dock._dockableblocks = {};
  */
 M.core.dock.init = function() {
     Y.all(SELECTOR.dockableblock).each(M.core.dock.registerDockableBlock);
+    Y.Global.on(M.core.globalEvents.BLOCK_CONTENT_UPDATED, function(e) {
+        M.core.dock.notifyBlockChange(e.instanceid);
+    }, this);
     BODY.delegate('click', M.core.dock.dockBlock, SELECTOR.blockmoveto);
     BODY.delegate('key', M.core.dock.dockBlock, SELECTOR.blockmoveto, 'enter');
 };
index 9f88e2a..7616fbd 100644 (file)
@@ -7,7 +7,8 @@
         "event-mouseenter",
         "event-resize",
         "escape",
-        "moodle-core-dock-loader"
+        "moodle-core-dock-loader",
+        "moodle-core-event"
     ]
   },
   "moodle-core-dock-loader": {
index 5a51c2a..f30f900 100644 (file)
@@ -50,7 +50,15 @@ M.core.globalEvents = M.core.globalEvents || {
      * @param formid {string} Id of form with error.
      * @param elementid {string} Id of element with error.
      */
-    FORM_ERROR: "form_error"
+    FORM_ERROR: "form_error",
+
+    /**
+     * This event is triggered when the content of a block has changed
+     *
+     * @event "block_content_updated"
+     * @param instanceid ID of the block instance that was updated
+     */
+    BLOCK_CONTENT_UPDATED: "block_content_updated"
 };
 
 
index 88d0a5f..6702c3b 100644 (file)
@@ -137,7 +137,8 @@ class core_message_external extends external_api {
             if ($success && empty($contactlist[$message['touserid']]) && !empty($blocknoncontacts)) {
                 // The user isn't a contact and they have selected to block non contacts so this message won't be sent.
                 $success = false;
-                $errormessage = get_string('userisblockingyounoncontact', 'message');
+                $errormessage = get_string('userisblockingyounoncontact', 'message',
+                        fullname(core_user::get_user($message['touserid'])));
             }
 
             //now we can send the message (at least try)
index 282d7e9..3904025 100644 (file)
@@ -983,6 +983,7 @@ function message_format_message_text($message, $forcetexttohtml = false) {
 
     $options = new stdClass();
     $options->para = false;
+    $options->blanktarget = true;
 
     $format = $message->fullmessageformat;
 
index 347dbdf..d872488 100644 (file)
Binary files a/mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-debug.js and b/mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-debug.js differ
index 084e92b..ebc14b8 100644 (file)
Binary files a/mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-min.js and b/mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-min.js differ
index 347dbdf..d872488 100644 (file)
Binary files a/mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor.js and b/mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor.js differ
index d4597ec..9d1c646 100644 (file)
@@ -42,7 +42,7 @@ Y.extend(ANNOTATIONSTAMP, M.assignfeedback_editpdf.annotation, {
      */
     draw : function() {
         var drawable = new M.assignfeedback_editpdf.drawable(this.editor),
-            drawingregion = this.editor.get_dialogue_element(SELECTOR.DRAWINGREGION),
+            drawingcanvas = this.editor.get_dialogue_element(SELECTOR.DRAWINGCANVAS),
             node,
             position;
 
@@ -58,16 +58,10 @@ Y.extend(ANNOTATIONSTAMP, M.assignfeedback_editpdf.annotation, {
             'zIndex': 50
         });
 
-        drawingregion.append(node);
+        drawingcanvas.append(node);
         node.setX(position.x);
         node.setY(position.y);
         drawable.store_position(node, position.x, position.y);
-
-        // Pass throught the event handlers on the div.
-        node.on('gesturemovestart', this.editor.edit_start, null, this.editor);
-        node.on('gesturemove', this.editor.edit_move, null, this.editor);
-        node.on('gesturemoveend', this.editor.edit_end, null, this.editor);
-
         drawable.nodes.push(node);
 
         this.drawable = drawable;
index bdb96f9..53f91c7 100644 (file)
@@ -159,7 +159,7 @@ var COMMENT = function(editor, gradeid, pageno, x, y, width, colour, rawtext) {
     this.draw = function(focus) {
         var drawable = new M.assignfeedback_editpdf.drawable(this.editor),
             node,
-            drawingregion = this.editor.get_dialogue_element(SELECTOR.DRAWINGREGION),
+            drawingcanvas = this.editor.get_dialogue_element(SELECTOR.DRAWINGCANVAS),
             container,
             menu,
             position,
@@ -189,14 +189,14 @@ var COMMENT = function(editor, gradeid, pageno, x, y, width, colour, rawtext) {
             color: COMMENTTEXTCOLOUR
         });
 
-        drawingregion.append(container);
+        drawingcanvas.append(container);
         container.setStyle('position', 'absolute');
         container.setX(position.x);
         container.setY(position.y);
         drawable.store_position(container, position.x, position.y);
         drawable.nodes.push(container);
         node.set('value', this.rawtext);
-        scrollheight = node.get('scrollHeight'),
+        scrollheight = node.get('scrollHeight');
         node.setStyles({
             'height' : scrollheight + 'px',
             'overflow': 'hidden'
index 6912735..8d842f8 100644 (file)
@@ -1106,7 +1106,7 @@ EDITOR.prototype = {
         drawingcanvas.setStyle('height', page.height + 'px');
 
         // Update page select.
-        this.get_dialogue_element(SELECTOR.PAGESELECT).set('value', this.currentpage);
+        this.get_dialogue_element(SELECTOR.PAGESELECT).set('selectedIndex', this.currentpage);
 
         this.resize(); // Internally will call 'redraw', after checking the dialogue size.
     },
index c4ce567..cd23c8e 100644 (file)
@@ -1411,7 +1411,11 @@ function assign_get_completion_state($course, $cm, $userid, $type) {
 
     // If completion option is enabled, evaluate it and return true/false.
     if ($assign->get_instance()->completionsubmit) {
-        $submission = $assign->get_user_submission($userid, false);
+        if ($assign->get_instance()->teamsubmission) {
+            $submission = $assign->get_group_submission($userid, 0, false);
+        } else {
+            $submission = $assign->get_user_submission($userid, false);
+        }
         return $submission && $submission->status == ASSIGN_SUBMISSION_STATUS_SUBMITTED;
     } else {
         // Completion option is not enabled so just return $type.
index c5a2b1e..5160225 100644 (file)
@@ -5466,11 +5466,10 @@ class assign {
      * @param int $updatetime
      * @return void
      */
-    public function send_notification($userfrom,
-                                      $userto,
-                                      $messagetype,
-                                      $eventtype,
-                                      $updatetime) {
+    public function send_notification($userfrom, $userto, $messagetype, $eventtype, $updatetime) {
+        global $USER;
+        $userid = core_user::is_real_user($userfrom->id) ? $userfrom->id : $USER->id;
+        $uniqueid = $this->get_uniqueid_for_user($userid);
         self::send_assignment_notification($userfrom,
                                            $userto,
                                            $messagetype,
@@ -5482,7 +5481,7 @@ class assign {
                                            $this->get_module_name(),
                                            $this->get_instance()->name,
                                            $this->is_blind_marking(),
-                                           $this->get_uniqueid_for_user($userfrom->id));
+                                           $uniqueid);
     }
 
     /**
@@ -5633,7 +5632,12 @@ class assign {
             $this->update_submission($submission, $userid, true, $instance->teamsubmission);
             $completion = new completion_info($this->get_course());
             if ($completion->is_enabled($this->get_course_module()) && $instance->completionsubmit) {
-                $completion->update_state($this->get_course_module(), COMPLETION_COMPLETE, $userid);
+                $this->update_activity_completion_records($instance->teamsubmission,
+                                                          $instance->requireallteammemberssubmit,
+                                                          $submission,
+                                                          $userid,
+                                                          COMPLETION_COMPLETE,
+                                                          $completion);
             }
 
             if (!empty($data->submissionstatement) && $USER->id == $userid) {
@@ -6325,7 +6329,12 @@ class assign {
         }
         $completion = new completion_info($this->get_course());
         if ($completion->is_enabled($this->get_course_module()) && $instance->completionsubmit) {
-            $completion->update_state($this->get_course_module(), $complete, $USER->id);
+            $this->update_activity_completion_records($instance->teamsubmission,
+                                                      $instance->requireallteammemberssubmit,
+                                                      $submission,
+                                                      $USER->id,
+                                                      $complete,
+                                                      $completion);
         }
 
         if (!$instance->submissiondrafts) {
@@ -7991,6 +8000,42 @@ class assign {
         }
         return $this->get_course_module()->id . '_' . $id;
     }
+
+    /**
+     * Updates and creates the completion records in mdl_course_modules_completion.
+     *
+     * @param int $teamsubmission value of 0 or 1 to indicate whether this is a group activity
+     * @param int $requireallteammemberssubmit value of 0 or 1 to indicate whether all group members must click Submit
+     * @param obj $submission the submission
+     * @param int $userid the user id
+     * @param int $complete
+     * @param obj $completion
+     *
+     * @return null
+     */
+    protected function update_activity_completion_records($teamsubmission,
+                                                          $requireallteammemberssubmit,
+                                                          $submission,
+                                                          $userid,
+                                                          $complete,
+                                                          $completion) {
+
+        if (($teamsubmission && $submission->groupid > 0 && !$requireallteammemberssubmit) ||
+            ($teamsubmission && $submission->groupid > 0 && $requireallteammemberssubmit &&
+             $submission->status == ASSIGN_SUBMISSION_STATUS_SUBMITTED)) {
+
+            $members = groups_get_members($submission->groupid);
+
+            foreach ($members as $member) {
+                $completion->update_state($this->get_course_module(), $complete, $member->id);
+            }
+        } else {
+            $completion->update_state($this->get_course_module(), $complete, $userid);
+        }
+
+        return;
+    }
+
 }
 
 /**
index 412f72f..be6166d 100644 (file)
@@ -209,32 +209,6 @@ class mod_assign_mod_form extends moodleform_mod {
         $this->apply_admin_defaults();
 
         $this->add_action_buttons();
-
-        // Add warning popup/noscript tag, if grades are changed by user.
-        $hasgrade = false;
-        if (!empty($this->_instance)) {
-            $hasgrade = $DB->record_exists_select('assign_grades',
-                                                  'assignment = ? AND grade <> -1',
-                                                  array($this->_instance));
-        }
-
-        if ($mform->elementExists('grade') && $hasgrade) {
-            $module = array(
-                'name' => 'mod_assign',
-                'fullpath' => '/mod/assign/module.js',
-                'requires' => array('node', 'event'),
-                'strings' => array(array('changegradewarning', 'mod_assign'))
-                );
-            $PAGE->requires->js_init_call('M.mod_assign.init_grade_change', null, false, $module);
-
-            // Add noscript tag in case.
-            $noscriptwarning = $mform->createElement('static',
-                                                     'warning',
-                                                     null,
-                                                     html_writer::tag('noscript',
-                                                     get_string('changegradewarning', 'mod_assign')));
-            $mform->insertElementBefore($noscriptwarning, 'grade');
-        }
     }
 
     /**
index 57c234b..5c92b96 100644 (file)
@@ -90,7 +90,7 @@ class mod_assign_base_testcase extends advanced_testcase {
 
         $this->resetAfterTest(true);
 
-        $this->course = $this->getDataGenerator()->create_course();
+        $this->course = $this->getDataGenerator()->create_course(array('enablecompletion' => 1));
         $this->teachers = array();
         for ($i = 0; $i < self::DEFAULT_TEACHER_COUNT; $i++) {
             array_push($this->teachers, $this->getDataGenerator()->create_user());
@@ -350,4 +350,18 @@ class testable_assign extends assign {
 
         return $mform;
     }
+
+    public function testable_update_activity_completion_records($teamsubmission,
+                                                          $requireallteammemberssubmit,
+                                                          $submission,
+                                                          $userid,
+                                                          $complete,
+                                                          $completion) {
+        return parent::update_activity_completion_records($teamsubmission,
+                                                          $requireallteammemberssubmit,
+                                                          $submission,
+                                                          $userid,
+                                                          $complete,
+                                                          $completion);
+    }
 }
index 2062696..65670a5 100644 (file)
@@ -2631,4 +2631,94 @@ Anchor link 2:<a title=\"bananas\" href=\"../logo-240x60.gif\">Link text</a>
         $grade = $assign->get_user_grade($this->students[0]->id, false);
         $this->assertEquals('30.0', $grade->grade);
     }
+
+    /**
+     * Test updating activity completion when submitting an assessment.
+     */
+    public function test_update_activity_completion_records_solitary_submission() {
+        $assign = $this->create_instance(array('grade' => 100,
+                'completion' => COMPLETION_TRACKING_AUTOMATIC,
+                'requireallteammemberssubmit' => 0));
+
+        $cm = $assign->get_course_module();
+
+        $student = $this->students[0];
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_assign');
+
+        $this->setUser($student);
+
+        // Simulate a submission.
+        $data = new stdClass();
+        $data->onlinetext_editor = array(
+            'itemid' => file_get_unused_draft_itemid(),
+            'text' => 'Student submission text',
+            'format' => FORMAT_MOODLE
+        );
+        $completion = new completion_info($this->course);
+
+        $notices = array();
+        $assign->save_submission($data, $notices);
+
+        $submission = $assign->get_user_submission($student->id, true);
+
+        // Check that completion is not met yet.
+        $completiondata = $completion->get_data($cm, false, $student->id);
+        $this->assertEquals(0, $completiondata->completionstate);
+        $assign->testable_update_activity_completion_records(0, 0, $submission,
+                $student->id, COMPLETION_COMPLETE, $completion);
+        // Completion should now be met.
+        $completiondata = $completion->get_data($cm, false, $student->id);
+        $this->assertEquals(1, $completiondata->completionstate);
+    }
+
+    /**
+     * Test updating activity completion when submitting an assessment.
+     */
+    public function test_update_activity_completion_records_team_submission() {
+        $assign = $this->create_instance(array('grade' => 100,
+                'completion' => COMPLETION_TRACKING_AUTOMATIC,
+                 'teamsubmission' => 1));
+
+        $cm = $assign->get_course_module();
+
+        $student1 = $this->students[0];
+        $student2 = $this->students[1];
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_assign');
+
+        // Put both users into a group.
+        $group1 = $this->getDataGenerator()->create_group(array('courseid' => $this->course->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1->id, 'userid' => $student1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1->id, 'userid' => $student2->id));
+
+        $this->setUser($student1);
+
+        // Simulate a submission.
+        $data = new stdClass();
+        $data->onlinetext_editor = array(
+            'itemid' => file_get_unused_draft_itemid(),
+            'text' => 'Student submission text',
+            'format' => FORMAT_MOODLE
+        );
+        $completion = new completion_info($this->course);
+
+        $notices = array();
+        $assign->save_submission($data, $notices);
+
+        $submission = $assign->get_user_submission($student1->id, true);
+        $submission->status = ASSIGN_SUBMISSION_STATUS_SUBMITTED;
+        $submission->groupid = $group1->id;
+
+        // Check that completion is not met yet.
+        $completiondata = $completion->get_data($cm, false, $student1->id);
+        $this->assertEquals(0, $completiondata->completionstate);
+        $completiondata = $completion->get_data($cm, false, $student2->id);
+        $this->assertEquals(0, $completiondata->completionstate);
+        $assign->testable_update_activity_completion_records(1, 0, $submission, $student1->id,
+                COMPLETION_COMPLETE, $completion);
+        // Completion should now be met.
+        $completiondata = $completion->get_data($cm, false, $student1->id);
+        $this->assertEquals(1, $completiondata->completionstate);
+        $completiondata = $completion->get_data($cm, false, $student2->id);
+        $this->assertEquals(1, $completiondata->completionstate);
+    }
 }
index 53968b4..3c5d57a 100644 (file)
@@ -749,6 +749,7 @@ function chat_format_message_manually($message, $courseid, $sender, $currentuser
     // Parse the text to clean and filter it.
     $options = new stdClass();
     $options->para = false;
+    $options->blanktarget = true;
     $text = format_text($text, FORMAT_MOODLE, $options, $courseid);
 
     // And now check for special cases.
@@ -922,6 +923,7 @@ function chat_format_message_theme ($message, $chatuser, $currentuser, $grouping
     // Parse the text to clean and filter it.
     $options = new stdClass();
     $options->para = false;
+    $options->blanktarget = true;
     $text = format_text($text, FORMAT_MOODLE, $options, $courseid);
 
     // And now check for special cases.
index 0deb0a8..666df96 100644 (file)
@@ -79,20 +79,23 @@ function choice_user_outline($course, $user, $mod, $choice) {
 }
 
 /**
- * @global object
+ * Callback for the "Complete" report - prints the activity summary for the given user
+ *
  * @param object $course
  * @param object $user
  * @param object $mod
  * @param object $choice
- * @return string|void
  */
 function choice_user_complete($course, $user, $mod, $choice) {
     global $DB;
-    if ($answer = $DB->get_record('choice_answers', array("choiceid" => $choice->id, "userid" => $user->id))) {
-        $result = new stdClass();
-        $result->info = "'".format_string(choice_get_option_text($choice, $answer->optionid))."'";
-        $result->time = $answer->timemodified;
-        echo get_string("answered", "choice").": $result->info. ".get_string("updated", '', userdate($result->time));
+    if ($answers = $DB->get_records('choice_answers', array("choiceid" => $choice->id, "userid" => $user->id))) {
+        $info = [];
+        foreach ($answers as $answer) {
+            $info[] = "'" . format_string(choice_get_option_text($choice, $answer->optionid)) . "'";
+        }
+        core_collator::asort($info);
+        echo get_string("answered", "choice") . ": ". join(', ', $info) . ". " .
+                get_string("updated", '', userdate($answer->timemodified));
     } else {
         print_string("notanswered", "choice");
     }
diff --git a/mod/data/classes/search/activity.php b/mod/data/classes/search/activity.php
new file mode 100644 (file)
index 0000000..eb776c3
--- /dev/null
@@ -0,0 +1,37 @@
+<?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/>.
+
+/**
+ * Search area for mod_data activities.
+ *
+ * @package    mod_data
+ * @copyright  2016 David Monllao {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_data\search;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Search area for mod_data activities.
+ *
+ * @package    mod_data
+ * @copyright  2016 David Monllao {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class activity extends \core_search\area\base_activity {
+}
index a733dbf..871de64 100644 (file)
@@ -6,9 +6,13 @@ function insert_field_tags(selectlist) {
     var value = selectlist.options[selectlist.selectedIndex].value;
     var editorname = 'template';
     if (typeof tinyMCE == 'undefined') {
-        var element = document.getElementsByName(editorname)[0];
-        // For inserting when in normal textareas
-        insertAtCursor(element, value);
+        if (document.execCommand('insertText')) {
+            document.execCommand('insertText', false, value);
+        } else {
+            var element = document.getElementsByName(editorname)[0];
+            // For inserting when in normal textareas
+            insertAtCursor(element, value);
+        }
     } else {
         tinyMCE.execInstanceCommand(editorname, 'mceInsertContent', false, value);
     }
index 0d01c13..a8e0d38 100644 (file)
@@ -331,6 +331,7 @@ $string['savesettings'] = 'Save settings';
 $string['savesuccess'] = 'Saved successfully. Your preset will now be available across the site.';
 $string['savetemplate'] = 'Save template';
 $string['search'] = 'Search';
+$string['search:activity'] = 'Database - activity information';
 $string['selectedrequired'] = 'All selected required';
 $string['showall'] = 'Show all entries';
 $string['single'] = 'View single';
index fe08813..4816808 100644 (file)
@@ -3078,6 +3078,12 @@ function forum_print_post($post, $discussion, $forum, &$cm, $course, $ownpost=fa
 
     // String cache
     static $str;
+    // This is an extremely hacky way to ensure we only print the 'unread' anchor
+    // the first time we encounter an unread post on a page. Ideally this would
+    // be moved into the caller somehow, and be better testable. But at the time
+    // of dealing with this bug, this static workaround was the most surgical and
+    // it fits together with only printing th unread anchor id once on a given page.
+    static $firstunreadanchorprinted = false;
 
     $modcontext = context_module::instance($cm->id);
 
@@ -3293,7 +3299,11 @@ function forum_print_post($post, $discussion, $forum, &$cm, $course, $ownpost=fa
             $forumpostclass = ' read';
         } else {
             $forumpostclass = ' unread';
-            $output .= html_writer::tag('a', '', array('name'=>'unread'));
+            // If this is the first unread post printed then give it an anchor and id of unread.
+            if (!$firstunreadanchorprinted) {
+                $output .= html_writer::tag('a', '', array('id' => 'unread'));
+                $firstunreadanchorprinted = true;
+            }
         }
     } else {
         // ignore trackign status if not tracked or tracked param missing
index 75f7d8e..e8709c6 100644 (file)
@@ -2596,17 +2596,31 @@ function lti_load_type_from_cartridge($url, $type) {
         array(
             "title" => "lti_typename",
             "launch_url" => "lti_toolurl",
-            "description" => "lti_description"
+            "description" => "lti_description",
+            "icon" => "lti_icon",
+            "secure_icon" => "lti_secureicon"
         ),
         array(
-            "icon_url" => "lti_icon",
-            "secure_icon_url" => "lti_secureicon"
+            "icon_url" => "lti_extension_icon",
+            "secure_icon_url" => "lti_extension_secureicon"
         )
     );
     // If an activity name exists, unset the cartridge name so we don't override it.
     if (isset($type->lti_typename)) {
         unset($toolinfo['lti_typename']);
     }
+
+    // Always prefer cartridge core icons first, then, if none are found, look at the extension icons.
+    if (empty($toolinfo['lti_icon']) && !empty($toolinfo['lti_extension_icon'])) {
+        $toolinfo['lti_icon'] = $toolinfo['lti_extension_icon'];
+    }
+    unset($toolinfo['lti_extension_icon']);
+
+    if (empty($toolinfo['lti_secureicon']) && !empty($toolinfo['lti_extension_secureicon'])) {
+        $toolinfo['lti_secureicon'] = $toolinfo['lti_extension_secureicon'];
+    }
+    unset($toolinfo['lti_extension_secureicon']);
+
     foreach ($toolinfo as $property => $value) {
         $type->$property = $value;
     }
@@ -2626,17 +2640,31 @@ function lti_load_tool_from_cartridge($url, $lti) {
             "title" => "name",
             "launch_url" => "toolurl",
             "secure_launch_url" => "securetoolurl",
-            "description" => "intro"
+            "description" => "intro",
+            "icon" => "icon",
+            "secure_icon" => "secureicon"
         ),
         array(
-            "icon_url" => "icon",
-            "secure_icon_url" => "secureicon"
+            "icon_url" => "extension_icon",
+            "secure_icon_url" => "extension_secureicon"
         )
     );
     // If an activity name exists, unset the cartridge name so we don't override it.
     if (isset($lti->name)) {
         unset($toolinfo['name']);
     }
+
+    // Always prefer cartridge core icons first, then, if none are found, look at the extension icons.
+    if (empty($toolinfo['icon']) && !empty($toolinfo['extension_icon'])) {
+        $toolinfo['icon'] = $toolinfo['extension_icon'];
+    }
+    unset($toolinfo['extension_icon']);
+
+    if (empty($toolinfo['secureicon']) && !empty($toolinfo['extension_secureicon'])) {
+        $toolinfo['secureicon'] = $toolinfo['extension_secureicon'];
+    }
+    unset($toolinfo['extension_secureicon']);
+
     foreach ($toolinfo as $property => $value) {
         $lti->$property = $value;
     }
index cd021e3..f4b9175 100644 (file)
@@ -544,14 +544,14 @@ function quiz_cron() {
 }
 
 /**
- * @param int $quizid the quiz id.
+ * @param int|array $quizids A quiz ID, or an array of quiz IDs.
  * @param int $userid the userid.
  * @param string $status 'all', 'finished' or 'unfinished' to control
  * @param bool $includepreviews
  * @return an array of all the user's attempts at this quiz. Returns an empty
  *      array if there are none.
  */
-function quiz_get_user_attempts($quizid, $userid, $status = 'finished', $includepreviews = false) {
+function quiz_get_user_attempts($quizids, $userid, $status = 'finished', $includepreviews = false) {
     global $DB, $CFG;
     // TODO MDL-33071 it is very annoying to have to included all of locallib.php
     // just to get the quiz_attempt::FINISHED constants, but I will try to sort
@@ -578,15 +578,18 @@ function quiz_get_user_attempts($quizid, $userid, $status = 'finished', $include
             break;
     }
 
+    $quizids = (array) $quizids;
+    list($insql, $inparams) = $DB->get_in_or_equal($quizids, SQL_PARAMS_NAMED);
+    $params += $inparams;
+    $params['userid'] = $userid;
+
     $previewclause = '';
     if (!$includepreviews) {
         $previewclause = ' AND preview = 0';
     }
 
-    $params['quizid'] = $quizid;
-    $params['userid'] = $userid;
     return $DB->get_records_select('quiz_attempts',
-            'quiz = :quizid AND userid = :userid' . $previewclause . $statuscondition,
+            "quiz $insql AND userid = :userid" . $previewclause . $statuscondition,
             $params, 'attempt ASC');
 }
 
@@ -1465,6 +1468,20 @@ function quiz_print_overview($courses, &$htmlarray) {
         return;
     }
 
+    // Get the quizzes attempts.
+    $attemptsinfo = [];
+    $quizids = [];
+    foreach ($quizzes as $quiz) {
+        $quizids[] = $quiz->id;
+        $attemptsinfo[$quiz->id] = ['count' => 0, 'hasfinished' => false];
+    }
+    $attempts = quiz_get_user_attempts($quizids, $USER->id);
+    foreach ($attempts as $attempt) {
+        $attemptsinfo[$attempt->quiz]['count']++;
+        $attemptsinfo[$attempt->quiz]['hasfinished'] = true;
+    }
+    unset($attempts);
+
     // Fetch some language strings outside the main loop.
     $strquiz = get_string('modulename', 'quiz');
     $strnoattempts = get_string('noattempts', 'quiz');
@@ -1474,15 +1491,7 @@ function quiz_print_overview($courses, &$htmlarray) {
     $now = time();
     foreach ($quizzes as $quiz) {
         if ($quiz->timeclose >= $now && $quiz->timeopen < $now) {
-            // Give a link to the quiz, and the deadline.
-            $str = '<div class="quiz overview">' .
-                    '<div class="name">' . $strquiz . ': <a ' .
-                    ($quiz->visible ? '' : ' class="dimmed"') .
-                    ' href="' . $CFG->wwwroot . '/mod/quiz/view.php?id=' .
-                    $quiz->coursemodule . '">' .
-                    $quiz->name . '</a></div>';
-            $str .= '<div class="info">' . get_string('quizcloseson', 'quiz',
-                    userdate($quiz->timeclose)) . '</div>';
+            $str = '';
 
             // Now provide more information depending on the uers's role.
             $context = context_module::instance($quiz->coursemodule);
@@ -1490,30 +1499,48 @@ function quiz_print_overview($courses, &$htmlarray) {
                 // For teacher-like people, show a summary of the number of student attempts.
                 // The $quiz objects returned by get_all_instances_in_course have the necessary $cm
                 // fields set to make the following call work.
-                $str .= '<div class="info">' .
-                        quiz_num_attempt_summary($quiz, $quiz, true) . '</div>';
-            } else if (has_any_capability(array('mod/quiz:reviewmyattempts', 'mod/quiz:attempt'),
-                    $context)) { // Student
+                $str .= '<div class="info">' . quiz_num_attempt_summary($quiz, $quiz, true) . '</div>';
+
+            } else if (has_any_capability(array('mod/quiz:reviewmyattempts', 'mod/quiz:attempt'), $context)) { // Student
                 // For student-like people, tell them how many attempts they have made.
-                if (isset($USER->id) &&
-                        ($attempts = quiz_get_user_attempts($quiz->id, $USER->id))) {
-                    $numattempts = count($attempts);
-                    $str .= '<div class="info">' .
-                            get_string('numattemptsmade', 'quiz', $numattempts) . '</div>';
+
+                if (isset($USER->id)) {
+                    if ($attemptsinfo[$quiz->id]['hasfinished']) {
+                        // The student's last attempt is finished.
+                        continue;
+                    }
+
+                    if ($attemptsinfo[$quiz->id]['count'] > 0) {
+                        $str .= '<div class="info">' .
+                            get_string('numattemptsmade', 'quiz', $attemptsinfo[$quiz->id]['count']) . '</div>';
+                    } else {
+                        $str .= '<div class="info">' . $strnoattempts . '</div>';
+                    }
+
                 } else {
                     $str .= '<div class="info">' . $strnoattempts . '</div>';
                 }
+
             } else {
                 // For ayone else, there is no point listing this quiz, so stop processing.
                 continue;
             }
 
-            // Add the output for this quiz to the rest.
-            $str .= '</div>';
+            // Give a link to the quiz, and the deadline.
+            $html = '<div class="quiz overview">' .
+                    '<div class="name">' . $strquiz . ': <a ' .
+                    ($quiz->visible ? '' : ' class="dimmed"') .
+                    ' href="' . $CFG->wwwroot . '/mod/quiz/view.php?id=' .
+                    $quiz->coursemodule . '">' .
+                    $quiz->name . '</a></div>';
+            $html .= '<div class="info">' . get_string('quizcloseson', 'quiz',
+                    userdate($quiz->timeclose)) . '</div>';
+            $html .= $str;
+            $html .= '</div>';
             if (empty($htmlarray[$quiz->course]['quiz'])) {
-                $htmlarray[$quiz->course]['quiz'] = $str;
+                $htmlarray[$quiz->course]['quiz'] = $html;
             } else {
-                $htmlarray[$quiz->course]['quiz'] .= $str;
+                $htmlarray[$quiz->course]['quiz'] .= $html;
             }
         }
     }
index 646073a..af8cacf 100644 (file)
@@ -302,7 +302,11 @@ class quiz_overview_report extends quiz_attempts_report {
      * @uses exit. This method never returns.
      */
     protected function finish_regrade($nexturl) {
-        redirect($nexturl, get_string('regradecomplete', 'quiz_overview'), null, \core\output\notification::NOTIFY_SUCCESS);
+        global $OUTPUT;
+        \core\notification::success(get_string('regradecomplete', 'quiz_overview'));
+        echo $OUTPUT->continue_button($nexturl);
+        echo $OUTPUT->footer();
+        die();
     }
 
     /**
@@ -441,23 +445,25 @@ class quiz_overview_report extends quiz_attempts_report {
         if ($groupstudents) {
             list($usql, $uparams) = $DB->get_in_or_equal($groupstudents);
             $where .= " AND quiza.userid $usql";
-            $params += $uparams;
+            $params = array_merge($params, $uparams);
         }
 
-        $toregrade = $DB->get_records_sql("
+        $toregrade = $DB->get_recordset_sql("
                 SELECT quiza.uniqueid, qqr.slot
                 FROM {quiz_attempts} quiza
                 JOIN {quiz_overview_regrades} qqr ON qqr.questionusageid = quiza.uniqueid
                 WHERE $where", $params);
 
-        if (!$toregrade) {
-            return;
-        }
-
         $attemptquestions = array();
         foreach ($toregrade as $row) {
             $attemptquestions[$row->uniqueid][] = $row->slot;
         }
+        $toregrade->close();
+
+        if (!$attemptquestions) {
+            return;
+        }
+
         $attempts = $DB->get_records_list('quiz_attempts', 'uniqueid',
                 array_keys($attemptquestions));
 
index 75edda7..22353ff 100644 (file)
@@ -227,4 +227,226 @@ class mod_quiz_lib_testcase extends advanced_testcase {
         $this->assertTrue(quiz_get_completion_state($course, $cm, $passstudent->id, 'return'));
         $this->assertFalse(quiz_get_completion_state($course, $cm, $failstudent->id, 'return'));
     }
+
+    public function test_quiz_get_user_attempts() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $dg = $this->getDataGenerator();
+        $quizgen = $dg->get_plugin_generator('mod_quiz');
+        $course = $dg->create_course();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+        $u3 = $dg->create_user();
+        $u4 = $dg->create_user();
+        $role = $DB->get_record('role', ['shortname' => 'student']);
+
+        $dg->enrol_user($u1->id, $course->id, $role->id);
+        $dg->enrol_user($u2->id, $course->id, $role->id);
+        $dg->enrol_user($u3->id, $course->id, $role->id);
+        $dg->enrol_user($u4->id, $course->id, $role->id);
+
+        $quiz1 = $quizgen->create_instance(['course' => $course->id, 'sumgrades' => 2]);
+        $quiz2 = $quizgen->create_instance(['course' => $course->id, 'sumgrades' => 2]);
+
+        // Questions.
+        $questgen = $dg->get_plugin_generator('core_question');
+        $quizcat = $questgen->create_question_category();
+        $question = $questgen->create_question('numerical', null, ['category' => $quizcat->id]);
+        quiz_add_quiz_question($question->id, $quiz1);
+        quiz_add_quiz_question($question->id, $quiz2);
+
+        $quizobj1a = quiz::create($quiz1->id, $u1->id);
+        $quizobj1b = quiz::create($quiz1->id, $u2->id);
+        $quizobj1c = quiz::create($quiz1->id, $u3->id);
+        $quizobj1d = quiz::create($quiz1->id, $u4->id);
+        $quizobj2a = quiz::create($quiz2->id, $u1->id);
+
+        // Set attempts.
+        $quba1a = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj1a->get_context());
+        $quba1a->set_preferred_behaviour($quizobj1a->get_quiz()->preferredbehaviour);
+        $quba1b = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj1b->get_context());
+        $quba1b->set_preferred_behaviour($quizobj1b->get_quiz()->preferredbehaviour);
+        $quba1c = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj1c->get_context());
+        $quba1c->set_preferred_behaviour($quizobj1c->get_quiz()->preferredbehaviour);
+        $quba1d = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj1d->get_context());
+        $quba1d->set_preferred_behaviour($quizobj1d->get_quiz()->preferredbehaviour);
+        $quba2a = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj2a->get_context());
+        $quba2a->set_preferred_behaviour($quizobj2a->get_quiz()->preferredbehaviour);
+
+        $timenow = time();
+
+        // User 1 passes quiz 1.
+        $attempt = quiz_create_attempt($quizobj1a, 1, false, $timenow, false, $u1->id);
+        quiz_start_new_attempt($quizobj1a, $quba1a, $attempt, 1, $timenow);
+        quiz_attempt_save_started($quizobj1a, $quba1a, $attempt);
+        $attemptobj = quiz_attempt::create($attempt->id);
+        $attemptobj->process_submitted_actions($timenow, false, [1 => ['answer' => '3.14']]);
+        $attemptobj->process_finish($timenow, false);
+
+        // User 2 goes overdue in quiz 1.
+        $attempt = quiz_create_attempt($quizobj1b, 1, false, $timenow, false, $u2->id);
+        quiz_start_new_attempt($quizobj1b, $quba1b, $attempt, 1, $timenow);
+        quiz_attempt_save_started($quizobj1b, $quba1b, $attempt);
+        $attemptobj = quiz_attempt::create($attempt->id);
+        $attemptobj->process_going_overdue($timenow, true);
+
+        // User 3 does not finish quiz 1.
+        $attempt = quiz_create_attempt($quizobj1c, 1, false, $timenow, false, $u3->id);
+        quiz_start_new_attempt($quizobj1c, $quba1c, $attempt, 1, $timenow);
+        quiz_attempt_save_started($quizobj1c, $quba1c, $attempt);
+
+        // User 4 abandons the quiz 1.
+        $attempt = quiz_create_attempt($quizobj1d, 1, false, $timenow, false, $u4->id);
+        quiz_start_new_attempt($quizobj1d, $quba1d, $attempt, 1, $timenow);
+        quiz_attempt_save_started($quizobj1d, $quba1d, $attempt);
+        $attemptobj = quiz_attempt::create($attempt->id);
+        $attemptobj->process_abandon($timenow, true);
+
+        // User 1 attempts the quiz three times (abandon, finish, in progress).
+        $quba2a = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj2a->get_context());
+        $quba2a->set_preferred_behaviour($quizobj2a->get_quiz()->preferredbehaviour);
+
+        $attempt = quiz_create_attempt($quizobj2a, 1, false, $timenow, false, $u1->id);
+        quiz_start_new_attempt($quizobj2a, $quba2a, $attempt, 1, $timenow);
+        quiz_attempt_save_started($quizobj2a, $quba2a, $attempt);
+        $attemptobj = quiz_attempt::create($attempt->id);
+        $attemptobj->process_abandon($timenow, true);
+
+        $quba2a = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj2a->get_context());
+        $quba2a->set_preferred_behaviour($quizobj2a->get_quiz()->preferredbehaviour);
+
+        $attempt = quiz_create_attempt($quizobj2a, 2, false, $timenow, false, $u1->id);
+        quiz_start_new_attempt($quizobj2a, $quba2a, $attempt, 2, $timenow);
+        quiz_attempt_save_started($quizobj2a, $quba2a, $attempt);
+        $attemptobj = quiz_attempt::create($attempt->id);
+        $attemptobj->process_finish($timenow, false);
+
+        $quba2a = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj2a->get_context());
+        $quba2a->set_preferred_behaviour($quizobj2a->get_quiz()->preferredbehaviour);
+
+        $attempt = quiz_create_attempt($quizobj2a, 3, false, $timenow, false, $u1->id);
+        quiz_start_new_attempt($quizobj2a, $quba2a, $attempt, 3, $timenow);
+        quiz_attempt_save_started($quizobj2a, $quba2a, $attempt);
+
+        // Check for user 1.
+        $attempts = quiz_get_user_attempts($quiz1->id, $u1->id, 'all');
+        $this->assertCount(1, $attempts);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::FINISHED, $attempt->state);
+        $this->assertEquals($u1->id, $attempt->userid);
+        $this->assertEquals($quiz1->id, $attempt->quiz);
+
+        $attempts = quiz_get_user_attempts($quiz1->id, $u1->id, 'finished');
+        $this->assertCount(1, $attempts);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::FINISHED, $attempt->state);
+        $this->assertEquals($u1->id, $attempt->userid);
+        $this->assertEquals($quiz1->id, $attempt->quiz);
+
+        $attempts = quiz_get_user_attempts($quiz1->id, $u1->id, 'unfinished');
+        $this->assertCount(0, $attempts);
+
+        // Check for user 2.
+        $attempts = quiz_get_user_attempts($quiz1->id, $u2->id, 'all');
+        $this->assertCount(1, $attempts);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::OVERDUE, $attempt->state);
+        $this->assertEquals($u2->id, $attempt->userid);
+        $this->assertEquals($quiz1->id, $attempt->quiz);
+
+        $attempts = quiz_get_user_attempts($quiz1->id, $u2->id, 'finished');
+        $this->assertCount(0, $attempts);
+
+        $attempts = quiz_get_user_attempts($quiz1->id, $u2->id, 'unfinished');
+        $this->assertCount(1, $attempts);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::OVERDUE, $attempt->state);
+        $this->assertEquals($u2->id, $attempt->userid);
+        $this->assertEquals($quiz1->id, $attempt->quiz);
+
+        // Check for user 3.
+        $attempts = quiz_get_user_attempts($quiz1->id, $u3->id, 'all');
+        $this->assertCount(1, $attempts);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::IN_PROGRESS, $attempt->state);
+        $this->assertEquals($u3->id, $attempt->userid);
+        $this->assertEquals($quiz1->id, $attempt->quiz);
+
+        $attempts = quiz_get_user_attempts($quiz1->id, $u3->id, 'finished');
+        $this->assertCount(0, $attempts);
+
+        $attempts = quiz_get_user_attempts($quiz1->id, $u3->id, 'unfinished');
+        $this->assertCount(1, $attempts);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::IN_PROGRESS, $attempt->state);
+        $this->assertEquals($u3->id, $attempt->userid);
+        $this->assertEquals($quiz1->id, $attempt->quiz);
+
+        // Check for user 4.
+        $attempts = quiz_get_user_attempts($quiz1->id, $u4->id, 'all');
+        $this->assertCount(1, $attempts);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::ABANDONED, $attempt->state);
+        $this->assertEquals($u4->id, $attempt->userid);
+        $this->assertEquals($quiz1->id, $attempt->quiz);
+
+        $attempts = quiz_get_user_attempts($quiz1->id, $u4->id, 'finished');
+        $this->assertCount(1, $attempts);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::ABANDONED, $attempt->state);
+        $this->assertEquals($u4->id, $attempt->userid);
+        $this->assertEquals($quiz1->id, $attempt->quiz);
+
+        $attempts = quiz_get_user_attempts($quiz1->id, $u4->id, 'unfinished');
+        $this->assertCount(0, $attempts);
+
+        // Multiple attempts for user 1 in quiz 2.
+        $attempts = quiz_get_user_attempts($quiz2->id, $u1->id, 'all');
+        $this->assertCount(3, $attempts);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::ABANDONED, $attempt->state);
+        $this->assertEquals($u1->id, $attempt->userid);
+        $this->assertEquals($quiz2->id, $attempt->quiz);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::FINISHED, $attempt->state);
+        $this->assertEquals($u1->id, $attempt->userid);
+        $this->assertEquals($quiz2->id, $attempt->quiz);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::IN_PROGRESS, $attempt->state);
+        $this->assertEquals($u1->id, $attempt->userid);
+        $this->assertEquals($quiz2->id, $attempt->quiz);
+
+        $attempts = quiz_get_user_attempts($quiz2->id, $u1->id, 'finished');
+        $this->assertCount(2, $attempts);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::ABANDONED, $attempt->state);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::FINISHED, $attempt->state);
+
+        $attempts = quiz_get_user_attempts($quiz2->id, $u1->id, 'unfinished');
+        $this->assertCount(1, $attempts);
+        $attempt = array_shift($attempts);
+
+        // Multiple quiz attempts fetched at once.
+        $attempts = quiz_get_user_attempts([$quiz1->id, $quiz2->id], $u1->id, 'all');
+        $this->assertCount(4, $attempts);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::FINISHED, $attempt->state);
+        $this->assertEquals($u1->id, $attempt->userid);
+        $this->assertEquals($quiz1->id, $attempt->quiz);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::ABANDONED, $attempt->state);
+        $this->assertEquals($u1->id, $attempt->userid);
+        $this->assertEquals($quiz2->id, $attempt->quiz);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::FINISHED, $attempt->state);
+        $this->assertEquals($u1->id, $attempt->userid);
+        $this->assertEquals($quiz2->id, $attempt->quiz);
+        $attempt = array_shift($attempts);
+        $this->assertEquals(quiz_attempt::IN_PROGRESS, $attempt->state);
+        $this->assertEquals($u1->id, $attempt->userid);
+        $this->assertEquals($quiz2->id, $attempt->quiz);
+    }
+
 }
index 3553f45..422b6e6 100644 (file)
@@ -60,9 +60,9 @@ class mod_wiki_create_form extends moodleform {
                 }
                 $mform->addElement('radio', 'pageformat', '', get_string('format'.$format, 'wiki'), $format, $attr);
             }
+            $mform->addRule('pageformat', get_string('required'), 'required', null, 'client');
         }
         $mform->setType('pageformat', PARAM_ALPHANUMEXT);
-        $mform->addRule('pageformat', get_string('required'), 'required', null, 'client');
 
         if (!empty($this->_customdata['groups']->availablegroups)) {
             foreach ($this->_customdata['groups']->availablegroups as $groupdata) {
index 9dc499b..01e5701 100644 (file)
@@ -81,6 +81,14 @@ function workshop_add_instance(stdclass $workshop) {
     $workshop->phaseswitchassessment = (int)!empty($workshop->phaseswitchassessment);
     $workshop->evaluation            = 'best';
 
+    if (isset($workshop->gradinggradepass)) {
+        $workshop->gradinggradepass = unformat_float($workshop->gradinggradepass);
+    }
+
+    if (isset($workshop->submissiongradepass)) {
+        $workshop->submissiongradepass = unformat_float($workshop->submissiongradepass);
+    }
+
     if (isset($workshop->submissionfiletypes)) {
         $workshop->submissionfiletypes = workshop::clean_file_extensions($workshop->submissionfiletypes);
     }
@@ -149,6 +157,14 @@ function workshop_update_instance(stdclass $workshop) {
     $workshop->latesubmissions       = (int)!empty($workshop->latesubmissions);
     $workshop->phaseswitchassessment = (int)!empty($workshop->phaseswitchassessment);
 
+    if (isset($workshop->gradinggradepass)) {
+        $workshop->gradinggradepass = unformat_float($workshop->gradinggradepass);
+    }
+
+    if (isset($workshop->submissiongradepass)) {
+        $workshop->submissiongradepass = unformat_float($workshop->submissiongradepass);
+    }
+
     if (isset($workshop->submissionfiletypes)) {
         $workshop->submissionfiletypes = workshop::clean_file_extensions($workshop->submissionfiletypes);
     }
index 64b9ac0..9fcaf27 100644 (file)
@@ -99,8 +99,7 @@ class mod_workshop_mod_form extends moodleform_mod {
         $mform->addElement('text', 'submissiongradepass', get_string('gradetopasssubmission', 'workshop'));
         $mform->addHelpButton('submissiongradepass', 'gradepass', 'grades');
         $mform->setDefault('submissiongradepass', '');
-        $mform->setType('submissiongradepass', PARAM_FLOAT);
-        $mform->addRule('submissiongradepass', null, 'numeric', null, 'client');
+        $mform->setType('submissiongradepass', PARAM_RAW);
 
         $label = get_string('gradinggrade', 'workshop');
         $mform->addGroup(array(
@@ -113,8 +112,7 @@ class mod_workshop_mod_form extends moodleform_mod {
         $mform->addElement('text', 'gradinggradepass', get_string('gradetopassgrading', 'workshop'));
         $mform->addHelpButton('gradinggradepass', 'gradepass', 'grades');
         $mform->setDefault('gradinggradepass', '');
-        $mform->setType('gradinggradepass', PARAM_FLOAT);
-        $mform->addRule('gradinggradepass', null, 'numeric', null, 'client');
+        $mform->setType('gradinggradepass', PARAM_RAW);
 
         $options = array();
         for ($i = 5; $i >= 0; $i--) {
@@ -397,11 +395,28 @@ class mod_workshop_mod_form extends moodleform_mod {
             }
         }
 
-        if ($data['submissiongradepass'] > $data['grade']) {
-            $errors['submissiongradepass'] = get_string('gradepassgreaterthangrade', 'grades', $data['grade']);
+        // Check that the submission grade pass is a valid number.
+        if (isset($data['submissiongradepass'])) {
+            $submissiongradefloat = unformat_float($data['submissiongradepass'], true);
+            if ($submissiongradefloat === false || $submissiongradefloat === null) {
+                $errors['submissiongradepass'] = get_string('err_numeric', 'form');
+            } else {
+                if ($submissiongradefloat > $data['grade']) {
+                    $errors['submissiongradepass'] = get_string('gradepassgreaterthangrade', 'grades', $data['grade']);
+                }
+            }
         }
-        if ($data['gradinggradepass'] > $data['gradinggrade']) {
-            $errors['gradinggradepass'] = get_string('gradepassgreaterthangrade', 'grades', $data['gradinggrade']);
+
+        // Check that the grade pass is a valid number.
+        if (isset($data['gradinggradepass'])) {
+            $gradepassfloat = unformat_float($data['gradinggradepass'], true);
+            if ($gradepassfloat === false || $gradepassfloat === null) {
+                $errors['gradinggradepass'] = get_string('err_numeric', 'form');
+            } else {
+                if ($gradepassfloat > $data['gradinggrade']) {
+                    $errors['gradinggradepass'] = get_string('gradepassgreaterthangrade', 'grades', $data['gradinggrade']);
+                }
+            }
         }
 
         return $errors;
index bd3219d..8ad1e70 100644 (file)
@@ -146,26 +146,30 @@ function my_reset_page($userid, $private=MY_PAGE_PRIVATE, $pagetype='my-index')
 function my_reset_page_for_all_users($private = MY_PAGE_PRIVATE, $pagetype = 'my-index') {
     global $DB;
 
+    // This may take a while. Raise the execution time limit.
+    core_php_time_limit::raise();
+
     // Find all the user pages.
     $where = 'userid IS NOT NULL AND private = :private';
     $params = array('private' => $private);
     $pages = $DB->get_recordset_select('my_pages', $where, $params, 'id, userid');
-    $pageids = array();
     $blockids = array();
 
     foreach ($pages as $page) {
-        $pageids[] = $page->id;
         $usercontext = context_user::instance($page->userid);
 
         // Find all block instances in that page.
-        $blocks = $DB->get_recordset('block_instances', array('parentcontextid' => $usercontext->id,
-            'pagetypepattern' => $pagetype), '', 'id, subpagepattern');
-        foreach ($blocks as $block) {
-            if (is_null($block->subpagepattern) || $block->subpagepattern == $page->id) {
-                $blockids[] = $block->id;
-            }
+        $blockswhere = 'parentcontextid = :parentcontextid AND
+            pagetypepattern = :pagetypepattern AND
+            (subpagepattern IS NULL OR subpagepattern = :subpagepattern)';
+        $blockswhereparams = [
+            'parentcontextid' => $usercontext->id,
+            'pagetypepattern' => $pagetype,
+            'subpagepattern' => $page->id
+        ];
+        if ($pageblockids = $DB->get_fieldset_select('block_instances', 'id', $blockswhere, $blockswhereparams)) {
+            $blockids = array_merge($blockids, $pageblockids);
         }
-        $blocks->close();
     }
     $pages->close();
 
@@ -178,9 +182,8 @@ function my_reset_page_for_all_users($private = MY_PAGE_PRIVATE, $pagetype = 'my
     }
 
     // Finally delete the pages.
-    if (!empty($pageids)) {
-        list($insql, $inparams) = $DB->get_in_or_equal($pageids);
-        $DB->delete_records_select('my_pages', "id $insql", $pageids);
+    if (!empty($pages)) {
+        $DB->delete_records_select('my_pages', $where, $params);
     }
 
     // We should be good to go now.
index c2194e3..6411bc5 100644 (file)
@@ -6,6 +6,16 @@
       "from": "abbrev@>=1.0.0 <2.0.0",
       "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.7.tgz"
     },
+    "acorn": {
+      "version": "3.2.0",
+      "from": "acorn@>=3.1.0 <4.0.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.2.0.tgz"
+    },
+    "acorn-jsx": {
+      "version": "3.0.1",
+      "from": "acorn-jsx@>=3.0.0 <4.0.0",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz"
+    },
     "align-text": {
       "version": "0.1.3",
       "from": "align-text@>=0.1.0 <0.2.0",
@@