Merge branch 'MDL-68635' of https://github.com/paulholden/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 6 Apr 2021 20:23:15 +0000 (22:23 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 6 Apr 2021 20:23:15 +0000 (22:23 +0200)
298 files changed:
.eslintignore
.eslintrc
.gitignore
.grunt/babel-plugin-add-module-to-define.js [moved from babel-plugin-add-module-to-define.js with 98% similarity]
.grunt/components.js [moved from GruntfileComponents.js with 81% similarity]
.grunt/tasks/eslint.js [new file with mode: 0644]
.grunt/tasks/gherkinlint.js [new file with mode: 0644]
.grunt/tasks/ignorefiles.js [new file with mode: 0644]
.grunt/tasks/javascript.js [new file with mode: 0644]
.grunt/tasks/sass.js [new file with mode: 0644]
.grunt/tasks/shifter.js [new file with mode: 0644]
.grunt/tasks/startup.js [new file with mode: 0644]
.grunt/tasks/style.js [new file with mode: 0644]
.grunt/tasks/stylelint.js [new file with mode: 0644]
.grunt/tasks/watch.js [new file with mode: 0644]
Gruntfile.js
admin/classes/task_log_table.php
admin/roles/assign.php
admin/settings/courses.php
admin/tests/behat/browse_users.feature [new file with mode: 0644]
admin/tests/behat/webservice_users.feature [new file with mode: 0644]
admin/tool/cohortroles/classes/output/cohort_role_assignments_table.php
admin/tool/dataprivacy/classes/api.php
admin/tool/dataprivacy/classes/external.php
admin/tool/dataprivacy/classes/local/helper.php
admin/tool/dataprivacy/createdatarequest_form.php
admin/tool/httpsreplace/tests/httpsreplace_test.php
admin/tool/lp/classes/external.php
admin/tool/lp/classes/output/template_plans_table.php
admin/tool/policy/classes/acceptances_table.php
admin/tool/policy/classes/api.php
admin/tool/policy/classes/form/accept_policy.php
admin/tool/uploaduser/classes/process.php
admin/user.php
admin/user/user_bulk_cohortadd.php
admin/user/user_bulk_display.php
availability/classes/info.php
availability/condition/profile/classes/condition.php
availability/condition/profile/classes/frontend.php
availability/tests/info_test.php
backup/moodle2/backup_stepslib.php
badges/assertion.php
badges/classes/assertion.php
badges/classes/output/external_badge.php
badges/classes/output/issued_badge.php
badges/criteria/award_criteria_profile.php
badges/recipients.php
badges/tests/badgeslib_test.php
badges/tests/events_test.php
blocks/activity_results/block_activity_results.php
blocks/mentees/block_mentees.php
blocks/online_users/classes/fetcher.php
blog/locallib.php
blog/rsslib.php
comment/lib.php
comment/locallib.php
completion/classes/cm_completion_details.php [new file with mode: 0644]
completion/tests/behat/custom_completion_display_conditions.feature [new file with mode: 0644]
completion/tests/cm_completion_details_test.php [new file with mode: 0644]
course/amd/build/manual_completion_toggle.min.js [new file with mode: 0644]
course/amd/build/manual_completion_toggle.min.js.map [new file with mode: 0644]
course/amd/build/repository.min.js
course/amd/build/repository.min.js.map
course/amd/src/manual_completion_toggle.js [new file with mode: 0644]
course/amd/src/repository.js
course/classes/category.php
course/classes/output/activity_information.php [new file with mode: 0644]
course/edit_form.php
course/recent_form.php
course/renderer.php
course/templates/activity_date.mustache [new file with mode: 0644]
course/templates/activity_info.mustache [new file with mode: 0644]
course/templates/completion_automatic.mustache [new file with mode: 0644]
course/templates/completion_manual.mustache [new file with mode: 0644]
course/tests/behat/course_activity_dates.feature [new file with mode: 0644]
enrol/ajax.php
enrol/externallib.php
enrol/locallib.php
enrol/manual/classes/enrol_users_form.php
enrol/otherusers.php
enrol/self/lib.php
enrol/self/locallib.php
files/tests/converter_test.php
grade/report/grader/ajax_callbacks.php
grade/report/grader/lib.php
grade/report/history/classes/helper.php
grade/report/history/classes/output/tablelog.php
grade/report/history/users_ajax.php
grade/tests/reportlib_test.php
group/autogroup.php
group/index.php
group/lib.php
group/overview.php
install/lang/hat/admin.php
install/lang/he/langconfig.php
install/lang/it/install.php
install/lang/mk/admin.php
install/lang/sr_cr/install.php
install/lang/sr_lt/install.php
install/lang/sv/error.php
install/lang/sv/langconfig.php
lang/en/completion.php
lang/en/course.php
lang/en/moodle.php
lib/accesslib.php
lib/adminlib.php
lib/amd/build/modal.min.js
lib/amd/build/modal.min.js.map
lib/amd/src/modal.js
lib/authlib.php
lib/badgeslib.php
lib/classes/check/access/riskadmin.php
lib/classes/check/access/riskbackup_result.php
lib/classes/check/access/riskxss_result.php
lib/classes/navigation/views/secondary.php
lib/classes/task/send_failed_login_notifications_task.php
lib/classes/task/send_new_user_passwords_task.php
lib/classes/user.php
lib/completionlib.php
lib/datalib.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/deprecatedlib.php
lib/editor/atto/plugins/html/thirdpartylibs.xml
lib/editor/atto/plugins/html/yui/build/moodle-atto_html-beautify/moodle-atto_html-beautify-debug.js
lib/editor/atto/plugins/html/yui/build/moodle-atto_html-beautify/moodle-atto_html-beautify-min.js
lib/editor/atto/plugins/html/yui/build/moodle-atto_html-beautify/moodle-atto_html-beautify.js
lib/editor/atto/plugins/html/yui/src/beautify/LICENSE
lib/editor/atto/plugins/html/yui/src/beautify/js/beautify-css.js
lib/editor/atto/plugins/html/yui/src/beautify/js/beautify-html.js
lib/editor/atto/plugins/html/yui/src/beautify/js/beautify.js
lib/editor/atto/plugins/html/yui/src/beautify/readme_moodle.txt
lib/form/amd/build/modalform.min.js
lib/form/amd/build/modalform.min.js.map
lib/form/amd/src/modalform.js
lib/grouplib.php
lib/modinfolib.php
lib/moodlelib.php
lib/myprofilelib.php
lib/outputcomponents.php
lib/outputrenderers.php
lib/plist/CODE_OF_CONDUCT.md [new file with mode: 0644]
lib/plist/CONTRIBUTING.md [new file with mode: 0644]
lib/plist/LICENSE.md [moved from lib/plist/LICENSE with 91% similarity]
lib/plist/README.md
lib/plist/classes/CFPropertyList/CFArray.php [new file with mode: 0644]
lib/plist/classes/CFPropertyList/CFBinaryPropertyList.php
lib/plist/classes/CFPropertyList/CFBoolean.php [new file with mode: 0644]
lib/plist/classes/CFPropertyList/CFData.php [new file with mode: 0644]
lib/plist/classes/CFPropertyList/CFDate.php [new file with mode: 0644]
lib/plist/classes/CFPropertyList/CFDictionary.php [new file with mode: 0644]
lib/plist/classes/CFPropertyList/CFNumber.php [new file with mode: 0644]
lib/plist/classes/CFPropertyList/CFPropertyList.php
lib/plist/classes/CFPropertyList/CFString.php [new file with mode: 0644]
lib/plist/classes/CFPropertyList/CFType.php
lib/plist/classes/CFPropertyList/CFTypeDetector.php
lib/plist/classes/CFPropertyList/CFUid.php [new file with mode: 0644]
lib/plist/classes/CFPropertyList/IOException.php
lib/plist/classes/CFPropertyList/PListException.php
lib/plist/readme_moodle.txt
lib/table/amd/build/dynamic.min.js
lib/table/amd/build/dynamic.min.js.map
lib/table/amd/build/local/dynamic/selectors.min.js
lib/table/amd/build/local/dynamic/selectors.min.js.map
lib/table/amd/src/dynamic.js
lib/table/amd/src/local/dynamic/selectors.js
lib/tablelib.php
lib/tests/behat/app_behat_runtime.js
lib/tests/moodlelib_test.php
lib/tests/outputcomponents_test.php
lib/tests/portfoliolib_test.php
lib/thirdpartylibs.xml
lib/upgrade.txt
message/classes/api.php
message/classes/helper.php
message/lib.php
message/output/email/classes/task/send_email_task.php
mod/assign/classes/output/grading_app.php
mod/assign/extensionform.php
mod/assign/feedback/file/locallib.php
mod/assign/gradingtable.php
mod/assign/lib.php
mod/assign/locallib.php
mod/assign/override_form.php
mod/assign/overridedelete.php
mod/assign/overrides.php
mod/chat/lib.php
mod/choice/lib.php
mod/choice/report.php
mod/choice/tests/behat/activity_info_completion_automatic.feature [new file with mode: 0644]
mod/choice/tests/behat/activity_info_completion_manual.feature [new file with mode: 0644]
mod/choice/view.php
mod/data/lib.php
mod/data/locallib.php
mod/data/preset.php
mod/data/view.php
mod/feedback/classes/responses_table.php
mod/feedback/lib.php
mod/forum/classes/form/export_form.php
mod/forum/classes/local/vaults/discussion_list.php
mod/forum/classes/subscriptions.php
mod/forum/deprecatedlib.php
mod/forum/externallib.php
mod/forum/lib.php
mod/forum/renderer.php
mod/forum/report/summary/classes/summary_table.php
mod/forum/rsslib.php
mod/glossary/classes/entry_query_builder.php
mod/glossary/lib.php
mod/glossary/rsslib.php
mod/h5pactivity/mod_form.php
mod/h5pactivity/tests/behat/attempt_options.feature [new file with mode: 0644]
mod/lesson/essay.php
mod/lesson/locallib.php
mod/lesson/override_form.php
mod/lesson/overridedelete.php
mod/lesson/overrides.php
mod/quiz/lib.php
mod/quiz/locallib.php
mod/quiz/override_form.php
mod/quiz/overridedelete.php
mod/quiz/overrides.php
mod/quiz/report/attemptsreport.php
mod/quiz/report/attemptsreport_table.php
mod/quiz/report/grading/report.php
mod/quiz/report/overview/report.php
mod/scorm/report/basic/classes/report.php
mod/scorm/report/interactions/classes/report.php
mod/scorm/report/objectives/classes/report.php
mod/scorm/report/userreport.php
mod/scorm/report/userreportinteractions.php
mod/scorm/report/userreporttracks.php
mod/survey/lib.php
mod/workshop/allocation/manual/lib.php
mod/workshop/lib.php
mod/workshop/locallib.php
mod/workshop/renderer.php
mod/workshop/view.php
question/classes/bank/creator_name_column.php
question/classes/bank/modifier_name_column.php
question/question.php
question/type/ddwtos/tests/behat/add.feature
question/type/edit_question_form.php
question/type/gapselect/edit_form_base.php
question/type/gapselect/questiontypebase.php
question/type/gapselect/tests/behat/add.feature [new file with mode: 0644]
question/type/gapselect/tests/edit_form_test.php
question/type/multichoice/edit_multichoice_form.php
question/type/multichoice/questiontype.php
question/type/multichoice/tests/behat/add.feature
question/type/questiontypebase.php
question/type/shortanswer/edit_shortanswer_form.php
question/type/shortanswer/questiontype.php
question/type/shortanswer/tests/behat/add.feature
question/type/upgrade.txt
rating/classes/external.php
rating/index.php
rating/lib.php
report/completion/index.php
report/configlog/classes/output/report_table.php
report/log/classes/renderable.php
report/log/classes/table_log.php
report/log/locallib.php
report/loglive/classes/table_log.php
report/participation/index.php
report/progress/index.php
report/stats/locallib.php
search/classes/engine.php
tag/classes/manage_table.php
theme/boost/scss/moodle/course.scss
theme/boost/scss/moodle/modules.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
user/action_redir.php
user/amd/build/participants.min.js
user/amd/build/participants.min.js.map
user/amd/src/participants.js
user/classes/external/search_identity.php
user/classes/external/user_summary_exporter.php
user/classes/fields.php [moved from lib/classes/user_fields.php with 93% similarity]
user/classes/output/participants_filter.php
user/classes/search/user.php
user/classes/table/participants.php
user/classes/table/participants_search.php
user/editlib.php
user/index.php
user/lib.php
user/selector/lib.php
user/tests/behat/filter_participants.feature
user/tests/fields_test.php [moved from lib/tests/user_fields_test.php with 84% similarity]
userpix/index.php
version.php
webservice/classes/token_filter.php
webservice/classes/token_form.php
webservice/classes/token_table.php
webservice/lib.php
webservice/renderer.php

index 0d0a6ea..a6d5413 100644 (file)
@@ -1,4 +1,5 @@
 # Generated by "grunt ignorefiles"
+!/.grunt
 */**/yui/src/*/meta/
 */**/build/
 node_modules/
index e44591a..0dce8d8 100644 (file)
--- a/.eslintrc
+++ b/.eslintrc
       }
     },
     {
-      files: ["**/amd/src/*.js", "**/amd/src/**/*.js", "Gruntfile*.js", "babel-plugin-add-module-to-define.js"],
+      files: ["**/amd/src/*.js", "**/amd/src/**/*.js", "Gruntfile.js", ".grunt/*.js", ".grunt/tasks/*.js"],
       // We support es6 now. Woot!
       env: {
         es6: true
index 41e19ac..8bc6f60 100644 (file)
@@ -46,3 +46,4 @@ composer.phar
 atlassian-ide-plugin.xml
 /node_modules/
 /.vscode/
+moodle-plugin-ci.phar
similarity index 98%
rename from babel-plugin-add-module-to-define.js
rename to .grunt/babel-plugin-add-module-to-define.js
index dfe68c6..2e76d31 100644 (file)
@@ -39,7 +39,7 @@ module.exports = ({template, types}) => {
     const fs = require('fs');
     const path = require('path');
     const cwd = process.cwd();
-    const ComponentList = require(path.resolve('GruntfileComponents.js'));
+    const ComponentList = require(path.join(process.cwd(), '.grunt', 'components.js'));
 
     /**
      * Search the list of components that match the given file name
similarity index 81%
rename from GruntfileComponents.js
rename to .grunt/components.js
index 74bd9de..848632d 100644 (file)
@@ -92,6 +92,16 @@ const fetchComponentData = () => {
     return componentData;
 };
 
+/**
+ * Get the list of component paths.
+ *
+ * @param   {string} relativeTo
+ * @returns {array}
+ */
+const getComponentPaths = (relativeTo = '') => fetchComponentData().pathList.map(componentPath => {
+    return componentPath.replace(relativeTo, '');
+});
+
 /**
  * Get the list of paths to build AMD sources.
  *
@@ -140,6 +150,49 @@ const getThirdPartyLibsList = relativeTo => {
         .sort();
 };
 
+/**
+ * Get the list of thirdparty library paths.
+ *
+ * @returns {array}
+ */
+const getThirdPartyPaths = () => {
+    const DOMParser = require('xmldom').DOMParser;
+    const fs = require('fs');
+    const path = require('path');
+    const xpath = require('xpath');
+
+    const thirdpartyfiles = getThirdPartyLibsList(fs.realpathSync('./'));
+    const libs = ['node_modules/', 'vendor/'];
+
+    const addLibToList = lib => {
+        if (!lib.match('\\*') && fs.statSync(lib).isDirectory()) {
+            // Ensure trailing slash on dirs.
+            lib = lib.replace(/\/?$/, '/');
+        }
+
+        // Look for duplicate paths before adding to array.
+        if (libs.indexOf(lib) === -1) {
+            libs.push(lib);
+        }
+    };
+
+    thirdpartyfiles.forEach(function(file) {
+        const dirname = path.dirname(file);
+
+        const xmlContent = fs.readFileSync(file, 'utf8');
+        const doc = new DOMParser().parseFromString(xmlContent);
+        const nodes = xpath.select("/libraries/library/location/text()", doc);
+
+        nodes.forEach(function(node) {
+            let lib = path.posix.join(dirname, node.toString());
+            addLibToList(lib);
+        });
+    });
+
+    return libs;
+
+};
+
 /**
  * Find the name of the component matching the specified path.
  *
@@ -182,7 +235,9 @@ const getOwningComponentDirectory = checkPath => {
 module.exports = {
     getAmdSrcGlobList,
     getComponentFromPath,
+    getComponentPaths,
     getOwningComponentDirectory,
     getYuiSrcGlobList,
     getThirdPartyLibsList,
+    getThirdPartyPaths,
 };
diff --git a/.grunt/tasks/eslint.js b/.grunt/tasks/eslint.js
new file mode 100644 (file)
index 0000000..b7408ac
--- /dev/null
@@ -0,0 +1,64 @@
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright  2021 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+module.exports = grunt => {
+    const files = grunt.moodleEnv.files;
+
+    // Project configuration.
+    grunt.config.merge({
+        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.
+            // To display warnings call: grunt eslint --show-lint-warnings
+            // To fail on warnings call: grunt eslint --max-lint-warnings=0
+            // Also --max-lint-warnings=-1 can be used to display warnings but not fail.
+            options: {
+                quiet: (!grunt.option('show-lint-warnings')) && (typeof grunt.option('max-lint-warnings') === 'undefined'),
+                maxWarnings: ((typeof grunt.option('max-lint-warnings') !== 'undefined') ? grunt.option('max-lint-warnings') : -1)
+            },
+
+            // Check AMD src files.
+            amd: {src: files ? files : grunt.moodleEnv.amdSrc},
+
+            // Check YUI module source files.
+            yui: {src: files ? files : grunt.moodleEnv.yuiSrc},
+        },
+    });
+
+    grunt.loadNpmTasks('grunt-eslint');
+
+    // On watch, we dynamically modify config to build only affected files. This
+    // method is slightly complicated to deal with multiple changed files at once (copied
+    // from the grunt-contrib-watch readme).
+    let changedFiles = Object.create(null);
+    const onChange = grunt.util._.debounce(function() {
+        const files = Object.keys(changedFiles);
+        grunt.config('eslint.amd.src', files);
+        grunt.config('eslint.yui.src', files);
+        changedFiles = Object.create(null);
+    }, 200);
+
+    grunt.event.on('watch', (action, filepath) => {
+        changedFiles[filepath] = action;
+        onChange();
+    });
+};
diff --git a/.grunt/tasks/gherkinlint.js b/.grunt/tasks/gherkinlint.js
new file mode 100644 (file)
index 0000000..cc269a5
--- /dev/null
@@ -0,0 +1,89 @@
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright  2021 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+module.exports = grunt => {
+    /**
+     * Get the list of feature files to pass to the gherkin linter.
+     *
+     * @returns {Array}
+     */
+    const getGherkinLintTargets = () => {
+        if (grunt.moodleEnv.files) {
+            // Specific files were requested. Only check these.
+            return grunt.moodleEnv.files;
+        }
+
+        if (grunt.moodleEnv.inComponent) {
+            return [`${grunt.moodleEnv.runDir}/tests/behat/*.feature`];
+        }
+
+        return ['**/tests/behat/*.feature'];
+    };
+
+    const handler = function() {
+        const done = this.async();
+        const options = grunt.config('gherkinlint.options');
+
+        // Grab the gherkin-lint linter and required scaffolding.
+        const linter = require('gherkin-lint/dist/linter.js');
+        const featureFinder = require('gherkin-lint/dist/feature-finder.js');
+        const configParser = require('gherkin-lint/dist/config-parser.js');
+        const formatter = require('gherkin-lint/dist/formatters/stylish.js');
+
+        // Run the linter.
+        return linter.lint(
+            featureFinder.getFeatureFiles(grunt.file.expand(options.files)),
+            configParser.getConfiguration(configParser.defaultConfigFileName)
+        )
+        .then(results => {
+            // Print the results out uncondtionally.
+            formatter.printResults(results);
+
+            return results;
+        })
+        .then(results => {
+            // Report on the results.
+            // The done function takes a bool whereby a falsey statement causes the task to fail.
+            return results.every(result => result.errors.length === 0);
+        })
+        .then(done); // eslint-disable-line promise/no-callback-in-promise
+    };
+
+    grunt.registerTask('gherkinlint', 'Run gherkinlint against the current directory', handler);
+
+    grunt.config.set('gherkinlint', {
+        options: {
+            files: getGherkinLintTargets(),
+        }
+    });
+
+    grunt.config.merge({
+        watch: {
+            gherkinlint: {
+                files: [grunt.moodleEnv.inComponent ? 'tests/behat/*.feature' : '**/tests/behat/*.feature'],
+                tasks: ['gherkinlint'],
+            },
+        },
+    });
+
+    return handler;
+};
diff --git a/.grunt/tasks/ignorefiles.js b/.grunt/tasks/ignorefiles.js
new file mode 100644 (file)
index 0000000..d8b9ec1
--- /dev/null
@@ -0,0 +1,59 @@
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright  2021 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+module.exports = grunt => {
+    /**
+     * Generate ignore files (utilising thirdpartylibs.xml data)
+     */
+    const handler = function() {
+        const path = require('path');
+        const ComponentList = require(path.join(process.cwd(), '.grunt', 'components.js'));
+
+        // An array of paths to third party directories.
+        const thirdPartyPaths = ComponentList.getThirdPartyPaths();
+
+        // Generate .eslintignore.
+        const eslintIgnores = [
+            '# Generated by "grunt ignorefiles"',
+            // Do not ignore the .grunt directory.
+            '!/.grunt',
+
+            // Ignore all yui/src meta directories and build directories.
+            '*/**/yui/src/*/meta/',
+            '*/**/build/',
+        ].concat(thirdPartyPaths);
+        grunt.file.write('.eslintignore', eslintIgnores.join('\n'));
+
+        // Generate .stylelintignore.
+        const stylelintIgnores = [
+            '# Generated by "grunt ignorefiles"',
+            '**/yui/build/*',
+            'theme/boost/style/moodle.css',
+            'theme/classic/style/moodle.css',
+        ].concat(thirdPartyPaths);
+        grunt.file.write('.stylelintignore', stylelintIgnores.join('\n'));
+    };
+
+    grunt.registerTask('ignorefiles', 'Generate ignore files for linters', handler);
+
+    return handler;
+};
diff --git a/.grunt/tasks/javascript.js b/.grunt/tasks/javascript.js
new file mode 100644 (file)
index 0000000..4cc309c
--- /dev/null
@@ -0,0 +1,141 @@
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright  2021 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Function to generate the destination for the uglify task
+ * (e.g. build/file.min.js). This function will be passed to
+ * the rename property of files array when building dynamically:
+ * http://gruntjs.com/configuring-tasks#building-the-files-object-dynamically
+ *
+ * @param {String} destPath the current destination
+ * @param {String} srcPath the  matched src path
+ * @return {String} The rewritten destination path.
+ */
+const babelRename = function(destPath, srcPath) {
+    destPath = srcPath.replace('src', 'build');
+    destPath = destPath.replace('.js', '.min.js');
+    return destPath;
+};
+
+module.exports = grunt => {
+    // Load the Shifter tasks.
+    require('./shifter')(grunt);
+
+    // Load ESLint.
+    require('./eslint')(grunt);
+
+    const path = require('path');
+
+    // Register JS tasks.
+    grunt.registerTask('yui', ['eslint:yui', 'shifter']);
+    grunt.registerTask('amd', ['eslint:amd', 'babel']);
+    grunt.registerTask('js', ['amd', 'yui']);
+
+    // Register NPM tasks.
+    grunt.loadNpmTasks('grunt-contrib-uglify');
+    grunt.loadNpmTasks('grunt-contrib-watch');
+
+    // Load the Babel tasks and config.
+    grunt.loadNpmTasks('grunt-babel');
+    grunt.config.merge({
+        babel: {
+            options: {
+                sourceMaps: true,
+                comments: false,
+                plugins: [
+                    'transform-es2015-modules-amd-lazy',
+                    'system-import-transformer',
+                    // This plugin modifies the Babel transpiling for "export default"
+                    // so that if it's used then only the exported value is returned
+                    // by the generated AMD module.
+                    //
+                    // It also adds the Moodle plugin name to the AMD module definition
+                    // so that it can be imported as expected in other modules.
+                    path.resolve('.grunt/babel-plugin-add-module-to-define.js'),
+                    '@babel/plugin-syntax-dynamic-import',
+                    '@babel/plugin-syntax-import-meta',
+                    ['@babel/plugin-proposal-class-properties', {'loose': false}],
+                    '@babel/plugin-proposal-json-strings'
+                ],
+                presets: [
+                    ['minify', {
+                        // This minification plugin needs to be disabled because it breaks the
+                        // source map generation and causes invalid source maps to be output.
+                        simplify: false,
+                        builtIns: false
+                    }],
+                    ['@babel/preset-env', {
+                        targets: {
+                            browsers: [
+                                ">0.25%",
+                                "last 2 versions",
+                                "not ie <= 10",
+                                "not op_mini all",
+                                "not Opera > 0",
+                                "not dead"
+                            ]
+                        },
+                        modules: false,
+                        useBuiltIns: false
+                    }]
+                ]
+            },
+            dist: {
+                files: [{
+                    expand: true,
+                    src: grunt.moodleEnv.files ? grunt.moodleEnv.files : grunt.moodleEnv.amdSrc,
+                    rename: babelRename
+                }]
+            }
+        },
+    });
+
+    grunt.config.merge({
+        watch: {
+            amd: {
+                files: grunt.moodleEnv.inComponent
+                    ? ['amd/src/*.js', 'amd/src/**/*.js']
+                    : ['**/amd/src/**/*.js'],
+                tasks: ['amd']
+            },
+        },
+    });
+
+    // On watch, we dynamically modify config to build only affected files. This
+    // method is slightly complicated to deal with multiple changed files at once (copied
+    // from the grunt-contrib-watch readme).
+    let changedFiles = Object.create(null);
+    const onChange = grunt.util._.debounce(function() {
+        const files = Object.keys(changedFiles);
+        grunt.config('babel.dist.files', [{expand: true, src: files, rename: babelRename}]);
+        changedFiles = Object.create(null);
+    }, 200);
+
+    grunt.event.on('watch', function(action, filepath) {
+        changedFiles[filepath] = action;
+        onChange();
+    });
+
+    return {
+        babelRename,
+    };
+};
diff --git a/.grunt/tasks/sass.js b/.grunt/tasks/sass.js
new file mode 100644 (file)
index 0000000..cdb8bb1
--- /dev/null
@@ -0,0 +1,40 @@
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright  2021 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+module.exports = grunt => {
+    grunt.loadNpmTasks('grunt-sass');
+
+    grunt.config.merge({
+        sass: {
+            dist: {
+                files: {
+                    "theme/boost/style/moodle.css": "theme/boost/scss/preset/default.scss",
+                    "theme/classic/style/moodle.css": "theme/classic/scss/classicgrunt.scss"
+                }
+            },
+            options: {
+                implementation: require('node-sass'),
+                includePaths: ["theme/boost/scss/", "theme/classic/scss/"]
+            }
+        },
+    });
+};
diff --git a/.grunt/tasks/shifter.js b/.grunt/tasks/shifter.js
new file mode 100644 (file)
index 0000000..b364af5
--- /dev/null
@@ -0,0 +1,155 @@
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright  2021 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/* eslint-env node */
+
+module.exports = grunt => {
+    /**
+     * 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.
+     *
+     * Note that this task runs the invidiaul shifter jobs async (becase it spawns
+     * so be careful to to call done().
+     */
+    const handler = function() {
+        const done = this.async();
+        const options = grunt.config('shifter.options');
+        const async = require('async');
+        const path = require('path');
+
+        // Run the shifter processes one at a time to avoid confusing output.
+        async.eachSeries(options.paths, function(src, filedone) {
+            var args = [];
+            args.push(path.normalize(process.cwd() + '/node_modules/shifter/bin/shifter'));
+
+            // Always ignore the node_modules directory.
+            args.push('--excludes', 'node_modules');
+
+            // Determine the most appropriate options to run with based upon the current location.
+            if (grunt.file.isMatch('**/yui/**/*.js', src)) {
+                // When passed a JS file, build our containing module (this happen with
+                // watch).
+                grunt.log.debug('Shifter passed a specific JS file');
+                src = path.dirname(path.dirname(src));
+                options.recursive = false;
+            } else if (grunt.file.isMatch('**/yui/src', src)) {
+                // When in a src directory --walk all modules.
+                grunt.log.debug('In a src directory');
+                args.push('--walk');
+                options.recursive = false;
+            } else if (grunt.file.isMatch('**/yui/src/*', src)) {
+                // When in module, only build our module.
+                grunt.log.debug('In a module directory');
+                options.recursive = false;
+            } else if (grunt.file.isMatch('**/yui/src/*/js', src)) {
+                // When in module src, only build our module.
+                grunt.log.debug('In a source directory');
+                src = path.dirname(src);
+                options.recursive = false;
+            }
+
+            if (grunt.option('watch')) {
+                grunt.fail.fatal('The --watch option has been removed, please use `grunt watch` instead');
+            }
+
+            // Add the stderr option if appropriate
+            if (grunt.option('verbose')) {
+                args.push('--lint-stderr');
+            }
+
+            if (grunt.option('no-color')) {
+                args.push('--color=false');
+            }
+
+            var execShifter = function() {
+
+                grunt.log.ok("Running shifter on " + src);
+                grunt.util.spawn({
+                    cmd: "node",
+                    args: args,
+                    opts: {cwd: src, stdio: 'inherit', env: process.env}
+                }, function(error, result, code) {
+                    if (code) {
+                        grunt.fail.fatal('Shifter failed with code: ' + code);
+                    } else {
+                        grunt.log.ok('Shifter build complete.');
+                        filedone();
+                    }
+                });
+            };
+
+            // Actually run shifter.
+            if (!options.recursive) {
+                execShifter();
+            } else {
+                // Check that there are yui modules otherwise shifter ends with exit code 1.
+                if (grunt.file.expand({cwd: src}, '**/yui/src/**/*.js').length > 0) {
+                    args.push('--recursive');
+                    execShifter();
+                } else {
+                    grunt.log.ok('No YUI modules to build.');
+                    filedone();
+                }
+            }
+        }, done);
+    };
+
+    // Register the shifter task.
+    grunt.registerTask('shifter', 'Run Shifter against the current directory', handler);
+
+    // Configure it.
+    grunt.config.set('shifter', {
+        options: {
+            recursive: true,
+            // Shifter takes a relative path.
+            paths: grunt.moodleEnv.files ? grunt.moodleEnv.files : [grunt.moodleEnv.runDir]
+        }
+    });
+
+    grunt.config.merge({
+        watch: {
+            yui: {
+                files: grunt.moodleEnv.inComponent
+                    ? ['yui/src/*.json', 'yui/src/**/*.js']
+                    : ['**/yui/src/**/*.js'],
+                tasks: ['yui']
+            },
+        },
+    });
+
+    // On watch, we dynamically modify config to build only affected files. This
+    // method is slightly complicated to deal with multiple changed files at once (copied
+    // from the grunt-contrib-watch readme).
+    let changedFiles = Object.create(null);
+    const onChange = grunt.util._.debounce(function() {
+        const files = Object.keys(changedFiles);
+        grunt.config('shifter.options.paths', files);
+        changedFiles = Object.create(null);
+    }, 200);
+
+    grunt.event.on('watch', (action, filepath) => {
+        changedFiles[filepath] = action;
+        onChange();
+    });
+
+    return handler;
+};
diff --git a/.grunt/tasks/startup.js b/.grunt/tasks/startup.js
new file mode 100644 (file)
index 0000000..b532852
--- /dev/null
@@ -0,0 +1,48 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright  2021 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+module.exports = grunt => {
+    /**
+     * Generate ignore files (utilising thirdpartylibs.xml data)
+     */
+    const handler = function() {
+        const path = require('path');
+
+        // Are we in a YUI directory?
+        if (path.basename(path.resolve(grunt.moodleEnv.cwd, '../../')) == 'yui') {
+            grunt.task.run('yui');
+        // Are we in an AMD directory?
+        } else if (grunt.moodleEnv.inAMD) {
+            grunt.task.run('amd');
+        } else {
+            // Run them all!.
+            grunt.task.run('css');
+            grunt.task.run('js');
+            grunt.task.run('gherkinlint');
+        }
+    };
+
+    // Register the startup task.
+    grunt.registerTask('startup', 'Run the correct tasks for the current directory', handler);
+
+    return handler;
+};
diff --git a/.grunt/tasks/style.js b/.grunt/tasks/style.js
new file mode 100644 (file)
index 0000000..05f713c
--- /dev/null
@@ -0,0 +1,29 @@
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright  2021 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+module.exports = grunt => {
+    // Load the Style Lint tasks.
+    require('./stylelint')(grunt);
+
+    // Load the SASS tasks.
+    require('./sass')(grunt);
+};
diff --git a/.grunt/tasks/stylelint.js b/.grunt/tasks/stylelint.js
new file mode 100644 (file)
index 0000000..4da9067
--- /dev/null
@@ -0,0 +1,181 @@
+// 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/>.
+/* jshint node: true, browser: false */
+/* eslint-env node */
+
+/**
+ * @copyright  2021 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+module.exports = grunt => {
+
+    const getCssConfigForFiles = files => {
+        return {
+            stylelint: {
+                css: {
+                    // Use a fully-qualified path.
+                    src: files,
+                    options: {
+                        configOverrides: {
+                            rules: {
+                                // These rules have to be disabled in .stylelintrc for scss compat.
+                                "at-rule-no-unknown": true,
+                            }
+                        }
+                    }
+                },
+            },
+        };
+    };
+
+    const getScssConfigForFiles = files => {
+        return {
+            stylelint: {
+                scss: {
+                    options: {syntax: 'scss'},
+                    src: files,
+                },
+            },
+        };
+    };
+
+    /**
+     * Register any stylelint tasks.
+     *
+     * @param {Object} grunt
+     * @param {Array} files
+     * @param {String} fullRunDir
+     */
+    const registerStyleLintTasks = () => {
+        const glob = require('glob');
+
+        // The stylelinters do not handle the case where a configuration was provided but no files were included.
+        // Keep track of whether any files were found.
+        let hasCss = false;
+        let hasScss = false;
+
+        // The stylelint processors do not take a path argument. They always check all provided values.
+        // As a result we must check through each glob and determine if any files match the current directory.
+        const scssFiles = [];
+        const cssFiles = [];
+
+        const requestedFiles = grunt.moodleEnv.files;
+        if (requestedFiles) {
+            // Grunt was called with a files argument.
+            // Check whether each of the requested files matches either the CSS or SCSS source file list.
+
+            requestedFiles.forEach(changedFilePath => {
+                let matchesGlob;
+
+                // Check whether this watched path matches any watched SCSS file.
+                matchesGlob = grunt.moodleEnv.scssSrc.some(watchedPathGlob => {
+                    return glob.sync(watchedPathGlob).indexOf(changedFilePath) !== -1;
+                });
+                if (matchesGlob) {
+                    scssFiles.push(changedFilePath);
+                    hasScss = true;
+                }
+
+                // Check whether this watched path matches any watched CSS file.
+                matchesGlob = grunt.moodleEnv.cssSrc.some(watchedPathGlob => {
+                    return glob.sync(watchedPathGlob).indexOf(changedFilePath) !== -1;
+                });
+                if (matchesGlob) {
+                    cssFiles.push(changedFilePath);
+                    hasCss = true;
+                }
+            });
+        } else {
+            // Grunt was called without a list of files.
+            // The start directory (runDir) may be a child dir of the project.
+            // Check each scssSrc file to see if it's in the start directory.
+            // This means that we can lint just mod/*/styles.css if started in the mod directory.
+
+            grunt.moodleEnv.scssSrc.forEach(path => {
+                if (path.startsWith(grunt.moodleEnv.runDir)) {
+                    scssFiles.push(path);
+                    hasScss = true;
+                }
+            });
+
+            grunt.moodleEnv.cssSrc.forEach(path => {
+                if (path.startsWith(grunt.moodleEnv.runDir)) {
+                    cssFiles.push(path);
+                    hasCss = true;
+                }
+            });
+        }
+
+        // Register the tasks.
+        const scssTasks = ['sass'];
+        if (hasScss) {
+            grunt.config.merge(getScssConfigForFiles(scssFiles));
+            scssTasks.unshift('stylelint:scss');
+        }
+
+        const cssTasks = [];
+        if (hasCss) {
+            grunt.config.merge(getCssConfigForFiles(cssFiles));
+            cssTasks.push('stylelint:css');
+        }
+
+        // The tasks must be registered, even if empty to ensure a consistent command list.
+        // They jsut won't run anything.
+        grunt.registerTask('scss', scssTasks);
+        grunt.registerTask('rawcss', cssTasks);
+    };
+
+    // Register CSS tasks.
+    grunt.loadNpmTasks('grunt-stylelint');
+
+    // Register the style lint tasks.
+    registerStyleLintTasks();
+    grunt.registerTask('css', ['scss', 'rawcss']);
+
+    const getCoreThemeMatches = () => {
+        const scssMatch = 'scss/**/*.scss';
+
+        if (grunt.moodleEnv.inTheme) {
+            return [scssMatch];
+        }
+
+        if (grunt.moodleEnv.runDir.startsWith('theme')) {
+            return [`*/${scssMatch}`];
+        }
+
+        return [`theme/*/${scssMatch}`];
+    };
+
+    // Add the watch configuration for rawcss, and scss.
+    grunt.config.merge({
+        watch: {
+            rawcss: {
+                files: [
+                    '**/*.css',
+                ],
+                excludes: [
+                    '**/moodle.css',
+                    '**/editor.css',
+                ],
+                tasks: ['rawcss']
+            },
+            scss: {
+                files: getCoreThemeMatches(),
+                tasks: ['scss']
+            },
+        },
+    });
+};
diff --git a/.grunt/tasks/watch.js b/.grunt/tasks/watch.js
new file mode 100644 (file)
index 0000000..25c6487
--- /dev/null
@@ -0,0 +1,272 @@
+/**
+ * This is a wrapper task to handle the grunt watch command. It attempts to use
+ * Watchman to monitor for file changes, if it's installed, because it's much faster.
+ *
+ * If Watchman isn't installed then it falls back to the grunt-contrib-watch file
+ * watcher for backwards compatibility.
+ */
+
+/* eslint-env node */
+
+module.exports = grunt => {
+    /**
+     * This is a wrapper task to handle the grunt watch command. It attempts to use
+     * Watchman to monitor for file changes, if it's installed, because it's much faster.
+     *
+     * If Watchman isn't installed then it falls back to the grunt-contrib-watch file
+     * watcher for backwards compatibility.
+     */
+    const watchHandler = function() {
+        const async = require('async');
+        const watchTaskDone = this.async();
+        let watchInitialised = false;
+        let watchTaskQueue = {};
+        let processingQueue = false;
+
+        const watchman = require('fb-watchman');
+        const watchmanClient = new watchman.Client();
+
+        // Grab the tasks and files that have been queued up and execute them.
+        var processWatchTaskQueue = function() {
+            if (!Object.keys(watchTaskQueue).length || processingQueue) {
+                // If there is nothing in the queue or we're already processing then wait.
+                return;
+            }
+
+            processingQueue = true;
+
+            // Grab all tasks currently in the queue.
+            var queueToProcess = watchTaskQueue;
+            // Reset the queue.
+            watchTaskQueue = {};
+
+            async.forEachSeries(
+                Object.keys(queueToProcess),
+                function(task, next) {
+                    var files = queueToProcess[task];
+                    var filesOption = '--files=' + files.join(',');
+                    grunt.log.ok('Running task ' + task + ' for files ' + filesOption);
+
+                    // Spawn the task in a child process so that it doesn't kill this one
+                    // if it failed.
+                    grunt.util.spawn(
+                        {
+                            // Spawn with the grunt bin.
+                            grunt: true,
+                            // Run from current working dir and inherit stdio from process.
+                            opts: {
+                                cwd: grunt.moodleEnv.fullRunDir,
+                                stdio: 'inherit'
+                            },
+                            args: [task, filesOption]
+                        },
+                        function(err, res, code) {
+                            if (code !== 0) {
+                                // The grunt task failed.
+                                grunt.log.error(err);
+                            }
+
+                            // Move on to the next task.
+                            next();
+                        }
+                    );
+                },
+                function() {
+                    // No longer processing.
+                    processingQueue = false;
+                    // Once all of the tasks are done then recurse just in case more tasks
+                    // were queued while we were processing.
+                    processWatchTaskQueue();
+                }
+            );
+        };
+
+        const originalWatchConfig = grunt.config.get(['watch']);
+        const watchConfig = Object.keys(originalWatchConfig).reduce(function(carry, key) {
+            if (key == 'options') {
+                return carry;
+            }
+
+            const value = originalWatchConfig[key];
+
+            const taskNames = value.tasks;
+            const files = value.files;
+            let excludes = [];
+            if (value.excludes) {
+                excludes = value.excludes;
+            }
+
+            taskNames.forEach(function(taskName) {
+                carry[taskName] = {
+                    files,
+                    excludes,
+                };
+            });
+
+            return carry;
+        }, {});
+
+        watchmanClient.on('error', function(error) {
+            // We have to add an error handler here and parse the error string because the
+            // example way from the docs to check if Watchman is installed doesn't actually work!!
+            // See: https://github.com/facebook/watchman/issues/509
+            if (error.message.match('Watchman was not found')) {
+                // If watchman isn't installed then we should fallback to the other watch task.
+                grunt.log.ok('It is recommended that you install Watchman for better performance using the "watch" command.');
+
+                // Fallback to the old grunt-contrib-watch task.
+                grunt.renameTask('watch-grunt', 'watch');
+                grunt.task.run(['watch']);
+                // This task is finished.
+                watchTaskDone(0);
+            } else {
+                grunt.log.error(error);
+                // Fatal error.
+                watchTaskDone(1);
+            }
+        });
+
+        watchmanClient.on('subscription', function(resp) {
+            if (resp.subscription !== 'grunt-watch') {
+                return;
+            }
+
+            resp.files.forEach(function(file) {
+                grunt.log.ok('File changed: ' + file.name);
+
+                var fullPath = grunt.moodleEnv.fullRunDir + '/' + file.name;
+                Object.keys(watchConfig).forEach(function(task) {
+
+                    const fileGlobs = watchConfig[task].files;
+                    var match = fileGlobs.some(function(fileGlob) {
+                        return grunt.file.isMatch(`**/${fileGlob}`, fullPath);
+                    });
+
+                    if (match) {
+                        // If we are watching a subdirectory then the file.name will be relative
+                        // to that directory. However the grunt tasks  expect the file paths to be
+                        // relative to the Gruntfile.js location so let's normalise them before
+                        // adding them to the queue.
+                        var relativePath = fullPath.replace(grunt.moodleEnv.gruntFilePath + '/', '');
+                        if (task in watchTaskQueue) {
+                            if (!watchTaskQueue[task].includes(relativePath)) {
+                                watchTaskQueue[task] = watchTaskQueue[task].concat(relativePath);
+                            }
+                        } else {
+                            watchTaskQueue[task] = [relativePath];
+                        }
+                    }
+                });
+            });
+
+            processWatchTaskQueue();
+        });
+
+        process.on('SIGINT', function() {
+            // Let the user know that they may need to manually stop the Watchman daemon if they
+            // no longer want it running.
+            if (watchInitialised) {
+                grunt.log.ok('The Watchman daemon may still be running and may need to be stopped manually.');
+            }
+
+            process.exit();
+        });
+
+        // Initiate the watch on the current directory.
+        watchmanClient.command(['watch-project', grunt.moodleEnv.fullRunDir], function(watchError, watchResponse) {
+            if (watchError) {
+                grunt.log.error('Error initiating watch:', watchError);
+                watchTaskDone(1);
+                return;
+            }
+
+            if ('warning' in watchResponse) {
+                grunt.log.error('warning: ', watchResponse.warning);
+            }
+
+            var watch = watchResponse.watch;
+            var relativePath = watchResponse.relative_path;
+            watchInitialised = true;
+
+            watchmanClient.command(['clock', watch], function(clockError, clockResponse) {
+                if (clockError) {
+                    grunt.log.error('Failed to query clock:', clockError);
+                    watchTaskDone(1);
+                    return;
+                }
+
+                // Generate the expression query used by watchman.
+                // Documentation is limited, but see https://facebook.github.io/watchman/docs/expr/allof.html for examples.
+                // We generate an expression to match any value in the files list of all of our tasks, but excluding
+                // all value in the  excludes list of that task.
+                //
+                // [anyof, [
+                //      [allof, [
+                //          [anyof, [
+                //              ['match', validPath, 'wholename'],
+                //              ['match', validPath, 'wholename'],
+                //          ],
+                //          [not,
+                //              [anyof, [
+                //                  ['match', invalidPath, 'wholename'],
+                //                  ['match', invalidPath, 'wholename'],
+                //              ],
+                //          ],
+                //      ],
+                var matchWholeName = fileGlob => ['match', fileGlob, 'wholename'];
+                var matches = Object.keys(watchConfig).map(function(task) {
+                    const matchAll = [];
+                    matchAll.push(['anyof'].concat(watchConfig[task].files.map(matchWholeName)));
+
+                    if (watchConfig[task].excludes.length) {
+                        matchAll.push(['not', ['anyof'].concat(watchConfig[task].excludes.map(matchWholeName))]);
+                    }
+
+                    return ['allof'].concat(matchAll);
+                });
+
+                matches = ['anyof'].concat(matches);
+
+                var sub = {
+                    expression: matches,
+                    // Which fields we're interested in.
+                    fields: ["name", "size", "type"],
+                    // Add our time constraint.
+                    since: clockResponse.clock
+                };
+
+                if (relativePath) {
+                    /* eslint-disable camelcase */
+                    sub.relative_root = relativePath;
+                }
+
+                watchmanClient.command(['subscribe', watch, 'grunt-watch', sub], function(subscribeError) {
+                    if (subscribeError) {
+                        // Probably an error in the subscription criteria.
+                        grunt.log.error('failed to subscribe: ', subscribeError);
+                        watchTaskDone(1);
+                        return;
+                    }
+
+                    grunt.log.ok('Listening for changes to files in ' + grunt.moodleEnv.fullRunDir);
+                });
+            });
+        });
+    };
+
+    // Rename the grunt-contrib-watch "watch" task because we're going to wrap it.
+    grunt.renameTask('watch', 'watch-grunt');
+
+    // Register the new watch handler.
+    grunt.registerTask('watch', 'Run tasks on file changes', watchHandler);
+
+    grunt.config.merge({
+        watch: {
+            options: {
+                nospawn: true // We need not to spawn so config can be changed dynamically.
+            },
+        },
+    });
+
+    return watchHandler;
+};
index a7b728f..89836af 100644 (file)
 /* eslint-env node */
 
 /**
+ * Grunt configuration for Moodle.
+ *
  * @copyright  2014 Andrew Nicols
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-/* eslint-env node */
-
 /**
- * Calculate the cwd, taking into consideration the `root` option (for Windows).
+ * Setup the Grunt Moodle environment.
  *
- * @param {Object} grunt
- * @returns {String} The current directory as best we can determine
+ * @param   {Grunt} grunt
+ * @returns {Object}
  */
-const getCwd = grunt => {
+const setupMoodleEnvironment = grunt => {
     const fs = require('fs');
     const path = require('path');
+    const ComponentList = require(path.join(process.cwd(), '.grunt', 'components.js'));
 
-    let cwd = fs.realpathSync(process.env.PWD || process.cwd());
+    const getAmdConfiguration = () => {
+        // If the cwd is the amd directory in the current component then it will be empty.
+        // If the cwd is a child of the component's AMD directory, the relative directory will not start with ..
+        let inAMD = !path.relative(`${componentDirectory}/amd`, cwd).startsWith('..');
 
-    // Windows users can't run grunt in a subdirectory, so allow them to set
-    // the root by passing --root=path/to/dir.
-    if (grunt.option('root')) {
-        const root = grunt.option('root');
-        if (grunt.file.exists(__dirname, root)) {
-            cwd = fs.realpathSync(path.join(__dirname, root));
-            grunt.log.ok('Setting root to ' + cwd);
+        // Globbing pattern for matching all AMD JS source files.
+        let amdSrc = [];
+        if (inComponent) {
+            amdSrc.push(
+                componentDirectory + "/amd/src/*.js",
+                componentDirectory + "/amd/src/**/*.js"
+            );
         } else {
-            grunt.fail.fatal('Setting root to ' + root + ' failed - path does not exist');
+            amdSrc = ComponentList.getAmdSrcGlobList();
         }
-    }
 
-    return cwd;
-};
-
-/**
- * Register any stylelint tasks.
- *
- * @param {Object} grunt
- * @param {Array} files
- * @param {String} fullRunDir
- */
-const registerStyleLintTasks = (grunt, files, fullRunDir) => {
-    const getCssConfigForFiles = files => {
         return {
-            stylelint: {
-                css: {
-                    // Use a fully-qualified path.
-                    src: files,
-                    options: {
-                        configOverrides: {
-                            rules: {
-                                // These rules have to be disabled in .stylelintrc for scss compat.
-                                "at-rule-no-unknown": true,
-                            }
-                        }
-                    }
-                },
-            },
+            inAMD,
+            amdSrc,
         };
     };
 
-    const getScssConfigForFiles = files => {
+    const getYuiConfiguration = () => {
+        let yuiSrc = [];
+        if (inComponent) {
+            yuiSrc.push(componentDirectory + "/yui/src/**/*.js");
+        } else {
+            yuiSrc = ComponentList.getYuiSrcGlobList(gruntFilePath + '/');
+        }
+
         return {
-            stylelint: {
-                scss: {
-                    options: {syntax: 'scss'},
-                    src: files,
-                },
-            },
+            yuiSrc,
         };
     };
 
-    let hasCss = true;
-    let hasScss = true;
-
-    if (files) {
-        // Specific files were passed. Just set them up.
-        grunt.config.merge(getCssConfigForFiles(files));
-        grunt.config.merge(getScssConfigForFiles(files));
-    } else {
-        // The stylelint system does not handle the case where there was no file to lint.
-        // Check whether there are any files to lint in the current directory.
-        const glob = require('glob');
-
-        const scssSrc = [];
-        glob.sync(`${fullRunDir}/**/*.scss`).forEach(path => scssSrc.push(path));
+    const getStyleConfiguration = () => {
+        const ComponentList = require(path.join(process.cwd(), '.grunt', 'components.js'));
+        // Build the cssSrc and scssSrc.
+        // Valid paths are:
+        // [component]/styles.css; and either
+        // [theme/[themename]]/scss/**/*.scss; or
+        // [theme/[themename]]/style/*.css.
+        //
+        // If a theme has scss, then it is assumed that the style directory contains generated content.
+        let cssSrc = [];
+        let scssSrc = [];
+
+        const checkComponentDirectory = componentDirectory => {
+            const isTheme = componentDirectory.startsWith('theme/');
+            if (isTheme) {
+                const scssDirectory = `${componentDirectory}/scss`;
+
+                if (fs.existsSync(scssDirectory)) {
+                    // This theme has an SCSS directory.
+                    // Include all scss files within it recursively, but do not check for css files.
+                    scssSrc.push(`${scssDirectory}/*.scss`);
+                    scssSrc.push(`${scssDirectory}/**/*.scss`);
+                } else {
+                    // This theme has no SCSS directory.
+                    // Only hte CSS files in the top-level directory are checked.
+                    cssSrc.push(`${componentDirectory}/style/*.css`);
+                }
+            } else {
+                // This is not a theme.
+                // All other plugin types are restricted to a single styles.css in their top level.
+                cssSrc.push(`${componentDirectory}/styles.css`);
+            }
+        };
 
-        if (scssSrc.length) {
-            grunt.config.merge(getScssConfigForFiles(scssSrc));
+        if (inComponent) {
+            checkComponentDirectory(componentDirectory);
         } else {
-            hasScss = false;
+            ComponentList.getComponentPaths(`${gruntFilePath}/`).forEach(componentPath => {
+                checkComponentDirectory(componentPath);
+            });
         }
 
-        const cssSrc = [];
-        glob.sync(`${fullRunDir}/**/*.css`).forEach(path => cssSrc.push(path));
+        return {
+            cssSrc,
+            scssSrc,
+        };
+    };
 
-        if (cssSrc.length) {
-            grunt.config.merge(getCssConfigForFiles(cssSrc));
-        } else {
-            hasCss = false;
+    /**
+     * Calculate the cwd, taking into consideration the `root` option (for Windows).
+     *
+     * @param {Object} grunt
+     * @returns {String} The current directory as best we can determine
+     */
+    const getCwd = grunt => {
+        let cwd = fs.realpathSync(process.env.PWD || process.cwd());
+
+        // Windows users can't run grunt in a subdirectory, so allow them to set
+        // the root by passing --root=path/to/dir.
+        if (grunt.option('root')) {
+            const root = grunt.option('root');
+            if (grunt.file.exists(__dirname, root)) {
+                cwd = fs.realpathSync(path.join(__dirname, root));
+                grunt.log.ok('Setting root to ' + cwd);
+            } else {
+                grunt.fail.fatal('Setting root to ' + root + ' failed - path does not exist');
+            }
         }
-    }
-
-    const scssTasks = ['sass'];
-    if (hasScss) {
-        scssTasks.unshift('stylelint:scss');
-    }
-    grunt.registerTask('scss', scssTasks);
-
-    const cssTasks = [];
-    if (hasCss) {
-        cssTasks.push('stylelint:css');
-    }
-    grunt.registerTask('rawcss', cssTasks);
-
-    grunt.registerTask('css', ['scss', 'rawcss']);
-};
-
-/**
- * Grunt configuration.
- *
- * @param {Object} grunt
- */
-module.exports = function(grunt) {
-    const path = require('path');
-    const tasks = {};
-    const async = require('async');
-    const DOMParser = require('xmldom').DOMParser;
-    const xpath = require('xpath');
-    const semver = require('semver');
-    const watchman = require('fb-watchman');
-    const watchmanClient = new watchman.Client();
-    const fs = require('fs');
-    const ComponentList = require(path.resolve('GruntfileComponents.js'));
-    const sass = require('node-sass');
 
-    // Verify the node version is new enough.
-    var expected = semver.validRange(grunt.file.readJSON('package.json').engines.node);
-    var actual = semver.valid(process.version);
-    if (!semver.satisfies(actual, expected)) {
-        grunt.fail.fatal('Node version not satisfied. Require ' + expected + ', version installed: ' + actual);
-    }
+        return cwd;
+    };
 
     // Detect directories:
     // * gruntFilePath          The real path on disk to this Gruntfile.js
@@ -171,8 +153,19 @@ module.exports = function(grunt) {
     const relativeCwd = path.relative(gruntFilePath, cwd);
     const componentDirectory = ComponentList.getOwningComponentDirectory(relativeCwd);
     const inComponent = !!componentDirectory;
+    const inTheme = !!componentDirectory && componentDirectory.startsWith('theme/');
     const runDir = inComponent ? componentDirectory : relativeCwd;
     const fullRunDir = fs.realpathSync(gruntFilePath + path.sep + runDir);
+    const {inAMD, amdSrc} = getAmdConfiguration();
+    const {yuiSrc} = getYuiConfiguration();
+    const {cssSrc, scssSrc} = getStyleConfiguration();
+
+    let files = null;
+    if (grunt.option('files')) {
+        // Accept a comma separated list of files to process.
+        files = grunt.option('files').split(',');
+    }
+
     grunt.log.debug('============================================================================');
     grunt.log.debug(`= Node version:        ${process.versions.node}`);
     grunt.log.debug(`= grunt version:       ${grunt.package.version}`);
@@ -192,667 +185,77 @@ module.exports = function(grunt) {
         grunt.log.ok(`Running tasks for component directory ${componentDirectory}`);
     }
 
-    let files = null;
-    if (grunt.option('files')) {
-        // Accept a comma separated list of files to process.
-        files = grunt.option('files').split(',');
-    }
-
-    // If the cwd is the amd directory in the current component then it will be empty.
-    // If the cwd is a child of the component's AMD directory, the relative directory will not start with ..
-    const inAMD = !path.relative(`${componentDirectory}/amd`, cwd).startsWith('..');
-
-    // Globbing pattern for matching all AMD JS source files.
-    let amdSrc = [];
-    if (inComponent) {
-        amdSrc.push(componentDirectory + "/amd/src/*.js");
-        amdSrc.push(componentDirectory + "/amd/src/**/*.js");
-    } else {
-        amdSrc = ComponentList.getAmdSrcGlobList();
-    }
-
-    let yuiSrc = [];
-    if (inComponent) {
-        yuiSrc.push(componentDirectory + "/yui/src/**/*.js");
-    } else {
-        yuiSrc = ComponentList.getYuiSrcGlobList(gruntFilePath + '/');
-    }
-
-    /**
-     * Function to generate the destination for the uglify task
-     * (e.g. build/file.min.js). This function will be passed to
-     * the rename property of files array when building dynamically:
-     * http://gruntjs.com/configuring-tasks#building-the-files-object-dynamically
-     *
-     * @param {String} destPath the current destination
-     * @param {String} srcPath the  matched src path
-     * @return {String} The rewritten destination path.
-     */
-    var babelRename = function(destPath, srcPath) {
-        destPath = srcPath.replace('src', 'build');
-        destPath = destPath.replace('.js', '.min.js');
-        return destPath;
+    return {
+        amdSrc,
+        componentDirectory,
+        cwd,
+        cssSrc,
+        files,
+        fullRunDir,
+        gruntFilePath,
+        inAMD,
+        inComponent,
+        inTheme,
+        relativeCwd,
+        runDir,
+        scssSrc,
+        yuiSrc,
     };
+};
 
-    /**
-     * 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() {
-        const thirdpartyfiles = ComponentList.getThirdPartyLibsList(gruntFilePath + '/');
-        const libs = ['node_modules/', 'vendor/'];
-
-        thirdpartyfiles.forEach(function(file) {
-            const dirname = path.dirname(file);
-
-            const doc = new DOMParser().parseFromString(grunt.file.read(file));
-            const nodes = xpath.select("/libraries/library/location/text()", doc);
-
-            nodes.forEach(function(node) {
-                let lib = path.posix.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;
-    };
-
-    /**
-     * Get the list of feature files to pass to the gherkin linter.
-     *
-     * @returns {Array}
-     */
-    const getGherkinLintTargets = () => {
-        if (files) {
-            // Specific files were requested. Only check these.
-            return files;
-        }
-
-        if (inComponent) {
-            return [`${runDir}/tests/behat/*.feature`];
-        }
+/**
+ * Verify tha tthe current NodeJS version matches the required version in package.json.
+ *
+ * @param   {Grunt} grunt
+ */
+const verifyNodeVersion = grunt => {
+    const semver = require('semver');
 
-        return ['**/tests/behat/*.feature'];
-    };
+    // Verify the node version is new enough.
+    var expected = semver.validRange(grunt.file.readJSON('package.json').engines.node);
+    var actual = semver.valid(process.version);
+    if (!semver.satisfies(actual, expected)) {
+        grunt.fail.fatal('Node version not satisfied. Require ' + expected + ', version installed: ' + actual);
+    }
+};
 
-    // Project configuration.
-    grunt.initConfig({
-        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.
-            // To display warnings call: grunt eslint --show-lint-warnings
-            // To fail on warnings call: grunt eslint --max-lint-warnings=0
-            // Also --max-lint-warnings=-1 can be used to display warnings but not fail.
-            options: {
-                quiet: (!grunt.option('show-lint-warnings')) && (typeof grunt.option('max-lint-warnings') === 'undefined'),
-                maxWarnings: ((typeof grunt.option('max-lint-warnings') !== 'undefined') ? grunt.option('max-lint-warnings') : -1)
-            },
-            amd: {src: files ? files : amdSrc},
-            // Check YUI module source files.
-            yui: {src: files ? files : yuiSrc},
-        },
-        babel: {
-            options: {
-                sourceMaps: true,
-                comments: false,
-                plugins: [
-                    'transform-es2015-modules-amd-lazy',
-                    'system-import-transformer',
-                    // This plugin modifies the Babel transpiling for "export default"
-                    // so that if it's used then only the exported value is returned
-                    // by the generated AMD module.
-                    //
-                    // It also adds the Moodle plugin name to the AMD module definition
-                    // so that it can be imported as expected in other modules.
-                    path.resolve('babel-plugin-add-module-to-define.js'),
-                    '@babel/plugin-syntax-dynamic-import',
-                    '@babel/plugin-syntax-import-meta',
-                    ['@babel/plugin-proposal-class-properties', {'loose': false}],
-                    '@babel/plugin-proposal-json-strings'
-                ],
-                presets: [
-                    ['minify', {
-                        // This minification plugin needs to be disabled because it breaks the
-                        // source map generation and causes invalid source maps to be output.
-                        simplify: false,
-                        builtIns: false
-                    }],
-                    ['@babel/preset-env', {
-                        targets: {
-                            browsers: [
-                                ">0.25%",
-                                "last 2 versions",
-                                "not ie <= 10",
-                                "not op_mini all",
-                                "not Opera > 0",
-                                "not dead"
-                            ]
-                        },
-                        modules: false,
-                        useBuiltIns: false
-                    }]
-                ]
-            },
-            dist: {
-                files: [{
-                    expand: true,
-                    src: files ? files : amdSrc,
-                    rename: babelRename
-                }]
-            }
-        },
-        sass: {
-            dist: {
-                files: {
-                    "theme/boost/style/moodle.css": "theme/boost/scss/preset/default.scss",
-                    "theme/classic/style/moodle.css": "theme/classic/scss/classicgrunt.scss"
-                }
-            },
-            options: {
-                implementation: sass,
-                includePaths: ["theme/boost/scss/", "theme/classic/scss/"]
-            }
-        },
-        watch: {
-            options: {
-                nospawn: true // We need not to spawn so config can be changed dynamically.
-            },
-            amd: {
-                files: inComponent
-                    ? ['amd/src/*.js', 'amd/src/**/*.js']
-                    : ['**/amd/src/**/*.js'],
-                tasks: ['amd']
-            },
-            boost: {
-                files: [inComponent ? 'scss/**/*.scss' : 'theme/boost/scss/**/*.scss'],
-                tasks: ['scss']
-            },
-            rawcss: {
-                files: [
-                    '**/*.css',
-                ],
-                excludes: [
-                    '**/moodle.css',
-                    '**/editor.css',
-                ],
-                tasks: ['rawcss']
-            },
-            yui: {
-                files: inComponent
-                    ? ['yui/src/*.json', 'yui/src/**/*.js']
-                    : ['**/yui/src/**/*.js'],
-                tasks: ['yui']
-            },
-            gherkinlint: {
-                files: [inComponent ? 'tests/behat/*.feature' : '**/tests/behat/*.feature'],
-                tasks: ['gherkinlint']
-            }
-        },
-        shifter: {
-            options: {
-                recursive: true,
-                // Shifter takes a relative path.
-                paths: files ? files : [runDir]
-            }
-        },
-        gherkinlint: {
-            options: {
-                files: getGherkinLintTargets(),
-            }
-        },
-    });
+/**
+ * Grunt configuration.
+ *
+ * @param {Grunt} grunt
+ */
+module.exports = function(grunt) {
+    // Verify that the Node version meets our requirements.
+    verifyNodeVersion(grunt);
 
-    /**
-     * Generate ignore files (utilising thirdpartylibs.xml data)
-     */
-    tasks.ignorefiles = function() {
-        // An array of paths to third party directories.
-        const thirdPartyPaths = getThirdPartyPathsFromXML();
-        // Generate .eslintignore.
-        const eslintIgnores = [
-            '# Generated by "grunt ignorefiles"',
-            '*/**/yui/src/*/meta/',
-            '*/**/build/',
-        ].concat(thirdPartyPaths);
-        grunt.file.write('.eslintignore', eslintIgnores.join('\n'));
-
-        // Generate .stylelintignore.
-        const stylelintIgnores = [
-            '# Generated by "grunt ignorefiles"',
-            '**/yui/build/*',
-            'theme/boost/style/moodle.css',
-            'theme/classic/style/moodle.css',
-        ].concat(thirdPartyPaths);
-        grunt.file.write('.stylelintignore', stylelintIgnores.join('\n'));
-    };
+    // Setup the Moodle environemnt within the Grunt object.
+    grunt.moodleEnv = setupMoodleEnvironment(grunt);
 
     /**
-     * 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.
+     * Add the named task.
      *
-     * Note that this task runs the invidiaul shifter jobs async (becase it spawns
-     * so be careful to to call done().
+     * @param   {string} name
+     * @param   {Grunt} grunt
      */
-    tasks.shifter = function() {
-        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) {
-            var args = [];
-            args.push(path.normalize(__dirname + '/node_modules/shifter/bin/shifter'));
-
-            // Always ignore the node_modules directory.
-            args.push('--excludes', 'node_modules');
-
-            // Determine the most appropriate options to run with based upon the current location.
-            if (grunt.file.isMatch('**/yui/**/*.js', src)) {
-                // When passed a JS file, build our containing module (this happen with
-                // watch).
-                grunt.log.debug('Shifter passed a specific JS file');
-                src = path.dirname(path.dirname(src));
-                options.recursive = false;
-            } else if (grunt.file.isMatch('**/yui/src', src)) {
-                // When in a src directory --walk all modules.
-                grunt.log.debug('In a src directory');
-                args.push('--walk');
-                options.recursive = false;
-            } else if (grunt.file.isMatch('**/yui/src/*', src)) {
-                // When in module, only build our module.
-                grunt.log.debug('In a module directory');
-                options.recursive = false;
-            } else if (grunt.file.isMatch('**/yui/src/*/js', src)) {
-                // When in module src, only build our module.
-                grunt.log.debug('In a source directory');
-                src = path.dirname(src);
-                options.recursive = false;
-            }
+    const addTask = (name, grunt) => {
+        const path = require('path');
+        const taskPath = path.resolve(`./.grunt/tasks/${name}.js`);
 
-            if (grunt.option('watch')) {
-                grunt.fail.fatal('The --watch option has been removed, please use `grunt watch` instead');
-            }
-
-            // Add the stderr option if appropriate
-            if (grunt.option('verbose')) {
-                args.push('--lint-stderr');
-            }
+        grunt.log.debug(`Including tasks for ${name} from ${taskPath}`);
 
-            if (grunt.option('no-color')) {
-                args.push('--color=false');
-            }
-
-            var execShifter = function() {
-
-                grunt.log.ok("Running shifter on " + src);
-                grunt.util.spawn({
-                    cmd: "node",
-                    args: args,
-                    opts: {cwd: src, stdio: 'inherit', env: process.env}
-                }, function(error, result, code) {
-                    if (code) {
-                        grunt.fail.fatal('Shifter failed with code: ' + code);
-                    } else {
-                        grunt.log.ok('Shifter build complete.');
-                        filedone();
-                    }
-                });
-            };
-
-            // Actually run shifter.
-            if (!options.recursive) {
-                execShifter();
-            } else {
-                // Check that there are yui modules otherwise shifter ends with exit code 1.
-                if (grunt.file.expand({cwd: src}, '**/yui/src/**/*.js').length > 0) {
-                    args.push('--recursive');
-                    execShifter();
-                } else {
-                    grunt.log.ok('No YUI modules to build.');
-                    filedone();
-                }
-            }
-        }, done);
-    };
-
-    tasks.gherkinlint = function() {
-        const done = this.async();
-        const options = grunt.config('gherkinlint.options');
-
-        // Grab the gherkin-lint linter and required scaffolding.
-        const linter = require('gherkin-lint/dist/linter.js');
-        const featureFinder = require('gherkin-lint/dist/feature-finder.js');
-        const configParser = require('gherkin-lint/dist/config-parser.js');
-        const formatter = require('gherkin-lint/dist/formatters/stylish.js');
-
-        // Run the linter.
-        return linter.lint(
-            featureFinder.getFeatureFiles(grunt.file.expand(options.files)),
-            configParser.getConfiguration(configParser.defaultConfigFileName)
-        )
-        .then(results => {
-            // Print the results out uncondtionally.
-            formatter.printResults(results);
-
-            return results;
-        })
-        .then(results => {
-            // Report on the results.
-            // The done function takes a bool whereby a falsey statement causes the task to fail.
-            return results.every(result => result.errors.length === 0);
-        })
-        .then(done); // eslint-disable-line promise/no-callback-in-promise
+        require(path.resolve(`./.grunt/tasks/${name}.js`))(grunt);
     };
 
-    tasks.startup = function() {
-        // Are we in a YUI directory?
-        if (path.basename(path.resolve(cwd, '../../')) == 'yui') {
-            grunt.task.run('yui');
-        // Are we in an AMD directory?
-        } else if (inAMD) {
-            grunt.task.run('amd');
-        } else {
-            // Run them all!.
-            grunt.task.run('css');
-            grunt.task.run('js');
-            grunt.task.run('gherkinlint');
-        }
-    };
-
-    /**
-     * This is a wrapper task to handle the grunt watch command. It attempts to use
-     * Watchman to monitor for file changes, if it's installed, because it's much faster.
-     *
-     * If Watchman isn't installed then it falls back to the grunt-contrib-watch file
-     * watcher for backwards compatibility.
-     */
-    tasks.watch = function() {
-        var watchTaskDone = this.async();
-        var watchInitialised = false;
-        var watchTaskQueue = {};
-        var processingQueue = false;
-
-        // Grab the tasks and files that have been queued up and execute them.
-        var processWatchTaskQueue = function() {
-            if (!Object.keys(watchTaskQueue).length || processingQueue) {
-                // If there is nothing in the queue or we're already processing then wait.
-                return;
-            }
-
-            processingQueue = true;
-
-            // Grab all tasks currently in the queue.
-            var queueToProcess = watchTaskQueue;
-            // Reset the queue.
-            watchTaskQueue = {};
-
-            async.forEachSeries(
-                Object.keys(queueToProcess),
-                function(task, next) {
-                    var files = queueToProcess[task];
-                    var filesOption = '--files=' + files.join(',');
-                    grunt.log.ok('Running task ' + task + ' for files ' + filesOption);
-
-                    // Spawn the task in a child process so that it doesn't kill this one
-                    // if it failed.
-                    grunt.util.spawn(
-                        {
-                            // Spawn with the grunt bin.
-                            grunt: true,
-                            // Run from current working dir and inherit stdio from process.
-                            opts: {
-                                cwd: fullRunDir,
-                                stdio: 'inherit'
-                            },
-                            args: [task, filesOption]
-                        },
-                        function(err, res, code) {
-                            if (code !== 0) {
-                                // The grunt task failed.
-                                grunt.log.error(err);
-                            }
-
-                            // Move on to the next task.
-                            next();
-                        }
-                    );
-                },
-                function() {
-                    // No longer processing.
-                    processingQueue = false;
-                    // Once all of the tasks are done then recurse just in case more tasks
-                    // were queued while we were processing.
-                    processWatchTaskQueue();
-                }
-            );
-        };
-
-        const originalWatchConfig = grunt.config.get(['watch']);
-        const watchConfig = Object.keys(originalWatchConfig).reduce(function(carry, key) {
-            if (key == 'options') {
-                return carry;
-            }
-
-            const value = originalWatchConfig[key];
-
-            const taskNames = value.tasks;
-            const files = value.files;
-            let excludes = [];
-            if (value.excludes) {
-                excludes = value.excludes;
-            }
-
-            taskNames.forEach(function(taskName) {
-                carry[taskName] = {
-                    files,
-                    excludes,
-                };
-            });
-
-            return carry;
-        }, {});
-
-        watchmanClient.on('error', function(error) {
-            // We have to add an error handler here and parse the error string because the
-            // example way from the docs to check if Watchman is installed doesn't actually work!!
-            // See: https://github.com/facebook/watchman/issues/509
-            if (error.message.match('Watchman was not found')) {
-                // If watchman isn't installed then we should fallback to the other watch task.
-                grunt.log.ok('It is recommended that you install Watchman for better performance using the "watch" command.');
-
-                // Fallback to the old grunt-contrib-watch task.
-                grunt.renameTask('watch-grunt', 'watch');
-                grunt.task.run(['watch']);
-                // This task is finished.
-                watchTaskDone(0);
-            } else {
-                grunt.log.error(error);
-                // Fatal error.
-                watchTaskDone(1);
-            }
-        });
-
-        watchmanClient.on('subscription', function(resp) {
-            if (resp.subscription !== 'grunt-watch') {
-                return;
-            }
-
-            resp.files.forEach(function(file) {
-                grunt.log.ok('File changed: ' + file.name);
-
-                var fullPath = fullRunDir + '/' + file.name;
-                Object.keys(watchConfig).forEach(function(task) {
-
-                    const fileGlobs = watchConfig[task].files;
-                    var match = fileGlobs.some(function(fileGlob) {
-                        return grunt.file.isMatch(`**/${fileGlob}`, fullPath);
-                    });
-
-                    if (match) {
-                        // If we are watching a subdirectory then the file.name will be relative
-                        // to that directory. However the grunt tasks  expect the file paths to be
-                        // relative to the Gruntfile.js location so let's normalise them before
-                        // adding them to the queue.
-                        var relativePath = fullPath.replace(gruntFilePath + '/', '');
-                        if (task in watchTaskQueue) {
-                            if (!watchTaskQueue[task].includes(relativePath)) {
-                                watchTaskQueue[task] = watchTaskQueue[task].concat(relativePath);
-                            }
-                        } else {
-                            watchTaskQueue[task] = [relativePath];
-                        }
-                    }
-                });
-            });
-
-            processWatchTaskQueue();
-        });
-
-        process.on('SIGINT', function() {
-            // Let the user know that they may need to manually stop the Watchman daemon if they
-            // no longer want it running.
-            if (watchInitialised) {
-                grunt.log.ok('The Watchman daemon may still be running and may need to be stopped manually.');
-            }
-
-            process.exit();
-        });
 
-        // Initiate the watch on the current directory.
-        watchmanClient.command(['watch-project', fullRunDir], function(watchError, watchResponse) {
-            if (watchError) {
-                grunt.log.error('Error initiating watch:', watchError);
-                watchTaskDone(1);
-                return;
-            }
-
-            if ('warning' in watchResponse) {
-                grunt.log.error('warning: ', watchResponse.warning);
-            }
-
-            var watch = watchResponse.watch;
-            var relativePath = watchResponse.relative_path;
-            watchInitialised = true;
-
-            watchmanClient.command(['clock', watch], function(clockError, clockResponse) {
-                if (clockError) {
-                    grunt.log.error('Failed to query clock:', clockError);
-                    watchTaskDone(1);
-                    return;
-                }
+    // Add Moodle task configuration.
+    addTask('gherkinlint', grunt);
+    addTask('ignorefiles', grunt);
 
-                // Generate the expression query used by watchman.
-                // Documentation is limited, but see https://facebook.github.io/watchman/docs/expr/allof.html for examples.
-                // We generate an expression to match any value in the files list of all of our tasks, but excluding
-                // all value in the  excludes list of that task.
-                //
-                // [anyof, [
-                //      [allof, [
-                //          [anyof, [
-                //              ['match', validPath, 'wholename'],
-                //              ['match', validPath, 'wholename'],
-                //          ],
-                //          [not,
-                //              [anyof, [
-                //                  ['match', invalidPath, 'wholename'],
-                //                  ['match', invalidPath, 'wholename'],
-                //              ],
-                //          ],
-                //      ],
-                var matchWholeName = fileGlob => ['match', fileGlob, 'wholename'];
-                var matches = Object.keys(watchConfig).map(function(task) {
-                    const matchAll = [];
-                    matchAll.push(['anyof'].concat(watchConfig[task].files.map(matchWholeName)));
-
-                    if (watchConfig[task].excludes.length) {
-                        matchAll.push(['not', ['anyof'].concat(watchConfig[task].excludes.map(matchWholeName))]);
-                    }
-
-                    return ['allof'].concat(matchAll);
-                });
-
-                matches = ['anyof'].concat(matches);
-
-                var sub = {
-                    expression: matches,
-                    // Which fields we're interested in.
-                    fields: ["name", "size", "type"],
-                    // Add our time constraint.
-                    since: clockResponse.clock
-                };
-
-                if (relativePath) {
-                    /* eslint-disable camelcase */
-                    sub.relative_root = relativePath;
-                }
-
-                watchmanClient.command(['subscribe', watch, 'grunt-watch', sub], function(subscribeError) {
-                    if (subscribeError) {
-                        // Probably an error in the subscription criteria.
-                        grunt.log.error('failed to subscribe: ', subscribeError);
-                        watchTaskDone(1);
-                        return;
-                    }
-
-                    grunt.log.ok('Listening for changes to files in ' + fullRunDir);
-                });
-            });
-        });
-    };
+    addTask('javascript', grunt);
+    addTask('style', grunt);
 
-    // On watch, we dynamically modify config to build only affected files. This
-    // method is slightly complicated to deal with multiple changed files at once (copied
-    // from the grunt-contrib-watch readme).
-    var changedFiles = Object.create(null);
-    var onChange = grunt.util._.debounce(function() {
-        var files = Object.keys(changedFiles);
-        grunt.config('eslint.amd.src', files);
-        grunt.config('eslint.yui.src', files);
-        grunt.config('shifter.options.paths', files);
-        grunt.config('gherkinlint.options.files', files);
-        grunt.config('babel.dist.files', [{expand: true, src: files, rename: babelRename}]);
-        changedFiles = Object.create(null);
-    }, 200);
-
-    grunt.event.on('watch', function(action, filepath) {
-        changedFiles[filepath] = action;
-        onChange();
-    });
-
-    // Register NPM tasks.
-    grunt.loadNpmTasks('grunt-contrib-uglify');
-    grunt.loadNpmTasks('grunt-contrib-watch');
-    grunt.loadNpmTasks('grunt-sass');
-    grunt.loadNpmTasks('grunt-eslint');
-    grunt.loadNpmTasks('grunt-stylelint');
-    grunt.loadNpmTasks('grunt-babel');
-
-    // Rename the grunt-contrib-watch "watch" task because we're going to wrap it.
-    grunt.renameTask('watch', 'watch-grunt');
-
-    // Register JS tasks.
-    grunt.registerTask('shifter', 'Run Shifter against the current directory', tasks.shifter);
-    grunt.registerTask('gherkinlint', 'Run gherkinlint against the current directory', tasks.gherkinlint);
-    grunt.registerTask('ignorefiles', 'Generate ignore files for linters', tasks.ignorefiles);
-    grunt.registerTask('watch', 'Run tasks on file changes', tasks.watch);
-    grunt.registerTask('yui', ['eslint:yui', 'shifter']);
-    grunt.registerTask('amd', ['eslint:amd', 'babel']);
-    grunt.registerTask('js', ['amd', 'yui']);
-
-    // Register CSS tasks.
-    registerStyleLintTasks(grunt, files, fullRunDir);
-
-    // Register the startup task.
-    grunt.registerTask('startup', 'Run the correct tasks for the current directory', tasks.startup);
+    addTask('watch', grunt);
+    addTask('startup', grunt);
 
     // Register the default task.
     grunt.registerTask('default', ['startup']);
index c02458d..1ec045f 100644 (file)
@@ -125,7 +125,7 @@ class task_log_table extends \table_sql {
         }
 
         // TODO Does not support custom user profile fields (MDL-70456).
-        $userfieldsapi = \core\user_fields::for_identity(\context_system::instance(), false)->with_userpic();
+        $userfieldsapi = \core_user\fields::for_identity(\context_system::instance(), false)->with_userpic();
         $userfields = $userfieldsapi->get_sql('u', false, 'user', 'userid2', false)->selects;
 
         $where = '';
index 17e6b65..50557d6 100644 (file)
@@ -271,7 +271,7 @@ if ($roleid) {
     foreach ($assignableroles as $roleid => $notused) {
         $roleusers = '';
         if (0 < $assigncounts[$roleid] && $assigncounts[$roleid] <= MAX_USERS_TO_LIST_PER_ROLE) {
-            $userfieldsapi = \core\user_fields::for_name();
+            $userfieldsapi = \core_user\fields::for_name();
             $userfields = 'u.id, u.username' . $userfieldsapi->get_sql('u')->selects;
             $roleusers = get_role_users($roleid, $context, false, $userfields);
             if (!empty($roleusers)) {
index c0da1aa..86d198e 100644 (file)
@@ -139,6 +139,13 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
         new lang_string('coursehelpshowgrades'), 1, array(0 => new lang_string('no'), 1 => new lang_string('yes'))));
     $temp->add(new admin_setting_configselect('moodlecourse/showreports', new lang_string('showreports'), '', 0,
         array(0 => new lang_string('no'), 1 => new lang_string('yes'))));
+    $temp->add(new admin_setting_configselect('moodlecourse/showactivitydates',
+        new lang_string('showactivitydates'),
+        new lang_string('showactivitydates_help'), 1, [
+            0 => new lang_string('no'),
+            1 => new lang_string('yes')
+        ]
+    ));
 
     // Files and uploads.
     $temp->add(new admin_setting_heading('filesanduploadshdr', new lang_string('filesanduploads'), ''));
@@ -163,6 +170,15 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
     $temp->add(new admin_setting_configselect('moodlecourse/enablecompletion', new lang_string('completion', 'completion'),
         new lang_string('enablecompletion_help', 'completion'), 1, array(0 => new lang_string('no'), 1 => new lang_string('yes'))));
 
+    // Display completion conditions.
+    $temp->add(new admin_setting_configselect('moodlecourse/showcompletionconditions',
+        new lang_string('showcompletionconditions', 'completion'),
+        new lang_string('showcompletionconditions_help', 'completion'), 1, [
+            0 => new lang_string('no'),
+            1 => new lang_string('yes')
+        ]
+    ));
+
     // Groups.
     $temp->add(new admin_setting_heading('groups', new lang_string('groups', 'group'), ''));
     $choices = array();
diff --git a/admin/tests/behat/browse_users.feature b/admin/tests/behat/browse_users.feature
new file mode 100644 (file)
index 0000000..b6d9f41
--- /dev/null
@@ -0,0 +1,50 @@
+@core @core_admin
+Feature: An administrator can browse user accounts
+  In order to find the user accounts I am looking for
+  As an admin
+  I can browse users and see their basic information
+
+  Background:
+    Given the following "custom profile fields" exist:
+      | datatype | shortname | name           |
+      | text     | frog      | Favourite frog |
+    And the following "users" exist:
+      | username | firstname | lastname | email           | department | profile_field_frog | firstnamephonetic |
+      | user1    | User      | One      | one@example.com | Attack     | Kermit             | Yewzer            |
+      | user2    | User      | Two      | two@example.com | Defence    | Tree               | Yoozare           |
+    And I log in as "admin"
+
+  Scenario: User accounts display default fields
+    When I navigate to "Users > Accounts > Browse list of users" in site administration
+    # Name field always present, email field is default for showidentity.
+    Then the following should exist in the "users" table:
+      | First name / Surname | Email address   |
+      | User One             | one@example.com |
+      | User Two             | two@example.com |
+    # Should not see other identity fields or non-default name fields.
+    And I should not see "Department" in the "table" "css_element"
+    And I should not see "Attack"
+    And I should not see "Favourite frog" in the "table" "css_element"
+    And I should not see "Kermit"
+    And I should not see "First name - phonetic" in the "table" "css_element"
+    And I should not see "Yoozare"
+
+  Scenario: User accounts with extra name fields
+    Given the following config values are set as admin:
+      | alternativefullnameformat | firstnamephonetic lastname |
+    When I navigate to "Users > Accounts > Browse list of users" in site administration
+    Then the following should exist in the "users" table:
+      | First name - phonetic / Surname | Email address   |
+      | Yewzer One                      | one@example.com |
+      | Yoozare Two                     | two@example.com |
+
+  Scenario: User accounts with specified identity fields
+    Given the following config values are set as admin:
+      | showuseridentity | department,profile_field_frog |
+    When I navigate to "Users > Accounts > Browse list of users" in site administration
+    Then the following should exist in the "users" table:
+      | First name / Surname | Favourite frog  | Department |
+      | User One             | Kermit          | Attack     |
+      | User Two             | Tree            | Defence    |
+    And I should not see "Email address" in the "table" "css_element"
+    And I should not see "one@example.com"
diff --git a/admin/tests/behat/webservice_users.feature b/admin/tests/behat/webservice_users.feature
new file mode 100644 (file)
index 0000000..e334b0f
--- /dev/null
@@ -0,0 +1,31 @@
+@core @core_admin
+Feature: Web service user settings
+  In order to configure authorised users for a web service
+  As an admin
+  I need to use the page that lets you do that
+
+  Background:
+    # Include a custom profile field so we can check it gets displayed
+    Given the following "custom profile fields" exist:
+      | datatype | shortname | name           | param2 |
+      | text     | frog      | Favourite frog | 100    |
+    And the following config values are set as admin:
+      | showuseridentity | email,profile_field_frog |
+    And the following "users" exist:
+      | username | firstname | lastname | email         | profile_field_frog |
+      | user1    | User      | One      | 1@example.org | Kermit             |
+    And the following "core_webservice > Service" exists:
+      | name            | Silly service |
+      | shortname       | silly         |
+      | restrictedusers | 1             |
+      | enabled         | 1             |
+
+  Scenario: Add a user to a web service
+    When I log in as "admin"
+    And I navigate to "Server > Web services > External services" in site administration
+    And I click on "Authorised users" "link" in the "Silly service" "table_row"
+    And I set the field "Not authorised users" to "User One"
+    And I press "Add"
+    Then I should see "User One" in the ".alloweduserlist" "css_element"
+    And I should see "1@example.org" in the ".alloweduserlist" "css_element"
+    And I should see "Kermit" in the ".alloweduserlist" "css_element"
index e906470..6172ef5 100644 (file)
@@ -127,7 +127,7 @@ class cohort_role_assignments_table extends table_sql {
      */
     protected function define_table_columns() {
         // TODO Does not support custom user profile fields (MDL-70456).
-        $extrafields = \core\user_fields::get_identity_fields($this->context, false);
+        $extrafields = \core_user\fields::get_identity_fields($this->context, false);
 
         // Define headers and columns.
         $cols = array(
@@ -175,7 +175,7 @@ class cohort_role_assignments_table extends table_sql {
 
         // Add extra user fields that we need for the graded user.
         // TODO Does not support custom user profile fields (MDL-70456).
-        $userfieldsapi = \core\user_fields::for_identity($this->context, false)->with_name();
+        $userfieldsapi = \core_user\fields::for_identity($this->context, false)->with_name();
         $fields .= $userfieldsapi->get_sql('u')->selects;
 
         if ($count) {
index ceaee01..4547319 100644 (file)
@@ -189,7 +189,7 @@ class api {
         $dpos = [];
         $context = context_system::instance();
         foreach ($dporoles as $roleid) {
-            $userfieldsapi = \core\user_fields::for_name();
+            $userfieldsapi = \core_user\fields::for_name();
             $allnames = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
             $fields = 'u.id, u.confirmed, u.username, '. $allnames . ', ' .
                       'u.maildisplay, u.mailformat, u.maildigest, u.email, u.emailstop, u.city, '.
index c2ab731..92dd398 100644 (file)
@@ -700,7 +700,7 @@ class external extends external_api {
         self::validate_context($context);
         require_capability('tool/dataprivacy:managedatarequests', $context);
 
-        $userfieldsapi = \core\user_fields::for_name();
+        $userfieldsapi = \core_user\fields::for_name();
         $allusernames = $userfieldsapi->get_sql('', false, '', '', false)->selects;
         // Exclude admins and guest user.
         $excludedusers = array_keys(get_admins()) + [guest_user()->id];
@@ -708,7 +708,7 @@ class external extends external_api {
         $fields = 'id,' . $allusernames;
 
         // TODO Does not support custom user profile fields (MDL-70456).
-        $extrafields = \core\user_fields::get_identity_fields($context, false);
+        $extrafields = \core_user\fields::get_identity_fields($context, false);
         if (!empty($extrafields)) {
             $fields .= ',' . implode(',', $extrafields);
         }
index b34cb8f..d05087e 100644 (file)
@@ -187,7 +187,7 @@ class helper {
         global $DB;
 
         // Get users that the user has role assignments to.
-        $userfieldsapi = \core\user_fields::for_name();
+        $userfieldsapi = \core_user\fields::for_name();
         $allusernames = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
         $sql = "SELECT u.id, $allusernames
                   FROM {role_assignments} ra, {context} c, {user} u
index 39c3e3c..af2f3aa 100644 (file)
@@ -62,7 +62,7 @@ class tool_dataprivacy_data_request_form extends \core\form\persistent {
                 'valuehtmlcallback' => function($value) {
                     global $OUTPUT;
 
-                    $userfieldsapi = \core\user_fields::for_name();
+                    $userfieldsapi = \core_user\fields::for_name();
                     $allusernames = $userfieldsapi->get_sql('', false, '', '', false)->selects;
                     $fields = 'id, email, ' . $allusernames;
                     $user = \core_user::get_user($value, $fields);
index fb60090..afc5cac 100644 (file)
@@ -145,7 +145,7 @@ class httpsreplace_test extends \advanced_testcase {
         $this->resetAfterTest();
         $this->expectOutputRegex($ouputregex);
 
-        $finder = new tool_httpreplace_url_finder_test();
+        $finder = new tool_httpreplace_url_finder_mock();
 
         $generator = $this->getDataGenerator();
         $course = $generator->create_course((object) [
@@ -216,7 +216,7 @@ class httpsreplace_test extends \advanced_testcase {
     public function test_http_link_stats($content, $domain, $expectedcount) {
         $this->resetAfterTest();
 
-        $finder = new tool_httpreplace_url_finder_test();
+        $finder = new tool_httpreplace_url_finder_mock();
 
         $generator = $this->getDataGenerator();
         $course = $generator->create_course((object) [
@@ -237,7 +237,7 @@ class httpsreplace_test extends \advanced_testcase {
         $this->resetAfterTest();
         $this->expectOutputRegex('/^$/');
 
-        $finder = new tool_httpreplace_url_finder_test();
+        $finder = new tool_httpreplace_url_finder_mock();
 
         $generator = $this->getDataGenerator();
         $course = $generator->create_course((object) [
@@ -269,7 +269,7 @@ class httpsreplace_test extends \advanced_testcase {
         $CFG->wwwroot = preg_replace('/^https:/', 'http:', $CFG->wwwroot);
         $this->expectOutputRegex('/^$/');
 
-        $finder = new tool_httpreplace_url_finder_test();
+        $finder = new tool_httpreplace_url_finder_mock();
 
         $generator = $this->getDataGenerator();
         $course = $generator->create_course((object) [
@@ -292,7 +292,7 @@ class httpsreplace_test extends \advanced_testcase {
 
         set_config('test_upgrade_http_links', '<img src="http://somesite/someimage.png" />');
 
-        $finder = new tool_httpreplace_url_finder_test();
+        $finder = new tool_httpreplace_url_finder_mock();
         ob_start();
         $results = $finder->upgrade_http_links();
         $output = ob_get_contents();
@@ -318,7 +318,7 @@ class httpsreplace_test extends \advanced_testcase {
 
         set_config('renames', json_encode($renames), 'tool_httpsreplace');
 
-        $finder = new tool_httpreplace_url_finder_test();
+        $finder = new tool_httpreplace_url_finder_mock();
 
         $generator = $this->getDataGenerator();
         $course = $generator->create_course((object) [
@@ -353,7 +353,7 @@ class httpsreplace_test extends \advanced_testcase {
             $original2 .= '<img src="http://example.com/image' . ($i + 15 ) . '.png">';
             $expected2 .= '<img src="https://example.com/image' . ($i + 15) . '.png">';
         }
-        $finder = new tool_httpreplace_url_finder_test();
+        $finder = new tool_httpreplace_url_finder_mock();
 
         $generator = $this->getDataGenerator();
         $course1 = $generator->create_course((object) ['summary' => $original1]);
@@ -397,7 +397,7 @@ class httpsreplace_test extends \advanced_testcase {
         $columnamequoted = $dbman->generator->getEncQuoted('where');
         $DB->execute("INSERT INTO {reserved_words_temp} ($columnamequoted) VALUES (?)", [$content]);
 
-        $finder = new tool_httpreplace_url_finder_test();
+        $finder = new tool_httpreplace_url_finder_mock();
         $finder->upgrade_http_links();
 
         $record = $DB->get_record('reserved_words_temp', []);
@@ -408,13 +408,13 @@ class httpsreplace_test extends \advanced_testcase {
 }
 
 /**
- * Class tool_httpreplace_url_finder_test for testing replace tool without calling curl
+ * Class tool_httpreplace_url_finder_mock for testing replace tool without calling curl
  *
  * @package   tool_httpsreplace
  * @copyright 2017 Marina Glancy
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class tool_httpreplace_url_finder_test extends \tool_httpsreplace\url_finder {
+class tool_httpreplace_url_finder_mock extends \tool_httpsreplace\url_finder {
     /**
      * Check if url is available (check hardcoded for unittests)
      *
index 0264bd0..b04ab76 100644 (file)
@@ -879,9 +879,9 @@ class external extends external_api {
             $USER->id, SQL_PARAMS_NAMED);
 
         // TODO Does not support custom user profile fields (MDL-70456).
-        $userfieldsapi = \core\user_fields::for_identity($context, false)->with_userpic();
+        $userfieldsapi = \core_user\fields::for_identity($context, false)->with_userpic();
         $fields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
-        $extrasearchfields = $userfieldsapi->get_required_fields([\core\user_fields::PURPOSE_IDENTITY]);
+        $extrasearchfields = $userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]);
 
         list($wheresql, $whereparams) = users_search_sql($query, 'u', true, $extrasearchfields);
         list($sortsql, $sortparams) = users_order_by_sql('u', $query, $context);
index ba17116..83829f6 100644 (file)
@@ -93,7 +93,7 @@ class template_plans_table extends table_sql {
      */
     protected function define_table_columns() {
         // TODO Does not support custom user profile fields (MDL-70456).
-        $extrafields = \core\user_fields::get_identity_fields($this->context, false);
+        $extrafields = \core_user\fields::get_identity_fields($this->context, false);
 
         // Define headers and columns.
         $cols = array(
@@ -137,7 +137,7 @@ class template_plans_table extends table_sql {
 
         // Add extra user fields that we need for the graded user.
         // TODO Does not support custom user profile fields (MDL-70456).
-        $userfieldsapi = \core\user_fields::for_identity($this->context, false)->with_name();
+        $userfieldsapi = \core_user\fields::for_identity($this->context, false)->with_name();
         $fields .= $userfieldsapi->get_sql('u')->selects;
 
         if ($count) {
index b4492bc..135f5bc 100644 (file)
@@ -92,9 +92,9 @@ class acceptances_table extends \table_sql {
         }
 
         // TODO Does not support custom user profile fields (MDL-70456).
-        $userfieldsapi = \core\user_fields::for_identity(\context_system::instance(), false)->with_userpic();
+        $userfieldsapi = \core_user\fields::for_identity(\context_system::instance(), false)->with_userpic();
         $userfields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
-        $extrafields = $userfieldsapi->get_required_fields([\core\user_fields::PURPOSE_IDENTITY]);
+        $extrafields = $userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]);
 
         $this->set_sql("$userfields",
             "{user} u",
@@ -105,7 +105,7 @@ class acceptances_table extends \table_sql {
         }
         $this->add_column_header('fullname', get_string('fullnameuser', 'core'));
         foreach ($extrafields as $field) {
-            $this->add_column_header($field, \core\user_fields::get_display_name($field));
+            $this->add_column_header($field, \core_user\fields::get_display_name($field));
         }
 
         if (!$this->is_downloading() && !has_capability('tool/policy:acceptbehalf', \context_system::instance())) {
@@ -170,7 +170,7 @@ class acceptances_table extends \table_sql {
      * Helper configuration method.
      */
     protected function configure_for_single_version() {
-        $userfieldsapi = \core\user_fields::for_name();
+        $userfieldsapi = \core_user\fields::for_name();
         $userfieldsmod = $userfieldsapi->get_sql('m', false, 'mod', '', false)->selects;
         $v = key($this->versionids);
         $this->sql->fields .= ", $userfieldsmod, a{$v}.status AS status{$v}, a{$v}.note, ".
index 5366daa..9a7af22 100644 (file)
@@ -343,7 +343,7 @@ class api {
         global $DB;
 
         $ctxfields = context_helper::get_preload_record_columns_sql('c');
-        $userfieldsapi = \core\user_fields::for_name()->with_userpic()->including(...($extrafields ?? []));
+        $userfieldsapi = \core_user\fields::for_name()->with_userpic()->including(...($extrafields ?? []));
         $userfields = $userfieldsapi->get_sql('u')->selects;
 
         $sql = "SELECT $ctxfields $userfields
@@ -682,7 +682,7 @@ class api {
             $vsql = ' AND a.policyversionid ' . $vsql;
         }
 
-        $userfieldsapi = \core\user_fields::for_name();
+        $userfieldsapi = \core_user\fields::for_name();
         $userfieldsmod = $userfieldsapi->get_sql('m', false, 'mod', '', false)->selects;
         $sql = "SELECT u.id AS mainuserid, a.policyversionid, a.status, a.lang, a.timemodified, a.usermodified, a.note,
                   u.policyagreed, $userfieldsmod
index 6821daa..9321c38 100644 (file)
@@ -128,7 +128,7 @@ class accept_policy extends \moodleform {
         $usernames = [];
         list($sql, $params) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
         $params['usercontextlevel'] = CONTEXT_USER;
-        $userfieldsapi = \core\user_fields::for_name();
+        $userfieldsapi = \core_user\fields::for_name();
         $users = $DB->get_records_sql("SELECT u.id" . $userfieldsapi->get_sql('u')->selects . ", " .
                 \context_helper::get_preload_record_columns_sql('ctx') .
             " FROM {user} u JOIN {context} ctx ON ctx.contextlevel=:usercontextlevel AND ctx.instanceid = u.id
index cde5929..4204c8b 100644 (file)
@@ -155,7 +155,7 @@ class process {
             'interests',
         );
         // Include all name fields.
-        $this->standardfields = array_merge($this->standardfields, \core\user_fields::get_name_fields());
+        $this->standardfields = array_merge($this->standardfields, \core_user\fields::get_name_fields());
     }
 
     /**
index 06ee33b..59853ca 100644 (file)
     // These columns are always shown in the users list.
     $requiredcolumns = array('city', 'country', 'lastaccess');
     // Extra columns containing the extra user fields, excluding the required columns (city and country, to be specific).
-    $userfields = \core\user_fields::for_identity($context, true)->with_name()->excluding(...$requiredcolumns);
+    $userfields = \core_user\fields::for_identity($context, true)->excluding(...$requiredcolumns);
     $extracolumns = $userfields->get_required_fields();
-    // Get all user name fields as an array.
-    $columns = array_merge($extracolumns, $requiredcolumns);
+    // Get all user name fields as an array, but with firstname and lastname first.
+    $allusernamefields = \core_user\fields::get_name_fields(true);
+    $columns = array_merge($allusernamefields, $extracolumns, $requiredcolumns);
 
     foreach ($columns as $column) {
-        $string[$column] = \core\user_fields::get_display_name($column);
+        $string[$column] = \core_user\fields::get_display_name($column);
         if ($sort != $column) {
             $columnicon = "";
             if ($column == "lastaccess") {
     }
 
     // Order in string will ensure that the name columns are in the correct order.
-    $usernames = order_in_string($extracolumns, $fullnamesetting);
+    $usernames = order_in_string($allusernamefields, $fullnamesetting);
     $fullnamedisplay = array();
     foreach ($usernames as $name) {
         // Use the link from $$column for sorting on the user's name.
index ab05021..21e949f 100644 (file)
@@ -64,7 +64,7 @@ if (count($cohorts) < 2) {
 }
 
 $countries = get_string_manager()->get_list_of_countries(true);
-$userfieldsapi = \core\user_fields::for_name();
+$userfieldsapi = \core_user\fields::for_name();
 $namefields = $userfieldsapi->get_sql('', false, '', '', false)->selects;
 foreach ($users as $key => $id) {
     $user = $DB->get_record('user', array('id' => $id), 'id, ' . $namefields . ', username,
index 146f4c7..70c1eec 100644 (file)
@@ -24,7 +24,7 @@ echo $OUTPUT->header();
 
 $countries = get_string_manager()->get_list_of_countries(true);
 
-$userfieldsapi = \core\user_fields::for_name();
+$userfieldsapi = \core_user\fields::for_name();
 $namefields = $userfieldsapi->get_sql('', false, '', '', false)->selects;
 foreach ($users as $key => $id) {
     $user = $DB->get_record('user', array('id'=>$id), 'id, ' . $namefields . ', username, email, country, lastaccess, city');
index 05d82fa..25fb076 100644 (file)
@@ -290,9 +290,9 @@ abstract class info {
      */
     protected function warn_about_invalid_availability(\coding_exception $e) {
         $name = $this->get_thing_name();
-        // If it occurs while building modinfo based on somebody calling $cm->name,
-        // we can't get $cm->name, and this line will cause a warning.
-        $htmlname = @$this->format_info($name, $this->course);
+        $htmlname = $this->format_info($name, $this->course);
+        // Because we call format_info here, likely in the middle of building dynamic data for the
+        // activity, there could be a chance that the name might not be available.
         if ($htmlname === '') {
             // So instead use the numbers (cmid) from the tag.
             $htmlname = preg_replace('~[^0-9]~', '', $name);
@@ -739,11 +739,11 @@ abstract class info {
         $info = preg_replace_callback('~<AVAILABILITY_CMNAME_([0-9]+)/>~',
                 function($matches) use($modinfo, $context) {
                     $cm = $modinfo->get_cm($matches[1]);
-                    if ($cm->has_view() and $cm->uservisible) {
+                    if ($cm->has_view() and $cm->get_user_visible()) {
                         // Help student by providing a link to the module which is preventing availability.
-                        return \html_writer::link($cm->url, format_string($cm->name, true, array('context' => $context)));
+                        return \html_writer::link($cm->get_url(), format_string($cm->get_name(), true, ['context' => $context]));
                     } else {
-                        return format_string($cm->name, true, array('context' => $context));
+                        return format_string($cm->get_name(), true, ['context' => $context]);
                     }
                 }, $info);
 
index ce65bb0..39399b7 100644 (file)
@@ -197,7 +197,7 @@ class condition extends \core_availability\condition {
                         $this->customfield);
             }
         } else {
-            $translatedfieldname = \core\user_fields::get_display_name($this->standardfield);
+            $translatedfieldname = \core_user\fields::get_display_name($this->standardfield);
         }
         $context = \context_course::instance($course->id);
         $a = new \stdClass();
index e5026e5..37fda53 100644 (file)
@@ -44,23 +44,23 @@ class frontend extends \core_availability\frontend {
             \section_info $section = null) {
         // Standard user fields.
         $standardfields = array(
-            'firstname' => \core\user_fields::get_display_name('firstname'),
-            'lastname' => \core\user_fields::get_display_name('lastname'),
-            'email' => \core\user_fields::get_display_name('email'),
-            'city' => \core\user_fields::get_display_name('city'),
-            'country' => \core\user_fields::get_display_name('country'),
-            'url' => \core\user_fields::get_display_name('url'),
-            'icq' => \core\user_fields::get_display_name('icq'),
-            'skype' => \core\user_fields::get_display_name('skype'),
-            'aim' => \core\user_fields::get_display_name('aim'),
-            'yahoo' => \core\user_fields::get_display_name('yahoo'),
-            'msn' => \core\user_fields::get_display_name('msn'),
-            'idnumber' => \core\user_fields::get_display_name('idnumber'),
-            'institution' => \core\user_fields::get_display_name('institution'),
-            'department' => \core\user_fields::get_display_name('department'),
-            'phone1' => \core\user_fields::get_display_name('phone1'),
-            'phone2' => \core\user_fields::get_display_name('phone2'),
-            'address' => \core\user_fields::get_display_name('address')
+            'firstname' => \core_user\fields::get_display_name('firstname'),
+            'lastname' => \core_user\fields::get_display_name('lastname'),
+            'email' => \core_user\fields::get_display_name('email'),
+            'city' => \core_user\fields::get_display_name('city'),
+            'country' => \core_user\fields::get_display_name('country'),
+            'url' => \core_user\fields::get_display_name('url'),
+            'icq' => \core_user\fields::get_display_name('icq'),
+            'skype' => \core_user\fields::get_display_name('skype'),
+            'aim' => \core_user\fields::get_display_name('aim'),
+            'yahoo' => \core_user\fields::get_display_name('yahoo'),
+            'msn' => \core_user\fields::get_display_name('msn'),
+            'idnumber' => \core_user\fields::get_display_name('idnumber'),
+            'institution' => \core_user\fields::get_display_name('institution'),
+            'department' => \core_user\fields::get_display_name('department'),
+            'phone1' => \core_user\fields::get_display_name('phone1'),
+            'phone2' => \core_user\fields::get_display_name('phone2'),
+            'address' => \core_user\fields::get_display_name('address')
         );
         \core_collator::asort($standardfields);
 
index 2510ac9..4108d60 100644 (file)
@@ -511,4 +511,33 @@ class info_testcase extends advanced_testcase {
         sort($result);
         $this->assertEquals($expected, $result);
     }
+
+    /**
+     * Tests the info_module class when involved in a recursive call to $cm->name.
+     */
+    public function test_info_recursive_name_call() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        // Create a course and page.
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $page1 = $generator->create_module('page', ['course' => $course->id, 'name' => 'Page1']);
+
+        // Set invalid availability.
+        $DB->set_field('course_modules', 'availability', 'not valid', ['id' => $page1->cmid]);
+
+        // Get the cm_info object.
+        $this->setAdminUser();
+        $modinfo = get_fast_modinfo($course);
+        $cm1 = $modinfo->get_cm($page1->cmid);
+
+        // At this point we will generate dynamic data for $cm1, which will cause the debugging
+        // call below.
+        $this->assertEquals('Page1', $cm1->name);
+
+        $this->assertDebuggingCalled('Error processing availability data for ' .
+                '&lsquo;Page1&rsquo;: Invalid availability text');
+    }
 }
index ea01b01..4339b0c 100644 (file)
@@ -1365,7 +1365,7 @@ class backup_users_structure_step extends backup_structure_step {
             'phone2', 'institution', 'department', 'address',
             'city', 'country', 'lastip', 'picture',
             'url', 'description', 'descriptionformat', 'imagealt', 'auth');
-        $anonfields = array_merge($anonfields, \core\user_fields::get_name_fields());
+        $anonfields = array_merge($anonfields, \core_user\fields::get_name_fields());
 
         // Add anonymized fields to $userfields with custom final element
         foreach ($anonfields as $field) {
index 0c41062..29f1d9e 100644 (file)
@@ -52,7 +52,7 @@ if (!is_null($action)) {
     } else { // Revoked badge.
         header("HTTP/1.0 410 Gone");
         $assertion = array();
-        if ($obversion == OPEN_BADGES_V2) {
+        if ($obversion >= OPEN_BADGES_V2) {
             $assertionurl = new moodle_url('/badges/assertion.php', array('b' => $hash));
             $assertion['id'] = $assertionurl->out();
         }
index 9121b9d..bbfa50b 100644 (file)
@@ -128,7 +128,7 @@ class core_badges_assertion {
             $email = empty($this->_data->backpackemail) ? $this->_data->email : $this->_data->backpackemail;
             $assertionurl = new moodle_url('/badges/assertion.php', array('b' => $hash, 'obversion' => $this->_obversion));
 
-            if ($this->_obversion == OPEN_BADGES_V2) {
+            if ($this->_obversion >= OPEN_BADGES_V2) {
                 $classurl = new moodle_url('/badges/badge_json.php', array('id' => $this->get_badge_id()));
             } else {
                 $classurl = new moodle_url('/badges/assertion.php', array('b' => $hash, 'action' => 1));
@@ -325,7 +325,7 @@ class core_badges_assertion {
      */
     protected function embed_data_badge_version2 (&$json, $type = OPEN_BADGES_V2_TYPE_ASSERTION) {
         // Specification Version 2.0.
-        if ($this->_obversion == OPEN_BADGES_V2) {
+        if ($this->_obversion >= OPEN_BADGES_V2) {
             $badge = new badge($this->_data->id);
             if (empty($this->_data->courseid)) {
                 $context = context_system::instance();
index f56bde0..7fad503 100644 (file)
@@ -58,7 +58,7 @@ class external_badge implements renderable {
         global $DB;
         // At this point a user has connected a backpack. So, we are going to get
         // their backpack email rather than their account email.
-        $userfieldsapi = \core\user_fields::for_name();
+        $userfieldsapi = \core_user\fields::for_name();
         $namefields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
         $user = $DB->get_record_sql("SELECT {$namefields}, b.email
                     FROM {user} u INNER JOIN {badge_backpack} b ON u.id = b.userid
index c3ce9a9..2b9e891 100644 (file)
@@ -76,7 +76,7 @@ class issued_badge implements renderable {
                 array('hash' => $hash), IGNORE_MISSING);
         if ($rec) {
             // Get a recipient from database.
-            $userfieldsapi = \core\user_fields::for_name();
+            $userfieldsapi = \core_user\fields::for_name();
             $namefields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
             $user = $DB->get_record_sql("SELECT u.id, $namefields, u.deleted, u.email
                         FROM {user} u WHERE u.id = :userid", array('userid' => $rec->userid));
index dbba62a..6091b62 100644 (file)
@@ -89,7 +89,7 @@ class award_criteria_profile extends award_criteria {
                     $checked = true;
                 }
                 $this->config_options($mform, array('id' => $field, 'checked' => $checked,
-                        'name' => \core\user_fields::get_display_name($field), 'error' => false));
+                        'name' => \core_user\fields::get_display_name($field), 'error' => false));
                 $none = false;
             }
         }
@@ -139,7 +139,7 @@ class award_criteria_profile extends award_criteria {
             if (is_numeric($p['field'])) {
                 $str = $DB->get_field('user_info_field', 'name', array('id' => $p['field']));
             } else {
-                $str = \core\user_fields::get_display_name($p['field']);
+                $str = \core_user\fields::get_display_name($p['field']);
             }
             if (!$str) {
                 $output[] = $OUTPUT->error_text(get_string('error:nosuchfield', 'badges'));
index 7f1e75f..83121cd 100644 (file)
@@ -87,7 +87,7 @@ if ($badge->has_manual_award_criteria() && has_capability('moodle/badges:awardba
     echo $OUTPUT->box($OUTPUT->single_button($url, get_string('award', 'badges')), 'clearfix mdl-align');
 }
 
-$userfieldsapi = \core\user_fields::for_name();
+$userfieldsapi = \core_user\fields::for_name();
 $namefields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
 $sql = "SELECT b.userid, b.dateissued, b.uniquehash, $namefields
     FROM {badge_issued} b INNER JOIN {user} u
index 475f818..fa0baa7 100644 (file)
@@ -32,7 +32,7 @@ require_once($CFG->dirroot . '/badges/lib.php');
 
 use core_badges\helper;
 
-class core_badges_badgeslib_testcase extends advanced_testcase {
+class badgeslib_test extends advanced_testcase {
     protected $badgeid;
     protected $course;
     protected $user;
@@ -698,6 +698,16 @@ class core_badges_badgeslib_testcase extends advanced_testcase {
         $this->assertStringMatchesFormat($testassertion2->badge, json_encode($assertion2->get_badge_assertion()));
         $this->assertStringMatchesFormat($testassertion2->class, json_encode($assertion2->get_badge_class()));
         $this->assertStringMatchesFormat($testassertion2->issuer, json_encode($assertion2->get_issuer()));
+
+        // Test Openbadge specification version 2.1. It has the same format as OBv2.0.
+        // Get assertion version 2.1.
+        $award = reset($awards);
+        $assertion2 = new core_badges_assertion($award->uniquehash, OPEN_BADGES_V2P1);
+
+        // Make sure JSON strings have the same structure.
+        $this->assertStringMatchesFormat($testassertion2->badge, json_encode($assertion2->get_badge_assertion()));
+        $this->assertStringMatchesFormat($testassertion2->class, json_encode($assertion2->get_badge_class()));
+        $this->assertStringMatchesFormat($testassertion2->issuer, json_encode($assertion2->get_issuer()));
     }
 
     /**
index e6c464f..ab411a9 100644 (file)
@@ -31,7 +31,7 @@ require_once($CFG->dirroot . '/badges/tests/badgeslib_test.php');
  * @copyright  2015 onwards Simey Lameze <simey@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class core_badges_events_testcase extends core_badges_badgeslib_testcase {
+class events_test extends badgeslib_test {
 
     /**
      * Test badge awarded event.
index 7c19e4c..a673f70 100644 (file)
@@ -508,12 +508,12 @@ class block_activity_results extends block_base {
 
                 // Now grab all the users from the database.
                 $userids = array_merge(array_keys($best), array_keys($worst));
-                $fields = array_merge(array('id', 'idnumber'), \core\user_fields::get_name_fields());
+                $fields = array_merge(array('id', 'idnumber'), \core_user\fields::get_name_fields());
                 $fields = implode(',', $fields);
                 $users = $DB->get_records_list('user', 'id', $userids, '', $fields);
 
                 // If configured to view user idnumber, ensure current user can see it.
-                $extrafields = \core\user_fields::for_identity($this->context)->get_required_fields();
+                $extrafields = \core_user\fields::for_identity($this->context)->get_required_fields();
                 $canviewidnumber = (array_search('idnumber', $extrafields) !== false);
 
                 // Ready for output!
index 9ad847c..efbe7a1 100644 (file)
@@ -50,7 +50,7 @@ class block_mentees extends block_base {
         $this->content = new stdClass();
 
         // get all the mentees, i.e. users you have a direct assignment to
-        $userfieldsapi = \core\user_fields::for_name();
+        $userfieldsapi = \core_user\fields::for_name();
         $allusernames = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
         if ($usercontexts = $DB->get_records_sql("SELECT c.instanceid, c.instanceid, $allusernames
                                                     FROM {role_assignments} ra, {context} c, {user} u
index c6777d2..4fcef20 100644 (file)
@@ -86,7 +86,7 @@ class fetcher {
         }
         $params = array();
 
-        $userfieldsapi = \core\user_fields::for_userpic()->including('username', 'deleted');
+        $userfieldsapi = \core_user\fields::for_userpic()->including('username', 'deleted');
         $userfields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
 
         // Add this to the SQL to show only group users.
index c6d111c..f2b3f22 100644 (file)
@@ -646,7 +646,7 @@ class blog_listing {
         if (!$userid) {
             $userid = $USER->id;
         }
-        $userfieldsapi = \core\user_fields::for_userpic();
+        $userfieldsapi = \core_user\fields::for_userpic();
         $allnamefields = $userfieldsapi->get_sql('u', false, '', 'useridalias', false)->selects;
         // The query used to locate blog entries is complicated.  It will be built from the following components:
         $requiredfields = "p.*, $allnamefields";  // The SELECT clause.
index 878f7a7..04bbef4 100644 (file)
@@ -234,7 +234,7 @@ function blog_rss_get_feed($context, $args) {
 
     switch ($type) {
         case 'user':
-            $userfieldsapi = \core\user_fields::for_name();
+            $userfieldsapi = \core_user\fields::for_name();
             $info = fullname($DB->get_record('user', array('id' => $id),
                     $userfieldsapi->get_sql('', false, '', '', false)->selects));
             break;
index f9d5cff..fe26ee2 100644 (file)
@@ -551,7 +551,7 @@ class comment {
         $params = array();
         $perpage = (!empty($CFG->commentsperpage))?$CFG->commentsperpage:15;
         $start = $page * $perpage;
-        $userfieldsapi = \core\user_fields::for_userpic();
+        $userfieldsapi = \core_user\fields::for_userpic();
         $ufields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
 
         list($componentwhere, $component) = $this->get_component_select_sql('c');
index 0fb00f9..0826561 100644 (file)
@@ -61,7 +61,7 @@ class comment_manager {
         }
         $comments = array();
 
-        $userfieldsapi = \core\user_fields::for_name();
+        $userfieldsapi = \core_user\fields::for_name();
         $usernamefields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
         $sql = "SELECT c.id, c.contextid, c.itemid, c.component, c.commentarea, c.userid, c.content, $usernamefields, c.timecreated
                   FROM {comments} c
@@ -76,7 +76,7 @@ class comment_manager {
             $item->time = userdate($item->timecreated);
             $item->content = format_text($item->content, FORMAT_MOODLE, $formatoptions);
             // Unset fields not related to the comment
-            foreach (\core\user_fields::get_name_fields() as $namefield) {
+            foreach (\core_user\fields::get_name_fields() as $namefield) {
                 unset($item->$namefield);
             }
             unset($item->timecreated);
diff --git a/completion/classes/cm_completion_details.php b/completion/classes/cm_completion_details.php
new file mode 100644 (file)
index 0000000..3d9b828
--- /dev/null
@@ -0,0 +1,198 @@
+<?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/>.
+
+/**
+ * Contains the class for building the user's activity completion details.
+ *
+ * @package   core_completion
+ * @copyright Jun Pataleta <jun@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types = 1);
+
+namespace core_completion;
+
+use cm_info;
+use completion_info;
+
+/**
+ * Class for building the user's activity completion details.
+ *
+ * @package   core_completion
+ * @copyright Jun Pataleta <jun@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class cm_completion_details {
+    /** @var completion_info The completion info instance for this cm's course. */
+    protected $completioninfo = null;
+
+    /** @var cm_info The course module information. */
+    protected $cminfo = null;
+
+    /** @var int The user ID. */
+    protected $userid = 0;
+
+    /** @var bool Whether to return automatic completion details. */
+    protected $returndetails = true;
+
+    /**
+     * Constructor.
+     *
+     * @param completion_info $completioninfo The completion info instance for this cm's course.
+     * @param cm_info $cminfo The course module information.
+     * @param int $userid The user ID.
+     * @param bool $returndetails Whether to return completion details or not.
+     */
+    public function __construct(completion_info $completioninfo, cm_info $cminfo, int $userid, bool $returndetails = true) {
+        $this->completioninfo = $completioninfo;
+        $this->cminfo = $cminfo;
+        $this->userid = $userid;
+        $this->returndetails = $returndetails;
+    }
+
+    /**
+     * Fetches the completion details for a user.
+     *
+     * @return array An array of completion details for a user containing the completion requirement's description and status.
+     */
+    public function get_details(): array {
+        if (!$this->is_automatic()) {
+            // No details need to be returned for modules that don't have automatic completion tracking enabled.
+            return [];
+        }
+
+        if (!$this->returndetails) {
+            // We don't need to return completion details.
+            return [];
+        }
+
+        $completiondata = $this->completioninfo->get_data($this->cminfo, false, $this->userid);
+        $hasoverride = !empty($this->overridden_by());
+
+        $details = [];
+
+        // Completion rule: Student must view this activity.
+        if (!empty($this->cminfo->completionview)) {
+            if (!$hasoverride) {
+                $status = COMPLETION_INCOMPLETE;
+                if ($completiondata->viewed == COMPLETION_VIEWED) {
+                    $status = COMPLETION_COMPLETE;
+                }
+            } else {
+                $status = $completiondata->completionstate;
+            }
+
+            $details['completionview'] = (object)[
+                'status' => $status,
+                'description' => get_string('detail_desc:view', 'completion'),
+            ];
+        }
+
+        // Completion rule: Student must receive a grade.
+        if (!is_null($this->cminfo->completiongradeitemnumber)) {
+            if (!$hasoverride) {
+                $status = $completiondata->completiongrade ?? COMPLETION_INCOMPLETE;
+            } else {
+                $status = $completiondata->completionstate;
+            }
+
+            $details['completionusegrade'] = (object)[
+                'status' => $status,
+                'description' => get_string('detail_desc:receivegrade', 'completion'),
+            ];
+        }
+
+        // Custom completion rules.
+        $cmcompletionclass = activity_custom_completion::get_cm_completion_class($this->cminfo->modname);
+        if (!isset($completiondata->customcompletion) || !$cmcompletionclass) {
+            // Return early if there are no custom rules to process or the cm completion class implementation is not available.
+            return $details;
+        }
+
+        /** @var activity_custom_completion $cmcompletion */
+        $cmcompletion = new $cmcompletionclass($this->cminfo, $this->userid);
+        foreach ($completiondata->customcompletion as $rule => $status) {
+            $details[$rule] = (object)[
+                'status' => !$hasoverride ? $status : $completiondata->completionstate,
+                'description' => $cmcompletion->get_custom_rule_description($rule),
+            ];
+        }
+
+        return $details;
+    }
+
+    /**
+     * Fetches the overall completion state of this course module.
+     *
+     * @return int The overall completion state for this course module.
+     */
+    public function get_overall_completion(): int {
+        $completiondata = $this->completioninfo->get_data($this->cminfo, false, $this->userid);
+        return (int)$completiondata->completionstate;
+    }
+
+    /**
+     * Whether this activity module has completion enabled.
+     *
+     * @return bool
+     */
+    public function has_completion(): bool {
+        return $this->completioninfo->is_enabled($this->cminfo) != COMPLETION_DISABLED;
+    }
+
+    /**
+     * Whether this activity module instance tracks completion automatically.
+     *
+     * @return bool
+     */
+    public function is_automatic(): bool {
+        return $this->cminfo->completion == COMPLETION_TRACKING_AUTOMATIC;
+    }
+
+    /**
+     * Fetches the user ID that has overridden the completion state of this activity for the user.
+     *
+     * @return int|null
+     */
+    public function overridden_by(): ?int {
+        $completiondata = $this->completioninfo->get_data($this->cminfo);
+        return isset($completiondata->overrideby) ? (int)$completiondata->overrideby : null;
+    }
+
+    /**
+     * Checks whether completion is being tracked for this user.
+     *
+     * @return bool
+     */
+    public function is_tracked_user(): bool {
+        return $this->completioninfo->is_tracked_user($this->userid);
+    }
+
+    /**
+     * Generates an instance of this class.
+     *
+     * @param cm_info $cminfo The course module info instance.
+     * @param int $userid The user ID that we're fetching completion details for.
+     * @param bool $returndetails  Whether to return completion details or not.
+     * @return cm_completion_details
+     */
+    public static function get_instance(cm_info $cminfo, int $userid, bool $returndetails = true): cm_completion_details {
+        $course = $cminfo->get_course();
+        $completioninfo = new completion_info($course);
+        return new self($completioninfo, $cminfo, $userid, $returndetails);
+    }
+}
diff --git a/completion/tests/behat/custom_completion_display_conditions.feature b/completion/tests/behat/custom_completion_display_conditions.feature
new file mode 100644 (file)
index 0000000..3ab7d2f
--- /dev/null
@@ -0,0 +1,62 @@
+@core @core_completion
+Feature: Allow teachers to edit the visibility of completion conditions in a course
+  In order to show students the course completion conditions in a course
+  As a teacher
+  I need to be able to edit completion conditions settings
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | enablecompletion | showcompletionconditions |
+      | Course 1 | C1        | 1                | 1                        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+    And the following "activities" exist:
+      | activity  | course | idnumber | name              | completion  | completionsubmit |
+      | choice    | C1     | c1m      | Test choice manual| 1           | 0                |
+      | choice    | C1     | c1a      | Test choice auto  | 2           | 1                |
+
+  Scenario: Completion condition displaying for manual and auto completion
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test choice manual"
+    And I should see "Mark as done"
+    And I am on "Course 1" course homepage
+    When I follow "Test choice auto"
+    Then I should see "Make a choice" in the "[data-region=completionrequirements]" "css_element"
+    # TODO MDL-70821: Check completion conditions display on course homepage.
+
+  Scenario: Completion condition displaying setting can be disabled at course level
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I navigate to "Edit settings" in current page administration
+    When I set the following fields to these values:
+      | Show completion conditions | No |
+    And I click on "Save and display" "button"
+    And I follow "Test choice auto"
+     # Completion conditions are always shown in the module's view page.
+    Then I should see "Make a choice" in the "[data-region=completionrequirements]" "css_element"
+    And I am on "Course 1" course homepage
+    And I follow "Test choice manual"
+    And I should see "Mark as done"
+
+  Scenario: Default show completion conditions value in course form when default show completion conditions admin setting is set to No
+    Given I log in as "admin"
+    And I navigate to "Courses > Course default settings" in site administration
+    When I set the following fields to these values:
+      | Show completion conditions | No |
+    And I click on "Save changes" "button"
+    And I navigate to "Courses > Add a new course" in site administration
+    Then the field "showcompletionconditions" matches value "No"
+
+  Scenario: Default show completion conditions value in course form when default show completion conditions admin setting is set to Yes
+    Given I log in as "admin"
+    And I navigate to "Courses > Course default settings" in site administration
+    When I set the following fields to these values:
+      | Show completion conditions | Yes |
+    And I click on "Save changes" "button"
+    And I navigate to "Courses > Add a new course" in site administration
+    Then the field "showcompletionconditions" matches value "Yes"
diff --git a/completion/tests/cm_completion_details_test.php b/completion/tests/cm_completion_details_test.php
new file mode 100644 (file)
index 0000000..1c44c86
--- /dev/null
@@ -0,0 +1,285 @@
+<?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/>.
+
+/**
+ * Contains unit tests for core_completion/cm_completion_details.
+ *
+ * @package   core_completion
+ * @copyright Jun Pataleta <jun@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types = 1);
+
+namespace core_completion;
+
+use advanced_testcase;
+use cm_info;
+use completion_info;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/completionlib.php');
+
+/**
+ * Class for unit testing core_completion/cm_completion_details.
+ *
+ * @package   core_completion
+ * @copyright Jun Pataleta <jun@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class cm_completion_details_test extends advanced_testcase {
+
+    /** @var completion_info A completion object. */
+    protected $completioninfo = null;
+
+    /**
+     * Fetches a mocked cm_completion_details instance.
+     *
+     * @param int|null $completion The completion tracking mode for the module.
+     * @param array $completionoptions Completion options (e.g. completionview, completionusegrade, etc.)
+     * @return cm_completion_details
+     */
+    protected function setup_data(?int $completion, array $completionoptions = []): cm_completion_details {
+        if (is_null($completion)) {
+            $completion = COMPLETION_TRACKING_AUTOMATIC;
+        }
+
+        // Mock a completion_info instance so we can simply mock the returns of completion_info::get_data() later.
+        $this->completioninfo = $this->getMockBuilder(completion_info::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        // Mock return of completion_info's is_enabled() method to match the expected completion tracking for the module.
+        $this->completioninfo->expects($this->any())
+            ->method('is_enabled')
+            ->willReturn($completion);
+
+        // Build a mock cm_info instance.
+        $mockcminfo = $this->getMockBuilder(cm_info::class)
+            ->disableOriginalConstructor()
+            ->onlyMethods(['__get'])
+            ->getMock();
+
+        // Mock the return of the magic getter method when fetching the cm_info object's customdata and instance values.
+        $mockcminfo->expects($this->any())
+            ->method('__get')
+            ->will($this->returnValueMap([
+                ['completion', $completion],
+                ['instance', 1],
+                ['modname', 'somenonexistentmod'],
+                ['completionview', $completionoptions['completionview'] ?? COMPLETION_VIEW_NOT_REQUIRED],
+                ['completiongradeitemnumber', $completionoptions['completionusegrade'] ?? null],
+            ]));
+
+        return new cm_completion_details($this->completioninfo, $mockcminfo, 2);
+    }
+
+    /**
+     * Provides data for test_has_completion().
+     *
+     * @return array[]
+     */
+    public function has_completion_provider(): array {
+        return [
+            'Automatic' => [
+                COMPLETION_TRACKING_AUTOMATIC, true
+            ],
+            'Manual' => [
+                COMPLETION_TRACKING_MANUAL, true
+            ],
+            'None' => [
+                COMPLETION_TRACKING_NONE, false
+            ],
+        ];
+    }
+
+    /**
+     * Test for has_completion().
+     *
+     * @dataProvider has_completion_provider
+     * @param int $completion The completion tracking mode.
+     * @param bool $expectedresult Expected result.
+     */
+    public function test_has_completion(int $completion, bool $expectedresult) {
+        $cmcompletion = $this->setup_data($completion);
+
+        $this->assertEquals($expectedresult, $cmcompletion->has_completion());
+    }
+
+    /**
+     * Provides data for test_is_automatic().
+     *
+     * @return array[]
+     */
+    public function is_automatic_provider(): array {
+        return [
+            'Automatic' => [
+                COMPLETION_TRACKING_AUTOMATIC, true
+            ],
+            'Manual' => [
+                COMPLETION_TRACKING_MANUAL, false
+            ],
+            'None' => [
+                COMPLETION_TRACKING_NONE, false
+            ],
+        ];
+    }
+
+    /**
+     * Test for is_available().
+     *
+     * @dataProvider is_automatic_provider
+     * @param int $completion The completion tracking mode.
+     * @param bool $expectedresult Expected result.
+     */
+    public function test_is_automatic(int $completion, bool $expectedresult) {
+        $cmcompletion = $this->setup_data($completion);
+
+        $this->assertEquals($expectedresult, $cmcompletion->is_automatic());
+    }
+
+    /**
+     * Data provider for test_get_overall_completion().
+     * @return array[]
+     */
+    public function overall_completion_provider(): array {
+        return [
+            'Complete' => [COMPLETION_COMPLETE],
+            'Incomplete' => [COMPLETION_INCOMPLETE],
+        ];
+    }
+
+    /**
+     * Test for get_overall_completion().
+     *
+     * @dataProvider overall_completion_provider
+     * @param int $state
+     */
+    public function test_get_overall_completion(int $state) {
+        $cmcompletion = $this->setup_data(COMPLETION_TRACKING_AUTOMATIC);
+
+        $this->completioninfo->expects($this->once())
+            ->method('get_data')
+            ->willReturn((object)['completionstate' => $state]);
+
+        $this->assertEquals($state, $cmcompletion->get_overall_completion());
+    }
+
+    /**
+     * Data provider for test_get_details().
+     * @return array[]
+     */
+    public function get_details_provider() {
+        return [
+            'No completion tracking' => [
+                COMPLETION_TRACKING_NONE, null, null, []
+            ],
+            'Manual completion tracking' => [
+                COMPLETION_TRACKING_MANUAL, null, null, []
+            ],
+            'Automatic, require view, not viewed' => [
+                COMPLETION_TRACKING_AUTOMATIC, COMPLETION_INCOMPLETE, null, [
+                    'completionview' => (object)[
+                        'status' => COMPLETION_INCOMPLETE,
+                        'description' => get_string('detail_desc:view', 'completion'),
+                    ]
+                ]
+            ],
+            'Automatic, require view, viewed' => [
+                COMPLETION_TRACKING_AUTOMATIC, COMPLETION_COMPLETE, null, [
+                    'completionview' => (object)[
+                        'status' => COMPLETION_COMPLETE,
+                        'description' => get_string('detail_desc:view', 'completion'),
+                    ]
+                ]
+            ],
+            'Automatic, require grade, incomplete' => [
+                COMPLETION_TRACKING_AUTOMATIC, null, COMPLETION_INCOMPLETE, [
+                    'completionusegrade' => (object)[
+                        'status' => COMPLETION_INCOMPLETE,
+                        'description' => get_string('detail_desc:receivegrade', 'completion'),
+                    ]
+                ]
+            ],
+            'Automatic, require grade, complete' => [
+                COMPLETION_TRACKING_AUTOMATIC, null, COMPLETION_COMPLETE, [
+                    'completionusegrade' => (object)[
+                        'status' => COMPLETION_COMPLETE,
+                        'description' => get_string('detail_desc:receivegrade', 'completion'),
+                    ]
+                ]
+            ],
+            'Automatic, require view (complete) and grade (incomplete)' => [
+                COMPLETION_TRACKING_AUTOMATIC, COMPLETION_COMPLETE, COMPLETION_INCOMPLETE, [
+                    'completionview' => (object)[
+                        'status' => COMPLETION_COMPLETE,
+                        'description' => get_string('detail_desc:view', 'completion'),
+                    ],
+                    'completionusegrade' => (object)[
+                        'status' => COMPLETION_INCOMPLETE,
+                        'description' => get_string('detail_desc:receivegrade', 'completion'),
+                    ]
+                ]
+            ],
+            'Automatic, require view (incomplete) and grade (complete)' => [
+                COMPLETION_TRACKING_AUTOMATIC, COMPLETION_INCOMPLETE, COMPLETION_COMPLETE, [
+                    'completionview' => (object)[
+                        'status' => COMPLETION_INCOMPLETE,
+                        'description' => get_string('detail_desc:view', 'completion'),
+                    ],
+                    'completionusegrade' => (object)[
+                        'status' => COMPLETION_COMPLETE,
+                        'description' => get_string('detail_desc:receivegrade', 'completion'),
+                    ]
+                ]
+            ],
+        ];
+    }
+
+    /**
+     * Test for \core_completion\cm_completion_details::get_details().
+     *
+     * @dataProvider get_details_provider
+     * @param int $completion The completion tracking mode.
+     * @param int|null $completionview Completion status of the "view" completion condition.
+     * @param int|null $completiongrade Completion status of the "must receive grade" completion condition.
+     * @param array $expecteddetails Expected completion details returned by get_details().
+     */
+    public function test_get_details(int $completion, ?int $completionview, ?int $completiongrade, array $expecteddetails) {
+        $options = [];
+        $getdatareturn = (object)[
+            'viewed' => $completionview,
+            'completiongrade' => $completiongrade,
+        ];
+
+        if (!is_null($completionview)) {
+            $options['completionview'] = true;
+        }
+        if (!is_null($completiongrade)) {
+            $options['completionusegrade'] = true;
+        }
+
+        $cmcompletion = $this->setup_data($completion, $options);
+
+        $this->completioninfo->expects($this->any())
+            ->method('get_data')
+            ->willReturn($getdatareturn);
+
+        $this->assertEquals($expecteddetails, $cmcompletion->get_details());
+    }
+}
diff --git a/course/amd/build/manual_completion_toggle.min.js b/course/amd/build/manual_completion_toggle.min.js
new file mode 100644 (file)
index 0000000..5f6ef96
Binary files /dev/null and b/course/amd/build/manual_completion_toggle.min.js differ
diff --git a/course/amd/build/manual_completion_toggle.min.js.map b/course/amd/build/manual_completion_toggle.min.js.map
new file mode 100644 (file)
index 0000000..e0720d1
Binary files /dev/null and b/course/amd/build/manual_completion_toggle.min.js.map differ
index 8d7c2a2..9b34369 100644 (file)
Binary files a/course/amd/build/repository.min.js and b/course/amd/build/repository.min.js differ
index c6b8c7c..2cf04de 100644 (file)
Binary files a/course/amd/build/repository.min.js.map and b/course/amd/build/repository.min.js.map differ
diff --git a/course/amd/src/manual_completion_toggle.js b/course/amd/src/manual_completion_toggle.js
new file mode 100644 (file)
index 0000000..c14eb9b
--- /dev/null
@@ -0,0 +1,122 @@
+// 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/>.
+
+/**
+ * Provides the functionality for toggling the manual completion state of a course module through
+ * the manual completion button.
+ *
+ * @module      core_course/manual_completion_toggle
+ * @package     core_course
+ * @copyright   2021 Jun Pataleta <jun@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import Templates from 'core/templates';
+import Notification from 'core/notification';
+import {toggleManualCompletion} from 'core_course/repository';
+
+/**
+ * Selectors in the manual completion template.
+ *
+ * @type {{MANUAL_TOGGLE: string}}
+ */
+const SELECTORS = {
+    MANUAL_TOGGLE: 'button[data-action=toggle-manual-completion]',
+};
+
+/**
+ * Toggle type values for the data-toggletype attribute in the core_course/completion_manual template.
+ *
+ * @type {{TOGGLE_UNDO: string, TOGGLE_MARK_DONE: string}}
+ */
+const TOGGLE_TYPES = {
+    TOGGLE_MARK_DONE: 'manual:mark-done',
+    TOGGLE_UNDO: 'manual:undo',
+};
+
+/**
+ * Whether the event listener has already been registered for this module.
+ *
+ * @type {boolean}
+ */
+let registered = false;
+
+/**
+ * Registers the click event listener for the manual completion toggle button.
+ */
+export const init = () => {
+    if (registered) {
+        return;
+    }
+    document.addEventListener('click', (e) => {
+        const toggleButton = e.target.closest(SELECTORS.MANUAL_TOGGLE);
+        if (toggleButton) {
+            e.preventDefault();
+            toggleManualCompletionState(toggleButton).catch(Notification.exception);
+        }
+    });
+    registered = true;
+};
+
+/**
+ * Toggles the manual completion state of the module for the given user.
+ *
+ * @param {HTMLElement} toggleButton
+ * @returns {Promise<void>}
+ */
+const toggleManualCompletionState = async(toggleButton) => {
+    // Make a copy of the original content of the button.
+    const originalInnerHtml = toggleButton.innerHTML;
+
+    // Disable the button to prevent double clicks.
+    toggleButton.setAttribute('disabled', 'disabled');
+
+    // Get button data.
+    const toggleType = toggleButton.getAttribute('data-toggletype');
+    const cmid = toggleButton.getAttribute('data-cmid');
+    const activityname = toggleButton.getAttribute('data-activityname');
+    // Get the target completion state.
+    const completed = toggleType === TOGGLE_TYPES.TOGGLE_MARK_DONE;
+
+    // Replace the button contents with the loading icon.
+    const loadingHtml = await Templates.render('core/loading', {});
+    await Templates.replaceNodeContents(toggleButton, loadingHtml, '');
+
+    try {
+        // Call the webservice to update the manual completion status.
+        await toggleManualCompletion(cmid, completed);
+
+        // All good so far. Refresh the manual completion button to reflect its new state by re-rendering the template.
+        const templateContext = {
+            cmid: cmid,
+            activityname: activityname,
+            overallcomplete: completed,
+            overallincomplete: !completed,
+            istrackeduser: true, // We know that we're tracking completion for this user given the presence of this button.
+        };
+        const renderObject = await Templates.renderForPromise('core_course/completion_manual', templateContext);
+
+        // Replace the toggle button with the newly loaded template.
+        await Templates.replaceNode(toggleButton, renderObject.html, renderObject.js);
+
+    } catch (exception) {
+        // In case of an error, revert the original state and appearance of the button.
+        toggleButton.removeAttribute('disabled');
+        toggleButton.innerHTML = originalInnerHtml;
+
+        // Show the exception.
+        Notification.exception(exception);
+    }
+};
index 956f309..47f37c7 100644 (file)
  * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/ajax'], function($, Ajax) {
-
-    /**
-     * Get the list of courses that the logged in user is enrolled in for a given
-     * timeline classification.
-     *
-     * @param {string} classification past, inprogress, or future
-     * @param {int} limit Only return this many results
-     * @param {int} offset Skip this many results from the start of the result set
-     * @param {string} sort Column to sort by and direction, e.g. 'shortname asc'
-     * @return {object} jQuery promise resolved with courses.
-     */
-    var getEnrolledCoursesByTimelineClassification = function(classification, limit, offset, sort) {
-        var args = {
-            classification: classification
-        };
-
-        if (typeof limit !== 'undefined') {
-            args.limit = limit;
-        }
 
-        if (typeof offset !== 'undefined') {
-            args.offset = offset;
-        }
+import Ajax from 'core/ajax';
 
-        if (typeof sort !== 'undefined') {
-            args.sort = sort;
-        }
+/**
+ * Get the list of courses that the logged in user is enrolled in for a given
+ * timeline classification.
+ *
+ * @param {string} classification past, inprogress, or future
+ * @param {int} limit Only return this many results
+ * @param {int} offset Skip this many results from the start of the result set
+ * @param {string} sort Column to sort by and direction, e.g. 'shortname asc'
+ * @return {object} jQuery promise resolved with courses.
+ */
+const getEnrolledCoursesByTimelineClassification = (classification, limit, offset, sort) => {
+    const args = {
+        classification: classification
+    };
 
-        var request = {
-            methodname: 'core_course_get_enrolled_courses_by_timeline_classification',
-            args: args
-        };
+    if (typeof limit !== 'undefined') {
+        args.limit = limit;
+    }
 
-        return Ajax.call([request])[0];
+    if (typeof offset !== 'undefined') {
+        args.offset = offset;
+    }
+
+    if (typeof sort !== 'undefined') {
+        args.sort = sort;
+    }
+
+    const request = {
+        methodname: 'core_course_get_enrolled_courses_by_timeline_classification',
+        args: args
     };
 
-    /**
-     * Get the list of courses that the user has most recently accessed.
-     *
-     * @method getLastAccessedCourses
-     * @param {int} userid User from which the courses will be obtained
-     * @param {int} limit Only return this many results
-     * @param {int} offset Skip this many results from the start of the result set
-     * @param {string} sort Column to sort by and direction, e.g. 'shortname asc'
-     * @return {promise} Resolved with an array of courses
-     */
-    var getLastAccessedCourses = function(userid, limit, offset, sort) {
-        var args = {};
-
-        if (typeof userid !== 'undefined') {
-            args.userid = userid;
-        }
+    return Ajax.call([request])[0];
+};
 
-        if (typeof limit !== 'undefined') {
-            args.limit = limit;
-        }
+/**
+ * Get the list of courses that the user has most recently accessed.
+ *
+ * @method getLastAccessedCourses
+ * @param {int} userid User from which the courses will be obtained
+ * @param {int} limit Only return this many results
+ * @param {int} offset Skip this many results from the start of the result set
+ * @param {string} sort Column to sort by and direction, e.g. 'shortname asc'
+ * @return {promise} Resolved with an array of courses
+ */
+const getLastAccessedCourses = (userid, limit, offset, sort) => {
+    const args = {};
 
-        if (typeof offset !== 'undefined') {
-            args.offset = offset;
-        }
+    if (typeof userid !== 'undefined') {
+        args.userid = userid;
+    }
 
-        if (typeof sort !== 'undefined') {
-            args.sort = sort;
-        }
+    if (typeof limit !== 'undefined') {
+        args.limit = limit;
+    }
+
+    if (typeof offset !== 'undefined') {
+        args.offset = offset;
+    }
 
-        var request = {
-            methodname: 'core_course_get_recent_courses',
-            args: args
-        };
+    if (typeof sort !== 'undefined') {
+        args.sort = sort;
+    }
 
-        return Ajax.call([request])[0];
+    const request = {
+        methodname: 'core_course_get_recent_courses',
+        args: args
     };
 
-    /**
-     * Get the list of users enrolled in this cmid.
-     *
-     * @param {Number} cmid Course Module from which the users will be obtained
-     * @param {Number} groupID Group ID from which the users will be obtained
-     * @returns {Promise} Promise containing a list of users
-     */
-    var getEnrolledUsersFromCourseModuleID = function(cmid, groupID) {
-        var request = {
-            methodname: 'core_course_get_enrolled_users_by_cmid',
-            args: {
-                cmid: cmid,
-                groupid: groupID,
-            },
-        };
-
-        return Ajax.call([request])[0];
+    return Ajax.call([request])[0];
+};
+
+/**
+ * Get the list of users enrolled in this cmid.
+ *
+ * @param {Number} cmid Course Module from which the users will be obtained
+ * @param {Number} groupID Group ID from which the users will be obtained
+ * @returns {Promise} Promise containing a list of users
+ */
+const getEnrolledUsersFromCourseModuleID = (cmid, groupID) => {
+    var request = {
+        methodname: 'core_course_get_enrolled_users_by_cmid',
+        args: {
+            cmid: cmid,
+            groupid: groupID,
+        },
     };
 
-    return {
-        getEnrolledCoursesByTimelineClassification: getEnrolledCoursesByTimelineClassification,
-        getLastAccessedCourses: getLastAccessedCourses,
-        getUsersFromCourseModuleID: getEnrolledUsersFromCourseModuleID,
+    return Ajax.call([request])[0];
+};
+
+/**
+ * Toggle the completion state of an activity with manual completion.
+ *
+ * @param {Number} cmid The course module ID.
+ * @param {Boolean} completed Whether to set as complete or not.
+ * @returns {object} jQuery promise
+ */
+const toggleManualCompletion = (cmid, completed) => {
+    const request = {
+        methodname: 'core_completion_update_activity_completion_status_manually',
+        args: {
+            cmid,
+            completed,
+        }
     };
-});
+    return Ajax.call([request])[0];
+};
+
+export default {
+    getEnrolledCoursesByTimelineClassification,
+    getLastAccessedCourses,
+    getUsersFromCourseModuleID: getEnrolledUsersFromCourseModuleID,
+    toggleManualCompletion,
+};
index e538368..d2386b8 100644 (file)
@@ -1033,7 +1033,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         list($sql2, $params2) = $DB->get_in_or_equal($managerroles, SQL_PARAMS_NAMED, 'rid');
         list($sort, $sortparams) = users_order_by_sql('u');
         $notdeleted = array('notdeleted' => 0);
-        $userfieldsapi = \core\user_fields::for_name();
+        $userfieldsapi = \core_user\fields::for_name();
         $allnames = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
         $sql = "SELECT ra.contextid, ra.id AS raid,
                        r.id AS roleid, r.name AS rolename, r.shortname AS roleshortname,
diff --git a/course/classes/output/activity_information.php b/course/classes/output/activity_information.php
new file mode 100644 (file)
index 0000000..2d281c3
--- /dev/null
@@ -0,0 +1,154 @@
+<?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/>.
+
+/**
+ * File containing the class activity information renderable.
+ *
+ * @package    core_course
+ * @copyright  2021 Jun Pataleta <jun@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+use cm_info;
+use completion_info;
+use context;
+use core\activity_dates;
+use core_completion\cm_completion_details;
+use core_user;
+use core_user\fields;
+use renderable;
+use renderer_base;
+use stdClass;
+use templatable;
+
+/**
+ * The activity information renderable class.
+ *
+ * @package    core_course
+ * @copyright  2021 Jun Pataleta <jun@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class activity_information implements renderable, templatable {
+
+    /** @var cm_info The course module information. */
+    protected $cminfo = null;
+
+    /** @var array The array of relevant dates for this activity. */
+    protected $activitydates = [];
+
+    /** @var cm_completion_details The user's completion details for this activity. */
+    protected $cmcompletion = null;
+
+    /**
+     * Constructor.
+     *
+     * @param cm_info $cminfo The course module information.
+     * @param cm_completion_details $cmcompletion The course module information.
+     * @param array $activitydates The activity dates.
+     */
+    public function __construct(cm_info $cminfo, cm_completion_details $cmcompletion, array $activitydates) {
+        $this->cminfo = $cminfo;
+        $this->cmcompletion = $cmcompletion;
+        $this->activitydates = $activitydates;
+    }
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param renderer_base $output Renderer base.
+     * @return stdClass
+     */
+    public function export_for_template(renderer_base $output): stdClass {
+        $data = $this->build_completion_data();
+
+        $data->cmid = $this->cminfo->id;
+        $data->activityname = $this->cminfo->name;
+        $data->activitydates = $this->activitydates;
+
+        return $data;
+    }
+
+    /**
+     * Builds the completion data for export.
+     *
+     * @return stdClass
+     */
+    protected function build_completion_data(): stdClass {
+        $data = new stdClass();
+
+        $data->hascompletion = $this->cmcompletion->has_completion();
+        $data->isautomatic = $this->cmcompletion->is_automatic();
+
+        // Get the name of the user overriding the completion condition, if available.
+        $data->overrideby = null;
+        $overrideby = $this->cmcompletion->overridden_by();
+        $overridebyname = null;
+        if (!empty($overrideby)) {
+            $userfields = fields::for_name();
+            $overridebyrecord = core_user::get_user($overrideby, 'id ' . $userfields->get_sql()->selects, MUST_EXIST);
+            $data->overrideby = fullname($overridebyrecord);
+        }
+
+        // We'll show only the completion conditions and not the completion status if we're not tracking completion for this user
+        // (e.g. a teacher, admin).
+        $data->istrackeduser = $this->cmcompletion->is_tracked_user();
+
+        // Overall completion states.
+        $overallcompletion = $this->cmcompletion->get_overall_completion();
+        $data->overallcomplete = $overallcompletion == COMPLETION_COMPLETE;
+        $data->overallincomplete = $overallcompletion == COMPLETION_INCOMPLETE;
+
+        // Set an accessible description for manual completions with overridden completion state.
+        if (!$data->isautomatic && $data->overrideby) {
+            $setbydata = (object)[
+                'activityname' => $this->cminfo->name,
+                'setby' => $data->overrideby,
+            ];
+            $setbylangkey = $data->overallcomplete ? 'completion_setby:manual:done' : 'completion_setby:manual:markdone';
+            $data->accessibledescription = get_string($setbylangkey, 'course', $setbydata);
+        }
+
+        // Build automatic completion details.
+        $details = [];
+        foreach ($this->cmcompletion->get_details() as $key => $detail) {
+            // Set additional attributes for the template.
+            $detail->key = $key;
+            $detail->statuscomplete = in_array($detail->status, [COMPLETION_COMPLETE, COMPLETION_COMPLETE_PASS]);
+            $detail->statuscompletefail = $detail->status == COMPLETION_COMPLETE_FAIL;
+            $detail->statusincomplete = $detail->status == COMPLETION_INCOMPLETE;
+
+            // Add an accessible description to be used for title and aria-label attributes for overridden completion details.
+            if ($data->overrideby) {
+                $setbydata = (object)[
+                    'condition' => $detail->description,
+                    'setby' => $data->overrideby,
+                ];
+                $detail->accessibledescription = get_string('completion_setby:auto', 'course', $setbydata);
+            }
+
+            // We don't need the status in the template.
+            unset($detail->status);
+
+            $details[] = $detail;
+        }
+        $data->completiondetails = $details;
+
+        return $data;
+    }
+}
index 9e7773d..3233a4d 100644 (file)
@@ -278,6 +278,11 @@ class course_edit_form extends moodleform {
         $mform->addHelpButton('showreports', 'showreports');
         $mform->setDefault('showreports', $courseconfig->showreports);
 
+        // Show activity dates.
+        $mform->addElement('selectyesno', 'showactivitydates', get_string('showactivitydates'));
+        $mform->addHelpButton('showactivitydates', 'showactivitydates');
+        $mform->setDefault('showactivitydates', $courseconfig->showactivitydates);
+
         // Files and uploads.
         $mform->addElement('header', 'filehdr', get_string('filesanduploads'));
 
@@ -312,6 +317,12 @@ class course_edit_form extends moodleform {
             $mform->addElement('selectyesno', 'enablecompletion', get_string('enablecompletion', 'completion'));
             $mform->setDefault('enablecompletion', $courseconfig->enablecompletion);
             $mform->addHelpButton('enablecompletion', 'enablecompletion', 'completion');
+
+            $showcompletionconditions = $courseconfig->showcompletionconditions ?? COMPLETION_SHOW_CONDITIONS;
+            $mform->addElement('selectyesno', 'showcompletionconditions', get_string('showcompletionconditions', 'completion'));
+            $mform->addHelpButton('showcompletionconditions', 'showcompletionconditions', 'completion');
+            $mform->setDefault('showcompletionconditions', $showcompletionconditions);
+            $mform->hideIf('showcompletionconditions', 'enablecompletion', 'eq', COMPLETION_HIDE_CONDITIONS);
         } else {
             $mform->addElement('hidden', 'enablecompletion');
             $mform->setType('enablecompletion', PARAM_INT);
index b4c870d..3af8cbe 100644 (file)
@@ -77,7 +77,7 @@ class recent_form extends moodleform {
             $options[0] = get_string('allparticipants');
             $options[$CFG->siteguest] = get_string('guestuser');
 
-            $userfieldsapi = \core\user_fields::for_userpic();
+            $userfieldsapi = \core_user\fields::for_userpic();
             $ufields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
 
             if (isset($groupoptions[0])) {
index 57af228..fce2e5f 100644 (file)
@@ -2134,6 +2134,19 @@ class core_course_renderer extends plugin_renderer_base {
         return $this->coursecat_tree($chelper, $tree);
     }
 
+    /**
+     * Renders the activity information.
+     *
+     * Defer to template.
+     *
+     * @param \core_course\output\activity_information $page
+     * @return string html for the page
+     */
+    public function render_activity_information(\core_course\output\activity_information $page) {
+        $data = $page->export_for_template($this->output);
+        return $this->output->render_from_template('core_course/activity_info', $data);
+    }
+
     /**
      * Renders the activity navigation.
      *
diff --git a/course/templates/activity_date.mustache b/course/templates/activity_date.mustache
new file mode 100644 (file)
index 0000000..c50e292
--- /dev/null
@@ -0,0 +1,30 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_course/activity_date
+
+    Template for displaying the activity's dates.
+
+    Example context (json):
+    {
+        "label": "Opens:",
+        "timestamp": 1293876000
+    }
+}}
+<div>
+    <strong>{{label}}</strong> {{#userdate}} {{timestamp}}, {{#str}} strftimedatetime, core_langconfig {{/str}} {{/userdate}}
+</div>
diff --git a/course/templates/activity_info.mustache b/course/templates/activity_info.mustache
new file mode 100644 (file)
index 0000000..edcc655
--- /dev/null
@@ -0,0 +1,68 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_course/activity_info
+
+    Container to display activity information such as:
+      - Activity dates
+      - Activity completion requirements (automatic completion)
+      - Manual completion button
+
+    Example context (json):
+    {
+        "hascompletion": true,
+        "isautomatic": true,
+        "istrackeduser": true,
+        "activitydates": [
+            {
+                "label": "Opens:",
+                "timestamp": 1293876000
+            }
+        ],
+        "completiondetails": [
+             {
+                "statuscomplete": 1,
+                "description": "Viewed"
+            },
+            {
+                "statusincomplete": 1,
+                "description": "Receive a grade"
+            }
+        ]
+    }
+}}
+<div data-region="activity-information" class="activity-information">
+    <div class="mb-1">
+        {{#activitydates}}
+            {{>core_course/activity_date}}
+        {{/activitydates}}
+    </div>
+    <div>
+        {{#hascompletion}}
+            {{#isautomatic}}
+                <div class="automatic-completion-conditions" data-region ="completionrequirements" role="list" aria-label="{{#str}}completionrequirements, core_course{{/str}}">
+                    {{#completiondetails}}
+                        {{> core_course/completion_automatic }}
+                    {{/completiondetails}}
+                </div>
+            {{/isautomatic}}
+            {{^isautomatic}}
+                {{> core_course/completion_manual }}
+            {{/isautomatic}}
+        {{/hascompletion}}
+    </div>
+</div>
diff --git a/course/templates/completion_automatic.mustache b/course/templates/completion_automatic.mustache
new file mode 100644 (file)
index 0000000..5875b87
--- /dev/null
@@ -0,0 +1,63 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_course/completion_automatic
+
+    Template for displaying an automatic completion rule and its status.
+
+    Example context (json):
+    {
+        "statuscomplete": 1,
+        "description": "View",
+        "istrackeduser": true,
+        "accessibledescription": "Done: View (Set by Admin User)"
+    }
+}}
+{{#istrackeduser}}
+    {{#statuscomplete}}
+    <span class="badge badge-success rounded mb-1" role="listitem" {{!
+        }}{{#accessibledescription}}{{!
+            }}title="{{.}}" {{!
+            }}aria-label="{{.}}" {{!
+        }}{{/accessibledescription}}>
+        <strong>{{#str}}completion_automatic:done, core_course{{/str}}</strong> <span class="font-weight-normal">{{description}}</span>
+    </span>
+    {{/statuscomplete}}
+    {{#statuscompletefail}}
+    <span class="badge badge-danger rounded mb-1" role="listitem" {{!
+        }}{{#accessibledescription}}{{!
+            }}title="{{.}}" {{!
+            }}aria-label="{{.}}" {{!
+        }}{{/accessibledescription}}>
+        <strong>{{#str}}completion_automatic:failed, core_course{{/str}}</strong> <span class="font-weight-normal">{{description}}</span>
+    </span>
+    {{/statuscompletefail}}
+    {{#statusincomplete}}
+    <span class="badge badge-secondary rounded mb-1" role="listitem" {{!
+        }}{{#accessibledescription}}{{!
+            }}title="{{.}}" {{!
+            }}aria-label="{{.}}" {{!
+        }}{{/accessibledescription}}>
+        <strong>{{#str}}completion_automatic:todo, core_course{{/str}}</strong> <span class="font-weight-normal">{{description}}</span>
+    </span>
+    {{/statusincomplete}}
+{{/istrackeduser}}
+{{^istrackeduser}}
+    <span class="badge badge-secondary rounded mb-1" role="listitem">
+        <span class="font-weight-normal">{{description}}</span>
+    </span>
+{{/istrackeduser}}
diff --git a/course/templates/completion_manual.mustache b/course/templates/completion_manual.mustache
new file mode 100644 (file)
index 0000000..c8b38b1
--- /dev/null
@@ -0,0 +1,67 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_course/completion_manual
+
+    Template for displaying the manual completion button.
+
+    Example context (json):
+    {
+        "cmid": 0,
+        "overallcomplete": true,
+        "overallincomplete": false
+    }
+}}
+{{#istrackeduser}}
+    {{#overallcomplete}}
+        <button class="btn btn-outline-success" data-action="toggle-manual-completion" data-toggletype="manual:undo" data-cmid="{{cmid}}" data-activityname="{{activityname}}" {{!
+        }}{{#accessibledescription}}{{!
+            }}title="{{.}}" {{!
+            }}aria-label="{{.}}" {{!
+        }}{{/accessibledescription}}{{!
+        }}{{^accessibledescription}}{{!
+            }}title="{{#str}}completion_manual:aria:done, course, {{activityname}} {{/str}}" {{!
+            }}aria-label="{{#str}}completion_manual:aria:done, course, {{activityname}} {{/str}}" {{!
+        }}{{/accessibledescription}}>
+            <i class="fa fa-check" aria-hidden="true"></i> {{#str}} completion_manual:done, core_course {{/str}}
+        </button>
+    {{/overallcomplete}}
+    {{#overallincomplete}}
+        <button class="btn btn-outline-secondary" data-action="toggle-manual-completion" data-toggletype="manual:mark-done" data-cmid="{{cmid}}" data-activityname="{{activityname}}" {{!
+        }}{{#accessibledescription}}{{!
+            }}title="{{.}}" {{!
+            }}aria-label="{{.}}" {{!
+        }}{{/accessibledescription}}{{!
+        }}{{^accessibledescription}}{{!
+            }}title="{{#str}}completion_manual:aria:markdone, course, {{activityname}} {{/str}}" {{!
+            }}aria-label="{{#str}}completion_manual:aria:markdone, course, {{activityname}} {{/str}}" {{!
+        }}{{/accessibledescription}}>
+            {{#str}} completion_manual:markdone, core_course {{/str}}
+        </button>
+    {{/overallincomplete}}
+{{/istrackeduser}}
+{{^istrackeduser}}
+    <button class="btn btn-outline-secondary" disabled>
+        {{#str}} completion_manual:markdone, core_course {{/str}}
+    </button>
+{{/istrackeduser}}
+
+{{#js}}
+require(['core_course/manual_completion_toggle'], toggle => {
+    toggle.init()
+});
+{{/js}}
diff --git a/course/tests/behat/course_activity_dates.feature b/course/tests/behat/course_activity_dates.feature
new file mode 100644 (file)
index 0000000..9b17854
--- /dev/null
@@ -0,0 +1,64 @@
+@core @core_course
+Feature: Allow teachers to edit the visibility of activity dates in a course
+  In order to show students the activity dates in a course
+  As a teacher
+  I need to be able to edit activity dates settings
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+    And the following "activities" exist:
+      | activity | course | idnumber | name          | intro                   | timeopen      | timeclose     |
+      | choice   | C1     | choice1  | Test choice 1 | Test choice description | ##yesterday## | ##tomorrow##  |
+
+  Scenario: Activity dates setting can be enabled to display activity dates in a course
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I navigate to "Edit settings" in current page administration
+    When I set the following fields to these values:
+      | Show activity dates | Yes |
+    And I click on "Save and display" "button"
+    And I follow "Test choice"
+    Then I should see "Opened:" in the "[data-region=activity-information]" "css_element"
+    And I should see "Closes:" in the "[data-region=activity-information]" "css_element"
+    # TODO MDL-70821: Check activity dates display on course homepage.
+
+  Scenario: Activity dates setting can be disabled to hidden activity dates in a course
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I navigate to "Edit settings" in current page administration
+    When I set the following fields to these values:
+      | Show activity dates | No |
+    And I click on "Save and display" "button"
+    And I follow "Test choice"
+    # Activity dates are always shown in the module's view page.
+    Then I should see "Opened:"
+    And I should see "Closes:"
+    And I am on "Course 1" course homepage
+    And I should not see "Opened:"
+    And I should not see "Closes:"
+
+  Scenario: Default activity dates setting default value can changed to No
+    Given I log in as "admin"
+    And I navigate to "Courses > Course default settings" in site administration
+    When I set the following fields to these values:
+      | Show activity dates | No |
+    And I click on "Save changes" "button"
+    And I navigate to "Courses > Add a new course" in site administration
+    Then the field "showactivitydates" matches value "No"
+
+  Scenario: Default activity dates setting default value can changed to Yes
+    Given I log in as "admin"
+    And I navigate to "Courses > Course default settings" in site administration
+    When I set the following fields to these values:
+      | Show activity dates | Yes |
+    And I click on "Save changes" "button"
+    And I navigate to "Courses > Add a new course" in site administration
+    Then the field "showactivitydates" matches value "Yes"
index 1bd9cc4..fab67db 100644 (file)
@@ -95,7 +95,7 @@ switch ($action) {
         $page = optional_param('page', 0, PARAM_INT);
         $outcome->response = $manager->search_other_users($search, $searchanywhere, $page);
         // TODO Does not support custom user profile fields (MDL-70456).
-        $extrafields = \core\user_fields::get_identity_fields($context, false);
+        $extrafields = \core_user\fields::get_identity_fields($context, false);
         $useroptions = array();
         // User is not enrolled, either link to site profile or do not link at all.
         if (has_capability('moodle/user:viewdetails', context_system::instance())) {
index 26dee50..116d1ff 100644 (file)
@@ -558,10 +558,10 @@ class core_enrol_external extends external_api {
 
         $results = array();
         // Add also extra user fields.
-        $identityfields = \core\user_fields::get_identity_fields($context, true);
+        $identityfields = \core_user\fields::get_identity_fields($context, true);
         $customprofilefields = [];
         foreach ($identityfields as $key => $value) {
-            if ($fieldname = \core\user_fields::match_custom_field($value)) {
+            if ($fieldname = \core_user\fields::match_custom_field($value)) {
                 unset($identityfields[$key]);
                 $customprofilefields[$fieldname] = true;
             }
@@ -673,7 +673,7 @@ class core_enrol_external extends external_api {
         $requiredfields = array_merge(
                 ['id', 'fullname', 'profileimageurl', 'profileimageurlsmall'],
                 // TODO Does not support custom user profile fields (MDL-70456).
-                \core\user_fields::get_identity_fields($context, false)
+                \core_user\fields::get_identity_fields($context, false)
         );
         foreach ($users['users'] as $user) {
             if ($userdetails = user_get_user_details($user, $course, $requiredfields)) {
index ba49c56..10f3a0d 100644 (file)
@@ -23,7 +23,7 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-use core\user_fields;
+use core_user\fields;
 
 defined('MOODLE_INTERNAL') || die();
 
@@ -240,7 +240,7 @@ class course_enrolment_manager {
             list($instancessql, $params, $filter) = $this->get_instance_sql();
             list($filtersql, $moreparams) = $this->get_filter_sql();
             $params += $moreparams;
-            $userfields = user_fields::for_identity($this->get_context())->with_userpic()->excluding('lastaccess');
+            $userfields = fields::for_identity($this->get_context())->with_userpic()->excluding('lastaccess');
             ['selects' => $fieldselect, 'joins' => $fieldjoin, 'params' => $fieldjoinparams] =
                     (array)$userfields->get_sql('u', true, '', '', false);
             $params += $fieldjoinparams;
@@ -273,7 +273,7 @@ class course_enrolment_manager {
 
         // Search condition.
         // TODO Does not support custom user profile fields (MDL-70456).
-        $extrafields = user_fields::get_identity_fields($this->get_context(), false);
+        $extrafields = fields::get_identity_fields($this->get_context(), false);
         list($sql, $params) = users_search_sql($this->searchfilter, 'u', true, $extrafields);
 
         // Role condition.
@@ -346,7 +346,7 @@ class course_enrolment_manager {
             list($ctxcondition, $params) = $DB->get_in_or_equal($this->context->get_parent_context_ids(true), SQL_PARAMS_NAMED, 'ctx');
             $params['courseid'] = $this->course->id;
             $params['cid'] = $this->course->id;
-            $userfields = user_fields::for_identity($this->get_context())->with_userpic();
+            $userfields = fields::for_identity($this->get_context())->with_userpic();
             ['selects' => $fieldselect, 'joins' => $fieldjoin, 'params' => $fieldjoinparams] =
                     (array)$userfields->get_sql('u', true);
             $params += $fieldjoinparams;
@@ -387,14 +387,14 @@ class course_enrolment_manager {
 
         // Get custom user field SQL used for querying all the fields we need (identity, name, and
         // user picture).
-        $userfields = user_fields::for_identity($this->context)->with_name()->with_userpic()
+        $userfields = fields::for_identity($this->context)->with_name()->with_userpic()
                 ->excluding('username', 'lastaccess', 'maildisplay');
         ['selects' => $fieldselects, 'joins' => $fieldjoins, 'params' => $params, 'mappings' => $mappings] =
                 (array)$userfields->get_sql('u', true, '', '', false);
 
         // Searchable fields are only the identity and name ones (not userpic).
         $searchable = array_fill_keys($userfields->get_required_fields(
-                [user_fields::PURPOSE_IDENTITY, user_fields::PURPOSE_NAME]), true);
+                [fields::PURPOSE_IDENTITY, fields::PURPOSE_NAME]), true);
 
         // Add some additional sensible conditions
         $tests = array("u.id <> :guestid", 'u.deleted = 0', 'u.confirmed = 1');
@@ -1068,7 +1068,7 @@ class course_enrolment_manager {
         $context    = $this->get_context();
         $now = time();
         // TODO Does not support custom user profile fields (MDL-70456).
-        $extrafields = user_fields::get_identity_fields($context, false);
+        $extrafields = fields::get_identity_fields($context, false);
 
         $users = array();
         foreach ($userroles as $userrole) {
@@ -1147,7 +1147,7 @@ class course_enrolment_manager {
 
         $url = new moodle_url($pageurl, $this->get_url_params());
         // TODO Does not support custom user profile fields (MDL-70456).
-        $extrafields = user_fields::get_identity_fields($context, false);
+        $extrafields = fields::get_identity_fields($context, false);
 
         $enabledplugins = $this->get_enrolment_plugins(true);
 
@@ -1226,7 +1226,7 @@ class course_enrolment_manager {
      * Please note that this function does not check capability for moodle/coures:viewhiddenuserfields
      *
      * @param object $user The user record
-     * @param array $extrafields The list of fields as returned from get_extra_user_fields used to determine which
+     * @param array $extrafields The list of fields as returned from \core_user\fields::get_identity_fields used to determine which
      * additional fields may be displayed
      * @param int $now The time used for lastaccess calculation
      * @return array The fields to be displayed including userid, courseid, picture, firstname, lastcourseaccess, lastaccess and any
@@ -1324,7 +1324,7 @@ class course_enrolment_manager {
             list($instancesql, $instanceparams) = $DB->get_in_or_equal(array_keys($instances), SQL_PARAMS_NAMED, 'instanceid0000');
         }
 
-        $userfieldsapi = \core\user_fields::for_userpic();
+        $userfieldsapi = \core_user\fields::for_userpic();
         $userfields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
         list($idsql, $idparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED, 'userid0000');
 
index 959cf72..c6f9a60 100644 (file)
@@ -93,7 +93,7 @@ class enrol_manual_enrol_users_form extends moodleform {
             'courseid' => $course->id,
             'enrolid' => $instance->id,
             'perpage' => $CFG->maxusersperpage,
-            'userfields' => implode(',', \core\user_fields::get_identity_fields($context, true))
+            'userfields' => implode(',', \core_user\fields::get_identity_fields($context, true))
         );
         $mform->addElement('autocomplete', 'userlist', get_string('selectusers', 'enrol_manual'), array(), $options);
 
index 82846e0..826ebdc 100644 (file)
@@ -55,9 +55,9 @@ $userdetails = array (
     'lastname' => get_string('lastname'),
 );
 // TODO Does not support custom user profile fields (MDL-70456).
-$extrafields = \core\user_fields::get_identity_fields($context, false);
+$extrafields = \core_user\fields::get_identity_fields($context, false);
 foreach ($extrafields as $field) {
-    $userdetails[$field] = \core\user_fields::get_display_name($field);
+    $userdetails[$field] = \core_user\fields::get_display_name($field);
 }
 
 $fields = array(
index 9131f40..1252b06 100644 (file)
@@ -1021,7 +1021,7 @@ class enrol_self_plugin extends enrol_plugin {
                 // We only use the first user.
                 $i = 0;
                 do {
-                    $userfieldsapi = \core\user_fields::for_name();
+                    $userfieldsapi = \core_user\fields::for_name();
                     $allnames = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
                     $rusers = get_role_users($croles[$i], $context, true, 'u.id,  u.confirmed, u.username, '. $allnames . ',
                     u.email, r.sortorder, ra.id', 'r.sortorder, ra.id ASC, ' . $sort, null, '', '', '', '', $sortparams);
index e360616..98f73c8 100644 (file)
@@ -82,7 +82,7 @@ class enrol_self_enrol_form extends moodleform {
             $mform->addElement('password', 'enrolpassword', get_string('password', 'enrol_self'),
                     array('id' => 'enrolpassword_'.$instance->id));
             $context = context_course::instance($this->instance->courseid);
-            $userfieldsapi = \core\user_fields::for_userpic();
+            $userfieldsapi = \core_user\fields::for_userpic();
             $ufields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
             $keyholders = get_users_by_capability($context, 'enrol/self:holdkey', $ufields);
             $keyholdercount = 0;
index 0e8d9d2..e73361e 100644 (file)
@@ -36,7 +36,7 @@ use core_files\converter;
  * @copyright  2017 Andrew nicols <andrew@nicols.co.uk>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class core_files_converter_testcase extends advanced_testcase {
+class converter_test extends advanced_testcase {
 
     /**
      * Get a testable mock of the abstract files_converter class.
@@ -247,7 +247,7 @@ class core_files_converter_testcase extends advanced_testcase {
      * Test the get_document_converter_classes function when the returned classes do not meet requirements.
      */
     public function test_get_document_converter_classes_plugin_class_requirements_not_met() {
-        $plugin = $this->getMockBuilder(\core_file_converter_requirements_not_met_test::class)
+        $plugin = $this->getMockBuilder(\core_file_converter_requirements_not_met::class)
             ->onlyMethods([])
             ->getMock();
 
@@ -266,7 +266,7 @@ class core_files_converter_testcase extends advanced_testcase {
      * Test the get_document_converter_classes function when the returned classes do not meet requirements.
      */
     public function test_get_document_converter_classes_plugin_class_met_not_supported() {
-        $plugin = $this->getMockBuilder(\core_file_converter_type_not_supported_test::class)
+        $plugin = $this->getMockBuilder(\core_file_converter_type_not_supported::class)
             ->onlyMethods([])
             ->getMock();
 
@@ -285,7 +285,7 @@ class core_files_converter_testcase extends advanced_testcase {
      * Test the get_document_converter_classes function when the returned classes do not meet requirements.
      */
     public function test_get_document_converter_classes_plugin_class_met_and_supported() {
-        $plugin = $this->getMockBuilder(\core_file_converter_type_supported_test::class)
+        $plugin = $this->getMockBuilder(\core_file_converter_type_supported::class)
             ->onlyMethods([])
             ->getMock();
         $classname = get_class($plugin);
@@ -741,7 +741,7 @@ class core_files_converter_testcase extends advanced_testcase {
     }
 }
 
-class core_file_converter_requirements_test_base implements \core_files\converter_interface {
+class core_file_converter_requirements_base implements \core_files\converter_interface {
 
     /**
      * Whether the plugin is configured and requirements are met.
@@ -799,7 +799,7 @@ class core_file_converter_requirements_test_base implements \core_files\converte
  * @copyright  2017 Andrew nicols <andrew@nicols.co.uk>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class core_file_converter_requirements_not_met_test extends core_file_converter_requirements_test_base {
+class core_file_converter_requirements_not_met extends core_file_converter_requirements_base {
 }
 
 /**
@@ -809,7 +809,7 @@ class core_file_converter_requirements_not_met_test extends core_file_converter_
  * @copyright  2017 Andrew nicols <andrew@nicols.co.uk>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class core_file_converter_type_not_supported_test extends core_file_converter_requirements_test_base {
+class core_file_converter_type_not_supported extends core_file_converter_requirements_base {
 
     /**
      * Whether the plugin is configured and requirements are met.
@@ -828,7 +828,7 @@ class core_file_converter_type_not_supported_test extends core_file_converter_re
  * @copyright  2017 Andrew nicols <andrew@nicols.co.uk>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class core_file_converter_type_supported_test extends core_file_converter_requirements_test_base {
+class core_file_converter_type_supported extends core_file_converter_requirements_base {
 
     /**
      * Whether the plugin is configured and requirements are met.
@@ -858,7 +858,7 @@ class core_file_converter_type_supported_test extends core_file_converter_requir
  * @copyright  2017 Andrew nicols <andrew@nicols.co.uk>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class core_file_converter_type_successful extends core_file_converter_requirements_test_base {
+class core_file_converter_type_successful extends core_file_converter_requirements_base {
 
     /**
      * Convert a document to a new format and return a conversion object relating to the conversion in progress.
@@ -891,7 +891,7 @@ class core_file_converter_type_successful extends core_file_converter_requiremen
  * @copyright  2017 Andrew nicols <andrew@nicols.co.uk>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class core_file_converter_type_failed extends core_file_converter_requirements_test_base {
+class core_file_converter_type_failed extends core_file_converter_requirements_base {
 
     /**
      * Whether the plugin is configured and requirements are met.
index a7e7ee8..de6bf8d 100644 (file)
@@ -91,7 +91,7 @@ switch ($action) {
                 }
 
                 if ($errorstr) {
-                    $userfieldsapi = \core\user_fields::for_name();
+                    $userfieldsapi = \core_user\fields::for_name();
                     $user = $DB->get_record('user', array('id' => $userid), 'id' . $userfieldsapi->get_sql()->selects);
                     $gradestr = new stdClass();
                     $gradestr->username = fullname($user);
index 38a6659..c411a84 100644 (file)
@@ -293,7 +293,7 @@ class grade_report_grader extends grade_report {
                         }
 
                         if ($errorstr) {
-                            $userfieldsapi = \core\user_fields::for_name();
+                            $userfieldsapi = \core_user\fields::for_name();
                             $userfields = 'id, ' . $userfieldsapi->get_sql('', false, '', '', false)->selects;
                             $user = $DB->get_record('user', array('id' => $userid), $userfields);
                             $gradestr = new stdClass();
@@ -439,7 +439,7 @@ class grade_report_grader extends grade_report {
 
         // Fields we need from the user table.
         // TODO Does not support custom user profile fields (MDL-70456).
-        $userfieldsapi = \core\user_fields::for_identity($this->context, false)->with_userpic();
+        $userfieldsapi = \core_user\fields::for_identity($this->context, false)->with_userpic();
         $userfields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
 
         // We want to query both the current context and parent contexts.
@@ -661,7 +661,7 @@ class grade_report_grader extends grade_report {
         $strgrade     = $this->get_lang_string('grade');
 
         // TODO Does not support custom user profile fields (MDL-70456).
-        $extrafields = \core\user_fields::get_identity_fields($this->context, false);
+        $extrafields = \core_user\fields::get_identity_fields($this->context, false);
 
         $arrows = $this->get_sort_arrows($extrafields);
 
@@ -1946,7 +1946,7 @@ class grade_report_grader extends grade_report {
         }
 
         $arrows['studentname'] = '';
-        $requirednames = order_in_string(\core\user_fields::get_name_fields(), $nameformat);
+        $requirednames = order_in_string(\core_user\fields::get_name_fields(), $nameformat);
         if (!empty($requirednames)) {
             foreach ($requirednames as $name) {
                 $arrows['studentname'] .= html_writer::link(
@@ -1963,7 +1963,7 @@ class grade_report_grader extends grade_report {
 
         foreach ($extrafields as $field) {
             $fieldlink = html_writer::link(new moodle_url($this->baseurl,
-                    array('sortitemid' => $field)), \core\user_fields::get_display_name($field));
+                    array('sortitemid' => $field)), \core_user\fields::get_display_name($field));
             $arrows[$field] = $fieldlink;
 
             if ($field == $this->sortitemid) {
index 050e6c4..1ff2d1e 100644 (file)
@@ -132,7 +132,7 @@ class helper {
 
         // Fields we need from the user table.
         // TODO Does not support custom user profile fields (MDL-70456).
-        $extrafields = \core\user_fields::get_identity_fields($context, false);
+        $extrafields = \core_user\fields::get_identity_fields($context, false);
         $params = array();
         if (!empty($search)) {
             list($filtersql, $params) = users_search_sql($search, 'u', true, $extrafields);
@@ -141,7 +141,7 @@ class helper {
             $filtersql = '';
         }
 
-        $userfieldsapi = \core\user_fields::for_userpic()->including(...(array_merge($extrafields, ['username'])));
+        $userfieldsapi = \core_user\fields::for_userpic()->including(...(array_merge($extrafields, ['username'])));
         $ufields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
         if ($count) {
             $select = "SELECT COUNT(DISTINCT u.id) ";
@@ -203,7 +203,7 @@ class helper {
             $groupwheresql = " AND gm.groupid $insql ";
         }
 
-        $userfieldsapi = \core\user_fields::for_name();
+        $userfieldsapi = \core_user\fields::for_name();
         $ufields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
         $sql = "SELECT u.id, $ufields
                   FROM {user} u
index 5cd19fb..663e294 100644 (file)
@@ -141,7 +141,7 @@ class tablelog extends \table_sql implements \renderable {
      */
     protected function define_table_columns() {
         // TODO Does not support custom user profile fields (MDL-70456).
-        $extrafields = \core\user_fields::get_identity_fields($this->context, false);
+        $extrafields = \core_user\fields::get_identity_fields($this->context, false);
 
         // Define headers and columns.
         $cols = array(
@@ -396,11 +396,11 @@ class tablelog extends \table_sql implements \renderable {
 
         // Add extra user fields that we need for the graded user.
         // TODO Does not support custom user profile fields (MDL-70456).
-        $extrafields = \core\user_fields::get_identity_fields($this->context, false);
+        $extrafields = \core_user\fields::get_identity_fields($this->context, false);
         foreach ($extrafields as $field) {
             $fields .= 'u.' . $field . ', ';
         }
-        $userfieldsapi = \core\user_fields::for_name();
+        $userfieldsapi = \core_user\fields::for_name();
         $gradeduserfields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
         $fields .= $gradeduserfields . ', ';
         $groupby = $fields;
index af43ee0..038cf02 100644 (file)
@@ -52,7 +52,7 @@ $outcome->response = array('users' => array());
 $outcome->response['totalusers'] = \gradereport_history\helper::get_users_count($context, $search);;
 
 // TODO Does not support custom user profile fields (MDL-70456).
-$extrafields = \core\user_fields::get_identity_fields($context, false);
+$extrafields = \core_user\fields::get_identity_fields($context, false);
 $useroptions = array('link' => false, 'visibletoscreenreaders' => false);
 
 // Format the user record.
index d8928e5..c94216b 100644 (file)
@@ -32,7 +32,7 @@ require_once($CFG->dirroot.'/grade/report/lib.php');
 /**
  * A test class used to test grade_report, the abstract grade report parent class
  */
-class grade_report_test extends grade_report {
+class grade_report_mock extends grade_report {
     public function __construct($courseid, $gpr, $context, $user) {
         parent::__construct($courseid, $gpr, $context);
         $this->user = $user;
@@ -61,7 +61,7 @@ class grade_report_test extends grade_report {
 /**
  * Tests grade_report, the parent class for all grade reports.
  */
-class core_grade_reportlib_testcase extends advanced_testcase {
+class reportlib_test extends advanced_testcase {
 
     /**
      * Tests grade_report::blank_hidden_total_and_adjust_bounds()
@@ -117,7 +117,7 @@ class core_grade_reportlib_testcase extends advanced_testcase {
         set_coursemodule_visible($datacm->id, 0);
 
         $gpr = new grade_plugin_return(array('type' => 'report', 'courseid' => $course->id));
-        $report = new grade_report_test($course->id, $gpr, $coursecontext, $student);
+        $report = new grade_report_mock($course->id, $gpr, $coursecontext, $student);
 
         // Should return the supplied student total grade regardless of hiding.
         $report->showtotalsifcontainhidden = array($course->id => GRADE_REPORT_SHOW_REAL_TOTAL_IF_CONTAINS_HIDDEN);
@@ -193,7 +193,7 @@ class core_grade_reportlib_testcase extends advanced_testcase {
         set_coursemodule_visible($forumcm->id, 0);
 
         $gpr = new grade_plugin_return(array('type' => 'report', 'courseid' => $course->id));
-        $report = new grade_report_test($course->id, $gpr, $coursecontext, $student);
+        $report = new grade_report_mock($course->id, $gpr, $coursecontext, $student);
 
         // Should return the supplied student total grade regardless of hiding.
         $report->showtotalsifcontainhidden = array($course->id => GRADE_REPORT_SHOW_REAL_TOTAL_IF_CONTAINS_HIDDEN);
index 87df71e..111eb44 100644 (file)
@@ -100,7 +100,7 @@ if ($editform->is_cancelled()) {
     $onlyactive = !empty($data->includeonlyactiveenrol) || !has_capability('moodle/course:viewsuspendedusers', $context);
 
     // TODO Does not support custom user profile fields (MDL-70456).
-    $extrafields = \core\user_fields::get_identity_fields($context, false);
+    $extrafields = \core_user\fields::get_identity_fields($context, false);
     $users = groups_get_potential_members($data->courseid, $data->roleid, $source, $orderby, !empty($data->notingroup),
         $onlyactive, $extrafields);
     $usercnt = count($users);
index b61e87d..ae8ac8e 100644 (file)
@@ -82,9 +82,9 @@ switch ($action) {
         $roles = array();
 
         // TODO Does not support custom user profile fields (MDL-70456).
-        $userfieldsapi = \core\user_fields::for_identity($context, false)->with_userpic();
+        $userfieldsapi = \core_user\fields::for_identity($context, false)->with_userpic();
         $userfields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
-        $extrafields = $userfieldsapi->get_required_fields([\core\user_fields::PURPOSE_IDENTITY]);
+        $extrafields = $userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]);
         if ($groupmemberroles = groups_get_members_by_role($groupids[0], $courseid,
                 'u.id, ' . $userfields)) {
 
@@ -206,9 +206,9 @@ if ($groups) {
 $members = array();
 if ($singlegroup) {
     // TODO Does not support custom user profile fields (MDL-70456).
-    $userfieldsapi = \core\user_fields::for_identity($context, false)->with_userpic();
+    $userfieldsapi = \core_user\fields::for_identity($context, false)->with_userpic();
     $userfields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
-    $extrafields = $userfieldsapi->get_required_fields([\core\user_fields::PURPOSE_IDENTITY]);
+    $extrafields = $userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]);
     if ($groupmemberroles = groups_get_members_by_role(reset($groupids), $courseid,
             'u.id, ' . $userfields)) {
 
index ebeab85..c755873 100644 (file)
@@ -848,7 +848,7 @@ function groups_get_potential_members($courseid, $roleid = null, $source = null,
         }
     }
 
-    $userfieldsapi = \core\user_fields::for_userpic()->including(...$extrafields);
+    $userfieldsapi = \core_user\fields::for_userpic()->including(...$extrafields);
     $allusernamefields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
     $sql = "SELECT DISTINCT u.id, u.username, $allusernamefields, u.idnumber
               FROM {user} u
index ff73970..a6e84c0 100644 (file)
@@ -111,9 +111,9 @@ if ($groupingid) {
 list($sort, $sortparams) = users_order_by_sql('u');
 
 // TODO Does not support custom user profile fields (MDL-70456).
-$userfieldsapi = \core\user_fields::for_identity($context, false)->with_userpic();
+$userfieldsapi = \core_user\fields::for_identity($context, false)->with_userpic();
 $userfields = $userfieldsapi->get_sql('u', false, '', '', false)->selects;
-$extrafields = $userfieldsapi->get_required_fields([\core\user_fields::PURPOSE_IDENTITY]);
+$extrafields = $userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]);
 $allnames = 'u.id, ' . $userfields;
 
 $sql = "SELECT g.id AS groupid, gg.groupingid, u.id AS userid, $allnames, u.idnumber, u.username
index bc7aa3f..bbd4840 100644 (file)
 defined('MOODLE_INTERNAL') || die();
 
 $string['clianswerno'] = 'n';
-$string['cliansweryes'] = 'w';
-$string['cliyesnoprompt'] = 'Tape w (pou wi) oswa n (pou non)';
+$string['cliansweryes'] = 'y';
+$string['cliincorrectvalueerror'] = 'Erè, valè kòrèk "{$ a-> valè}" pou "{$ a-> opsyon}"';
+$string['cliincorrectvalueretry'] = 'Valè ki pa kòrèk, tanpri eseye ankò';
+$string['clitypevalue'] = 'valè kalite';
+$string['clitypevaluedefault'] = 'tape valè, peze Antre pou itilize valè default ({$ a})';
+$string['cliunknowoption'] = 'Opsyon ki pa rekonèt:
+  {$ a}
+Tanpri itilize opsyon --help.';
+$string['cliyesnoprompt'] = 'tape y (vle di wi) oswa n (vle di non)';
+$string['environmentrequireinstall'] = 'dwe enstale ak pèmèt';
+$string['environmentrequireversion'] = 'vèsyon {$ a-> bezwen} obligatwa epi w ap kouri {$ a-> current}';
+$string['upgradekeyset'] = 'Mizajou kle (kite vid pou pa mete li)';
index 9d06728..d01b3fa 100644 (file)
@@ -29,6 +29,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$string['parentlanguage'] = 'he';
+$string['parentlanguage'] = '';
 $string['thisdirection'] = 'rtl';
 $string['thislanguage'] = 'עברית';