Merge branch 'MDL-50265-master' of git://github.com/danpoltawski/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 6 Jun 2017 17:27:59 +0000 (19:27 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 6 Jun 2017 17:27:59 +0000 (19:27 +0200)
112 files changed:
.eslintrc
Gruntfile.js
admin/templates/setting_filetypes.mustache [new file with mode: 0644]
admin/tool/lp/amd/build/competencies.min.js
admin/tool/lp/amd/build/competencyactions.min.js
admin/tool/lp/amd/build/competencypicker.min.js
admin/tool/lp/amd/build/competencypicker_user_plans.min.js
admin/tool/lp/amd/build/competencyruleconfig.min.js
admin/tool/lp/amd/build/form-cohort-selector.min.js
admin/tool/lp/amd/build/form-user-selector.min.js
admin/tool/lp/amd/build/frameworks_datasource.min.js
admin/tool/lp/amd/build/planactions.min.js
admin/tool/lp/amd/build/user_competency_plan_popup.min.js
admin/tool/lp/amd/build/user_competency_workflow.min.js
admin/tool/lp/amd/build/user_evidence_actions.min.js
admin/tool/lp/amd/src/actionselector.js
admin/tool/lp/amd/src/competencies.js
admin/tool/lp/amd/src/competency_rule_points.js
admin/tool/lp/amd/src/competencyactions.js
admin/tool/lp/amd/src/competencypicker.js
admin/tool/lp/amd/src/competencypicker_user_plans.js
admin/tool/lp/amd/src/competencyruleconfig.js
admin/tool/lp/amd/src/evidence_delete.js
admin/tool/lp/amd/src/form-cohort-selector.js
admin/tool/lp/amd/src/form-user-selector.js
admin/tool/lp/amd/src/frameworks_datasource.js
admin/tool/lp/amd/src/parentcompetency_form.js
admin/tool/lp/amd/src/planactions.js
admin/tool/lp/amd/src/user_competency_plan_popup.js
admin/tool/lp/amd/src/user_competency_workflow.js
admin/tool/lp/amd/src/user_evidence_actions.js
admin/tool/templatelibrary/amd/build/display.min.js
admin/tool/templatelibrary/amd/src/display.js
admin/tool/usertours/amd/src/usertours.js
auth/oauth2/classes/auth.php
blocks/myoverview/amd/build/event_list.min.js
blocks/myoverview/amd/src/event_list.js
cache/classes/loaders.php
cache/tests/cache_test.php
competency/classes/api.php
course/amd/build/actions.min.js
course/amd/src/actions.js
grade/report/grader/index.php
lang/en/cache.php
lang/en/form.php
lang/en/moodle.php
lib/accesslib.php
lib/adminlib.php
lib/amd/build/fragment.min.js
lib/amd/build/templates.min.js
lib/amd/build/user_date.min.js
lib/amd/src/fragment.js
lib/amd/src/templates.js
lib/amd/src/user_date.js
lib/classes/output/external.php
lib/cronlib.php
lib/db/caches.php
lib/db/services.php
lib/deprecatedlib.php
lib/form/amd/build/filetypes.min.js [new file with mode: 0644]
lib/form/amd/src/filetypes.js [new file with mode: 0644]
lib/form/classes/external.php [new file with mode: 0644]
lib/form/classes/filetypes_util.php [new file with mode: 0644]
lib/form/filetypes.php [new file with mode: 0644]
lib/form/templates/filetypes-browser.mustache [new file with mode: 0644]
lib/form/templates/filetypes-descriptions.mustache [new file with mode: 0644]
lib/form/templates/filetypes-trigger.mustache [new file with mode: 0644]
lib/form/tests/external_test.php [new file with mode: 0644]
lib/form/tests/filetypes_util_test.php [new file with mode: 0644]
lib/formslib.php
lib/outputrenderers.php
lib/php-css-parser/Parser.php
lib/php-css-parser/moodle_readme.txt
lib/templates/loginform.mustache [moved from lib/templates/login.mustache with 99% similarity]
lib/tests/accesslib_test.php
lib/tests/output_external_test.php [new file with mode: 0644]
lib/upgrade.txt
lib/upgradelib.php
message/amd/build/message_area_messages.min.js
message/amd/build/message_area_search.min.js
message/amd/src/message_area_messages.js
message/amd/src/message_area_search.js
message/output/popup/amd/build/message_popover_controller.min.js
message/output/popup/amd/build/notification_area_control_area.min.js
message/output/popup/amd/build/notification_popover_controller.min.js
message/output/popup/amd/src/message_popover_controller.js
message/output/popup/amd/src/notification_area_control_area.js
message/output/popup/amd/src/notification_popover_controller.js
mod/assign/amd/src/participant_selector.js
mod/assign/overrideedit.php
mod/data/tests/behat/completion_condition_entries.feature
mod/lti/amd/build/contentitem.min.js
mod/lti/amd/build/tool_card_controller.min.js
mod/lti/amd/src/contentitem.js
mod/lti/amd/src/tool_card_controller.js
mod/quiz/tests/behat/editing_remove_multiple_questions.feature
mod/survey/amd/build/validation.min.js
mod/survey/amd/src/validation.js
npm-shrinkwrap.json
package.json
report/competency/amd/build/grading_popup.min.js
report/competency/amd/src/grading_popup.js
theme/boost/classes/output/core_renderer.php
theme/boost/scss/moodle/drawer.scss
theme/boost/scss/moodle/forms.scss
theme/boost/templates/core/loginform.mustache [moved from theme/boost/templates/core/login.mustache with 99% similarity]
theme/boost/templates/core_form/element-filetypes.mustache [new file with mode: 0644]
theme/boost/tests/behat/behat_theme_boost_behat_course.php
theme/bootstrapbase/less/moodle/forms.less
theme/bootstrapbase/style/moodle.css
theme/upgrade.txt
version.php

index 3514759..c50d2bb 100644 (file)
--- a/.eslintrc
+++ b/.eslintrc
@@ -1,4 +1,7 @@
 {
+  'plugins': [
+    'promise',
+  ],
   'env': {
     'browser': true,
     'amd': true
     'unicode-bom': 'error',
     'wrap-regex': 'off',
 
+    // === Promises ===
+    'promise/always-return': 'warn',
+    'promise/no-return-wrap': 'warn',
+    'promise/param-names': 'warn',
+    'promise/catch-or-return': ['warn', {terminationMethod: ['catch', 'fail']}],
+    'promise/no-native': 'warn',
+    'promise/no-promise-in-callback': 'warn',
+    'promise/no-callback-in-promise': 'warn',
+    'promise/avoid-new': 'warn',
+
     // === Deprecations ===
     "no-restricted-properties": ['warn', {
         'object': 'M',
         'property': 'str',
         'message': 'Use AMD module "core/str" or M.util.get_string()'
     }],
+
   }
 }
index 4cb9e9e..5f2302e 100644 (file)
@@ -338,7 +338,7 @@ module.exports = function(grunt) {
             opts: {stdio: 'inherit', env: process.env}
         }, function(error, result, code) {
             // Propagate the exit code.
-            done(code);
+            done(code === 0);
         });
     };
 
diff --git a/admin/templates/setting_filetypes.mustache b/admin/templates/setting_filetypes.mustache
new file mode 100644 (file)
index 0000000..7075ea2
--- /dev/null
@@ -0,0 +1,52 @@
+{{!
+    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_admin/setting_filetypes
+
+    Renders the admin_setting_filetypes setting element.
+
+    Context variables required for this template:
+    * id - element id
+    * name - form element name
+    * value - element value
+    * descriptions - data for the core_form/filetypes-descriptions template
+
+    Example context (json):
+    {
+        "id": "test0",
+        "name": "test",
+        "value": ".jpg,.gif",
+        "descriptions": {
+            "hasdescriptions": true,
+            "descriptions": [
+                {
+                    "description": "Image (JPEG)",
+                    "extensions": ".jpeg .jpe .jpg"
+                },
+                {
+                    "description": "Image (GIF)",
+                    "extensions": ".gif"
+                }
+            ]
+        }
+    }
+}}
+<div class="form-text defaultsnext">
+    <input type="text" name="{{name}}" value="{{value}}" size="30" id="{{id}}" class="text-ltr">
+    <span data-filetypesbrowser="{{id}}"></span>
+    <div data-filetypesdescriptions="{{id}}">{{#descriptions}}{{>core_form/filetypes-descriptions}}{{/descriptions}}</div>
+</div>
index 6177d34..1f5ddbe 100644 (file)
Binary files a/admin/tool/lp/amd/build/competencies.min.js and b/admin/tool/lp/amd/build/competencies.min.js differ
index 594bc75..143c935 100644 (file)
Binary files a/admin/tool/lp/amd/build/competencyactions.min.js and b/admin/tool/lp/amd/build/competencyactions.min.js differ
index e4312d2..e32b080 100644 (file)
Binary files a/admin/tool/lp/amd/build/competencypicker.min.js and b/admin/tool/lp/amd/build/competencypicker.min.js differ
index bf54916..fd3bf2d 100644 (file)
Binary files a/admin/tool/lp/amd/build/competencypicker_user_plans.min.js and b/admin/tool/lp/amd/build/competencypicker_user_plans.min.js differ
index f5f7f43..db877a5 100644 (file)
Binary files a/admin/tool/lp/amd/build/competencyruleconfig.min.js and b/admin/tool/lp/amd/build/competencyruleconfig.min.js differ
index b582a15..fd26ffb 100644 (file)
Binary files a/admin/tool/lp/amd/build/form-cohort-selector.min.js and b/admin/tool/lp/amd/build/form-cohort-selector.min.js differ
index d228806..febb8d4 100644 (file)
Binary files a/admin/tool/lp/amd/build/form-user-selector.min.js and b/admin/tool/lp/amd/build/form-user-selector.min.js differ
index cfc5c70..9f2ec48 100644 (file)
Binary files a/admin/tool/lp/amd/build/frameworks_datasource.min.js and b/admin/tool/lp/amd/build/frameworks_datasource.min.js differ
index aeb8b9e..f63fbbc 100644 (file)
Binary files a/admin/tool/lp/amd/build/planactions.min.js and b/admin/tool/lp/amd/build/planactions.min.js differ
index 610c935..755a664 100644 (file)
Binary files a/admin/tool/lp/amd/build/user_competency_plan_popup.min.js and b/admin/tool/lp/amd/build/user_competency_plan_popup.min.js differ
index 821222f..deab0e1 100644 (file)
Binary files a/admin/tool/lp/amd/build/user_competency_workflow.min.js and b/admin/tool/lp/amd/build/user_competency_workflow.min.js differ
index a69a9d8..f8b28d7 100644 (file)
Binary files a/admin/tool/lp/amd/build/user_evidence_actions.min.js and b/admin/tool/lp/amd/build/user_evidence_actions.min.js differ
index 582478b..380e05a 100644 (file)
@@ -131,6 +131,7 @@ define(['jquery',
                 html,
                 self._afterRender.bind(self)
             );
+            return;
         }).fail(Notification.exception);
     };
 
@@ -156,6 +157,7 @@ define(['jquery',
         return self._render().then(function(html) {
             self._find('[data-region="action-selector"]').replaceWith(html);
             self._afterRender();
+            return;
         });
     };
 
index 79e1aa9..1b82116 100644 (file)
@@ -181,13 +181,13 @@ define(['jquery',
                     pagerender = 'tool_lp/plan_page';
                     pageregion = 'plan-page';
                 }
-
                 ajax.call(requests)[requests.length - 1].then(function(context) {
-                    return templates.render(pagerender, context).done(function(html, js) {
-                        $('[data-region="' + pageregion + '"]').replaceWith(html);
-                        templates.runTemplateJS(js);
-                    });
-                }, notification.exception);
+                    return templates.render(pagerender, context);
+                }).then(function(html, js) {
+                    $('[data-region="' + pageregion + '"]').replaceWith(html);
+                    templates.runTemplateJS(js);
+                    return;
+                }).catch(notification.exception);
             });
         }
 
index 5c4ec17..542cf09 100644 (file)
@@ -166,6 +166,7 @@ define(['jquery',
             // We're done, let's trigger a change.
             self._templateLoaded = true;
             self._triggerChange();
+            return;
         });
     };
 
index 522e827..5c433f4 100644 (file)
@@ -430,12 +430,13 @@ define(['jquery',
                 var promises = ajax.call(calls);
 
                 promises[calls.length - 1].then(function(context) {
-                    return templates.render('tool_lp/related_competencies', context).done(function(html, js) {
-                        $('[data-region="relatedcompetencies"]').replaceWith(html);
-                        templates.runTemplateJS(js);
-                        updatedRelatedCompetencies();
-                    });
-                }, notification.exception);
+                    return templates.render('tool_lp/related_competencies', context);
+                }).then(function(html, js) {
+                    $('[data-region="relatedcompetencies"]').replaceWith(html);
+                    templates.runTemplateJS(js);
+                    updatedRelatedCompetencies();
+                    return;
+                }).catch(notification.exception);
             });
         }
 
@@ -472,7 +473,8 @@ define(['jquery',
                 relatedTarget.ruleconfig = config.ruleconfig;
                 renderCompetencySummary(relatedTarget);
             }
-        }, notification.exception);
+            return;
+        }).catch(notification.exception);
     };
 
     /**
@@ -692,28 +694,27 @@ define(['jquery',
                     type: strs[1]
                 };
             }
-        }).then(function() {
-            return templates.render('tool_lp/competency_summary', context).then(function(html) {
-                $('[data-region="competencyinfo"]').html(html);
-                $('[data-action="deleterelation"]').on('click', deleteRelatedHandler);
-            });
-        }).then(function() {
+            return context;
+        }).then(function(context) {
+            return templates.render('tool_lp/competency_summary', context);
+        }).then(function(html) {
+            $('[data-region="competencyinfo"]').html(html);
+            $('[data-action="deleterelation"]').on('click', deleteRelatedHandler);
             return templates.render('tool_lp/loading', {});
         }).then(function(html, js) {
             templates.replaceNodeContents('[data-region="relatedcompetencies"]', html, js);
-        }).done(function() {
-            ajax.call([{
+            return ajax.call([{
                 methodname: 'tool_lp_data_for_related_competencies_section',
-                args: {competencyid: competency.id},
-                done: function(context) {
-                    return templates.render('tool_lp/related_competencies', context).done(function(html, js) {
-                        $('[data-region="relatedcompetencies"]').replaceWith(html);
-                        templates.runTemplateJS(js);
-                        updatedRelatedCompetencies();
-                    });
-                }
-            }]);
-        }).fail(notification.exception);
+                args: {competencyid: competency.id}
+            }])[0];
+        }).then(function(context) {
+            return templates.render('tool_lp/related_competencies', context);
+        }).then(function(html, js) {
+            $('[data-region="relatedcompetencies"]').replaceWith(html);
+            templates.runTemplateJS(js);
+            updatedRelatedCompetencies();
+            return;
+        }).catch(notification.exception);
     };
 
     /**
@@ -776,16 +777,17 @@ define(['jquery',
             // Log Competency viewed event.
             triggerCompetencyViewedEvent(competency);
         }
-
         strSelectedTaxonomy(level).then(function(str) {
             selectedTitle.text(str);
-        });
+            return;
+        }).catch(notification.exception);
 
         strAddTaxonomy(sublevel).then(function(str) {
             btn.show()
                 .find('[data-region="term"]')
                 .text(str);
-        });
+            return;
+        }).catch(notification.exception);
 
         // We handled this event so consume it.
         evt.preventDefault();
index 1aebe05..23dce8a 100644 (file)
@@ -134,7 +134,7 @@ define(['jquery',
         if (!self._singleFramework) {
             self._find('[data-action="chooseframework"]').change(function(e) {
                 self._frameworkId = $(e.target).val();
-                self._loadCompetencies().then(self._refresh.bind(self));
+                self._loadCompetencies().then(self._refresh.bind(self)).catch(Notification.exception);
             });
         }
 
@@ -203,15 +203,15 @@ define(['jquery',
      */
     Picker.prototype.display = function() {
         var self = this;
-        return self._render().then(function(html) {
-            return Str.get_string('competencypicker', 'tool_lp').then(function(title) {
-                self._popup = new Dialogue(
-                    title,
-                    html,
-                    self._afterRender.bind(self)
-                );
-            });
-        }).fail(Notification.exception);
+        return $.when(Str.get_string('competencypicker', 'tool_lp'), self._render())
+        .then(function(title, render) {
+            self._popup = new Dialogue(
+                title,
+                render[0],
+                self._afterRender.bind(self)
+            );
+            return;
+        }).catch(Notification.exception);
     };
 
     /**
@@ -388,6 +388,7 @@ define(['jquery',
         return self._render().then(function(html) {
             self._find('[data-region="competencylinktree"]').replaceWith(html);
             self._afterRender();
+            return;
         });
     };
 
index 8d5b536..e621f32 100644 (file)
@@ -77,7 +77,8 @@ define(['jquery',
         if (!self._singlePlan) {
             self._find('[data-action="chooseplan"]').change(function(e) {
                 self._planId = $(e.target).val();
-                self._loadCompetencies().then(self._refresh.bind(self));
+                self._loadCompetencies().then(self._refresh.bind(self))
+                .catch(Notification.exception);
             });
         }
     };
index 3a956cb..b0e3db6 100644 (file)
@@ -165,14 +165,14 @@ define(['jquery',
         if (!self._competency) {
             return false;
         }
-        return self._render().then(function(html) {
-            return Str.get_string('competencyrule', 'tool_lp').then(function(title) {
-                self._popup = new Dialogue(
-                    title,
-                    html,
-                    self._afterRender.bind(self)
-                );
-            });
+        return $.when(Str.get_string('competencyrule', 'tool_lp'), self._render())
+        .then(function(title, render) {
+            self._popup = new Dialogue(
+                title,
+                render[0],
+                self._afterRender.bind(self)
+            );
+            return;
         }).fail(Notification.exception);
     };
 
@@ -312,9 +312,9 @@ define(['jquery',
      */
     RuleConfig.prototype._initOutcomes = function() {
         var self = this;
-
         return Outcomes.getAll().then(function(outcomes) {
             self._outcomesOption = outcomes;
+            return;
         });
     };
 
@@ -328,11 +328,11 @@ define(['jquery',
     RuleConfig.prototype._initRules = function() {
         var self = this,
             promises = [];
-
         $.each(self._rules, function(index, rule) {
             var promise = rule.init().then(function() {
                 rule.setTargetCompetency(self._competency);
                 rule.on('change', self._afterRuleConfigChange.bind(self));
+                return;
             }, function() {
                 // Upon failure remove the rule, and resolve the promise.
                 self._rules.splice(index, 1);
@@ -518,13 +518,13 @@ define(['jquery',
             self._afterChange();
             return;
         }
-
         rule.injectTemplate(container).then(function() {
             container.show();
-        }, function() {
-            container.empty().hide();
+            return;
         }).always(function() {
             self._afterChange();
+        }).catch(function() {
+            container.empty().hide();
         });
     };
 
index ac59ad9..e695e12 100644 (file)
@@ -76,6 +76,7 @@ define(['jquery',
                         }]);
                         promise[0].then(function() {
                             parent.remove();
+                            return;
                         }).fail(Notification.exception);
                     }
                 );
index 486c25c..281c492 100644 (file)
@@ -51,7 +51,6 @@ define(['jquery', 'core/ajax', 'core/templates'], function($, Ajax, Templates) {
                     includes: includes
                 }
             }]);
-
             promise[0].then(function(results) {
                 var promises = [],
                     i = 0;
@@ -69,9 +68,10 @@ define(['jquery', 'core/ajax', 'core/templates'], function($, Ajax, Templates) {
                         i++;
                     });
                     success(results.cohorts);
+                    return;
                 });
 
-            }failure);
+            }).catch(failure);
         }
 
     };
index 8713d06..3d1e613 100644 (file)
@@ -79,9 +79,10 @@ define(['jquery', 'core/ajax', 'core/templates'], function($, Ajax, Templates) {
                         i++;
                     });
                     success(results.users);
+                    return;
                 });
 
-            }failure);
+            }).catch(failure);
         }
 
     };
index 3638830..c46d005 100644 (file)
@@ -35,20 +35,17 @@ define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notificat
          * @return {Promise}
          */
         list: function(contextId, options) {
-            var promise,
-                args = {
+            var args = {
                     context: {
                         contextid: contextId
                     }
                 };
 
             $.extend(args, typeof options === 'undefined' ? {} : options);
-            promise = Ajax.call([{
+            return Ajax.call([{
                 methodname: 'core_competency_list_competency_frameworks',
                 args: args
             }])[0];
-
-            return promise.fail(Notification.exception);
         },
 
         /**
@@ -76,6 +73,7 @@ define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notificat
          * @param {String} query The query string.
          * @param {Function} callback A callback function receiving an array of results.
          */
+        /* eslint-disable promise/no-callback-in-promise */
         transport: function(selector, query, callback) {
             var el = $(selector),
                 contextId = el.data('contextid'),
@@ -84,11 +82,10 @@ define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notificat
             if (!contextId) {
                 throw new Error('The attribute data-contextid is required on ' + selector);
             }
-
             this.list(contextId, {
                 query: query,
                 onlyvisible: onlyVisible,
-            }).then(callback);
+            }).then(callback).catch(Notification.exception);
         }
     };
 
index 705ab29..c42aece 100644 (file)
@@ -81,6 +81,7 @@ define(['jquery', 'core/ajax', 'core/str', 'tool_lp/competencypicker', 'core/tem
             Str.get_string('competencyframeworkroot', 'tool_lp').then(function(rootframework) {
                 $(self.staticElementSelector).html(rootframework);
                 $(self.inputHiddenSelector).val(data.competencyId);
+                return;
             }).fail(Notification.exception);
         }
     };
index 932318f..be39396 100644 (file)
@@ -110,15 +110,16 @@ define(['jquery',
      * Callback to render the region template.
      *
      * @param {Object} context The context for the template.
+     * @return {Promise}
      */
     PlanActions.prototype._renderView = function(context) {
         var self = this;
-        templates.render(self._template, context)
-            .done(function(newhtml, newjs) {
+        return templates.render(self._template, context)
+            .then(function(newhtml, newjs) {
                 $(self._region).replaceWith(newhtml);
                 templates.runTemplateJS(newjs);
-            })
-            .fail(notification.exception);
+                return;
+            });
     };
 
     /**
@@ -130,16 +131,15 @@ define(['jquery',
      */
     PlanActions.prototype._callAndRefresh = function(calls, planData) {
         var self = this;
-
         calls.push({
             methodname: self._contextMethod,
             args: self._getContextArgs(planData)
         });
 
         // Apply all the promises, and refresh when the last one is resolved.
-        return $.when.apply($.when, ajax.call(calls))
+        return $.when.apply($, ajax.call(calls))
             .then(function() {
-                self._renderView(arguments[arguments.length - 1]);
+                return self._renderView(arguments[arguments.length - 1]);
             })
             .fail(notification.exception);
     };
index 6600b1f..e7aff46 100644 (file)
@@ -58,7 +58,6 @@ define(['jquery', 'core/notification', 'core/str', 'core/ajax', 'core/templates'
             done: this._contextLoaded.bind(this),
             fail: notification.exception
         }]);
-
         // Log the user competency viewed in plan event.
         requests[0].then(function(result) {
             var eventMethodName = 'core_competency_user_competency_viewed_in_plan';
@@ -66,12 +65,11 @@ define(['jquery', 'core/notification', 'core/str', 'core/ajax', 'core/templates'
             if (result.plan.iscompleted) {
                 eventMethodName = 'core_competency_user_competency_plan_viewed';
             }
-            ajax.call([{
+            return ajax.call([{
                 methodname: eventMethodName,
-                args: {competencyid: competencyId, userid: userId, planid: planId},
-                fail: notification.exception
-            }]);
-        });
+                args: {competencyid: competencyId, userid: userId, planid: planId}
+            }])[0];
+        }).catch(notification.exception);
     };
 
     /**
index f61c0f9..d6d6b71 100644 (file)
@@ -61,7 +61,7 @@ define(['jquery',
         Ajax.call([call])[0].then(function() {
             this._trigger('review-request-cancelled', data);
             this._trigger('status-changed', data);
-        }.bind(this)function() {
+        }.bind(this)).catch(function() {
             this._trigger('error-occured', data);
         }.bind(this));
     };
@@ -106,7 +106,7 @@ define(['jquery',
         Ajax.call([call])[0].then(function() {
             this._trigger('review-requested', data);
             this._trigger('status-changed', data);
-        }.bind(this)function() {
+        }.bind(this)).catch(function() {
             this._trigger('error-occured', data);
         }.bind(this));
     };
@@ -147,11 +147,10 @@ define(['jquery',
                 competencyid: data.competencyid
             }
         };
-
         Ajax.call([call])[0].then(function() {
             this._trigger('review-started', data);
             this._trigger('status-changed', data);
-        }.bind(this)function() {
+        }.bind(this)).catch(function() {
             this._trigger('error-occured', data);
         }.bind(this));
     };
@@ -196,7 +195,7 @@ define(['jquery',
         Ajax.call([call])[0].then(function() {
             this._trigger('review-stopped', data);
             this._trigger('status-changed', data);
-        }.bind(this)function() {
+        }.bind(this)).catch(function() {
             this._trigger('error-occured', data);
         }.bind(this));
     };
index b9ef686..1c37728 100644 (file)
@@ -98,14 +98,15 @@ define(['jquery',
      * Callback to render the region template.
      *
      * @param {Object} context The context for the template.
+     * @return {Promise}
      */
     UserEvidenceActions.prototype._renderView = function(context) {
         var self = this;
-        templates.render(self._template, context)
-            .done(function(newhtml, newjs) {
+        return templates.render(self._template, context)
+            .then(function(newhtml, newjs) {
                 templates.replaceNode($(self._region), newhtml, newjs);
-            })
-            .fail(notification.exception);
+                return;
+            });
     };
 
     /**
@@ -117,7 +118,6 @@ define(['jquery',
      */
     UserEvidenceActions.prototype._callAndRefresh = function(calls, evidenceData) {
         var self = this;
-
         calls.push({
             methodname: self._contextMethod,
             args: self._getContextArgs(evidenceData)
@@ -126,7 +126,7 @@ define(['jquery',
         // Apply all the promises, and refresh when the last one is resolved.
         return $.when.apply($.when, ajax.call(calls))
             .then(function() {
-                self._renderView(arguments[arguments.length - 1]);
+                return self._renderView(arguments[arguments.length - 1]);
             })
             .fail(notification.exception);
     };
index 4b5103c..0c75544 100644 (file)
Binary files a/admin/tool/templatelibrary/amd/build/display.min.js and b/admin/tool/templatelibrary/amd/build/display.min.js differ
index 1c2e4de..3979366 100644 (file)
@@ -126,7 +126,8 @@ define(['jquery', 'core/ajax', 'core/log', 'core/notification', 'core/templates'
             args: {
                     component: component,
                     template: name,
-                    themename: config.theme
+                    themename: config.theme,
+                    includecomments: true
             }
         }, {
             methodname: 'tool_templatelibrary_load_canonical_template',
index e0d877d..4541f23 100644 (file)
@@ -68,6 +68,7 @@ function(ajax, BootstrapTour, $, templates, str, log, notification) {
                 templates.render('tool_usertours/tourstep', {})
             ).then(function(response, template) {
                 usertours.startBootstrapTour(tourId, template[0], response.tourconfig);
+                return;
             }).fail(notification.exception);
         },
 
@@ -213,6 +214,7 @@ function(ajax, BootstrapTour, $, templates, str, log, notification) {
                 if (response.startTour) {
                     usertours.fetchTour(response.startTour);
                 }
+                return;
             }).fail(notification.exception);
         }
     };
index fc230aa..812cf98 100644 (file)
@@ -247,6 +247,10 @@ class auth extends \auth_plugin_base {
         if (!empty($user->picture)) {
             return false;
         }
+        if (!empty($CFG->enablegravatar)) {
+            return false;
+        }
+
         $picture = $this->get_static_user_picture();
         if (empty($picture)) {
             return false;
index cdc315e..b1db8fe 100644 (file)
Binary files a/blocks/myoverview/amd/build/event_list.min.js and b/blocks/myoverview/amd/build/event_list.min.js differ
index 17e888f..f1d273e 100644 (file)
@@ -350,34 +350,37 @@ define(['jquery', 'core/notification', 'core/templates',
 
         // Request data from the server.
         return promise.then(function(result) {
-            return result.events;
-        }).then(function(calendarEvents) {
-            if (!calendarEvents.length || (calendarEvents.length < limit)) {
-                // We have no more events so mark the list as done.
+            if (!result.events.length) {
+                // No events, nothing to do.
                 setLoadedAll(root);
+                return 0;
             }
 
-            if (calendarEvents.length) {
-                // Remember the last id we've seen.
-                root.attr('data-last-id', calendarEvents[calendarEvents.length - 1].id);
-
-                // Render the events.
-                return render(root, calendarEvents).then(function(renderCount) {
-                    updateContentVisibility(root, calendarEvents.length);
-
-                    if (renderCount < calendarEvents.length) {
-                        // if the number of events that was rendered is less than
-                        // the number we sent for rendering we can assume that there
-                        // are no groups to add them in. Since the ordering of the
-                        // events is guaranteed it means that any future requests will
-                        // also yield events that can't be rendered, so let's not bother
-                        // sending any more requests.
-                        setLoadedAll(root);
-                    }
-                });
-            } else {
-                updateContentVisibility(root, calendarEvents.length);
+            var calendarEvents = result.events;
+
+            // Remember the last id we've seen.
+            root.attr('data-last-id', calendarEvents[calendarEvents.length - 1].id);
+
+            if (calendarEvents.length <= limit) {
+                // No more events to load, disable loading button.
+                setLoadedAll(root);
             }
+
+            // Render the events.
+            return render(root, calendarEvents).then(function(renderCount) {
+                if (renderCount < calendarEvents.length) {
+                    // if the number of events that was rendered is less than
+                    // the number we sent for rendering we can assume that there
+                    // are no groups to add them in. Since the ordering of the
+                    // events is guaranteed it means that any future requests will
+                    // also yield events that can't be rendered, so let's not bother
+                    // sending any more requests.
+                    setLoadedAll(root);
+                }
+                return calendarEvents.length;
+            });
+        }).then(function(eventCount) {
+            return updateContentVisibility(root, eventCount);
         }).fail(
             Notification.exception
         ).always(function() {
index 9ea6df3..168124d 100644 (file)
@@ -1077,7 +1077,7 @@ class cache implements cache_loader {
                 $result = $data;
             }
         }
-        if ($result) {
+        if ($result !== false) {
             if ($this->perfdebug) {
                 cache_helper::record_cache_hit('** static acceleration **', $this->definition);
             }
@@ -2162,4 +2162,4 @@ class cache_session extends cache {
  */
 class cache_request extends cache {
     // This comment appeases code pre-checker ;) !
-}
\ No newline at end of file
+}
index ef47096..0a47e4f 100644 (file)
@@ -2177,6 +2177,39 @@ class core_cache_testcase extends advanced_testcase {
             $startstats[$requestid]['stores']['cachestore_static']['sets']);
     }
 
+    public function test_static_cache() {
+        global $CFG;
+        $this->resetAfterTest(true);
+        $CFG->perfdebug = 15;
+
+        // Create cache store with static acceleration.
+        $instance = cache_config_testing::instance();
+        $applicationid = 'phpunit/applicationperf';
+        $instance->phpunit_add_definition($applicationid, array(
+            'mode' => cache_store::MODE_APPLICATION,
+            'component' => 'phpunit',
+            'area' => 'applicationperf',
+            'simplekeys' => true,
+            'staticacceleration' => true,
+            'staticaccelerationsize' => 3
+        ));
+
+        $application = cache::make('phpunit', 'applicationperf');
+
+        // Check that stores register sets.
+        $this->assertTrue($application->set('setMe1', 1));
+        $this->assertTrue($application->set('setMe2', 0));
+        $this->assertTrue($application->set('setMe3', array()));
+        $this->assertTrue($application->get('setMe1') !== false);
+        $this->assertTrue($application->get('setMe2') !== false);
+        $this->assertTrue($application->get('setMe3') !== false);
+
+        // Check that the static acceleration worked, even on empty arrays and the number 0.
+        $endstats = cache_helper::get_stats();
+        $this->assertEquals(0, $endstats[$applicationid]['stores']['** static acceleration **']['misses']);
+        $this->assertEquals(3, $endstats[$applicationid]['stores']['** static acceleration **']['hits']);
+    }
+
     public function test_performance_debug_off() {
         global $CFG;
         $this->resetAfterTest(true);
index 60adab4..0604f3e 100644 (file)
@@ -5154,9 +5154,12 @@ class api {
         $syscontext = context_system::instance();
         $hassystem = has_capability($capability, $syscontext, $userid);
 
-        $access = get_user_access_sitewide($userid);
+        $access = get_user_roles_sitewide_accessdata($userid);
         // Build up a list of level 2 contexts (candidates to be user context).
         $filtercontexts = array();
+        // Build list of roles to check overrides.
+        $roles = array();
+
         foreach ($access['ra'] as $path => $role) {
             $parts = explode('/', $path);
             if (count($parts) == 3) {
@@ -5165,24 +5168,23 @@ class api {
                 // We know this is not a user context because there is another path with more than 2 levels.
                 unset($filtercontexts[$parts[2]]);
             }
+            $roles = array_merge($roles, $role);
         }
 
         // Add all contexts in which a role may be overidden.
-        foreach ($access['rdef'] as $pathandroleid => $def) {
-            $matches = array();
-            if (!isset($def[$capability])) {
-                // The capability is not mentioned, we can ignore.
-                continue;
-            }
-
-            list($contextpath, $roleid) = explode(':', $pathandroleid, 2);
-            $parts = explode('/', $contextpath);
-            if (count($parts) != 3) {
-                // Only get potential user contexts, they only ever have 2 slashes /parentId/Id.
-                continue;
+        $rdefs = get_role_definitions($roles);
+        foreach ($rdefs as $roledef) {
+            foreach ($roledef as $path => $caps) {
+                if (!isset($caps[$capability])) {
+                    // The capability is not mentioned, we can ignore.
+                    continue;
+                }
+                $parts = explode('/', $path);
+                if (count($parts) === 3) {
+                    // Only get potential user contexts, they only ever have 2 slashes /parentId/Id.
+                    $filtercontexts[$parts[2]] = $parts[2];
+                }
             }
-
-            $filtercontexts[$parts[2]] = $parts[2];
         }
 
         // No interesting contexts - return all or no results.
index ce8c49c..904a587 100644 (file)
Binary files a/course/amd/build/actions.min.js and b/course/amd/build/actions.min.js differ
index a804b45..d7e7b84 100644 (file)
@@ -354,27 +354,32 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
          * @param {String} titlestr string for "title" attribute (if different from stringname)
          * @param {String} titlecomponent
          * @param {String} newaction new value for data-action attribute of the link
+         * @return {Promise} promise which is resolved when the replacement has completed
          */
         var replaceActionItem = function(actionitem, image, stringname,
                                            stringcomponent, titlestr, titlecomponent, newaction) {
 
-            str.get_string(stringname, stringcomponent).done(function(newstring) {
-                actionitem.find('span.menu-action-text').html(newstring);
-                actionitem.attr('title', newstring);
-            });
+
+            var stringRequests = [{key: stringname, component: stringcomponent}];
             if (titlestr) {
-                str.get_string(titlestr, titlecomponent).then(function(newtitle) {
-                    templates.renderPix(image, 'core', newtitle).then(function(html) {
-                        actionitem.find('.icon').replaceWith(html);
-                    });
-                    actionitem.attr('title', newtitle);
-                });
-            } else {
-                templates.renderPix(image, 'core', '').then(function(html) {
-                    actionitem.find('.icon').replaceWith(html);
-                });
+                stringRequests.push({key: titlestr, component: titlecomponent});
             }
-            actionitem.attr('data-action', newaction);
+
+            return str.get_strings(stringRequests).then(function(strings) {
+                actionitem.find('span.menu-action-text').html(strings[0]);
+                actionitem.attr('title', strings[0]);
+
+                var title = '';
+                if (titlestr) {
+                    title = strings[1];
+                    actionitem.attr('title', title);
+                }
+                return templates.renderPix(image, 'core', title);
+            }).then(function(pixhtml) {
+                actionitem.find('.icon').replaceWith(pixhtml);
+                actionitem.attr('data-action', newaction);
+                return;
+            }).catch(notification.exception);
         };
 
         /**
index 7885fdc..43917fa 100644 (file)
@@ -188,7 +188,8 @@ if ($USER->gradeediting[$course->id] && ($report->get_pref('showquickfeedback')
     echo '<input type="hidden" value="grader" name="report"/>';
     echo '<input type="hidden" value="'.$page.'" name="page"/>';
     echo $reporthtml;
-    echo '<div class="submit"><input type="submit" id="gradersubmit" value="'.s(get_string('savechanges')).'" /></div>';
+    echo '<div class="submit"><input type="submit" id="gradersubmit" class="btn btn-primary"
+        value="'.s(get_string('savechanges')).'" /></div>';
     echo '</div></form>';
 } else {
     echo $reporthtml;
index e54522f..e811f03 100644 (file)
@@ -63,6 +63,7 @@ $string['cachedef_plugin_manager'] = 'Plugin info manager';
 $string['cachedef_tagindexbuilder'] = 'Search results for tagged items';
 $string['cachedef_questiondata'] = 'Question definitions';
 $string['cachedef_repositories'] = 'Repositories instances data';
+$string['cachedef_roledefs'] = 'Role definitions';
 $string['cachedef_grade_categories'] = 'Grade category queries';
 $string['cachedef_string'] = 'Language string cache';
 $string['cachedef_tags'] = 'Tags collections and areas';
index 0285e82..2ed6cbf 100644 (file)
@@ -41,6 +41,9 @@ $string['err_nopunctuation'] = 'You must enter no punctuation characters here.';
 $string['err_numeric'] = 'You must enter a number here.';
 $string['err_rangelength'] = 'You must enter between {$a->format[0]} and {$a->format[1]} characters here.';
 $string['err_required'] = 'You must supply a value here.';
+$string['filetypesany'] = 'All file types';
+$string['filetypesnotwhitelisted'] = 'These file types are not allowed here: {$a}';
+$string['filetypesothers'] = 'Other files';
 $string['general'] = 'General';
 $string['hideadvanced'] = 'Hide advanced';
 $string['hour'] = 'Hour';
index 196ebde..07e154d 100644 (file)
@@ -1843,6 +1843,7 @@ $string['subcategory'] = 'Subcategory';
 $string['subcategoryof'] = 'Subcategory of {$a}';
 $string['submit'] = 'Submit';
 $string['success'] = 'Success';
+$string['successduration'] = 'Success ({$a} seconds)';
 $string['summary'] = 'Summary';
 $string['summary_help'] = 'The idea of a summary is a short text to prepare students for the activities within the topic or week. The text is shown on the course page under the section name.';
 $string['summaryof'] = 'Summary of {$a}';
index 6360b4e..728b13c 100644 (file)
@@ -56,9 +56,7 @@
  * - load_all_capabilities()
  * - reload_all_capabilities()
  * - has_capability_in_accessdata()
- * - get_user_access_sitewide()
- * - load_course_context()
- * - load_role_access_by_context()
+ * - get_user_roles_sitewide_accessdata()
  * - etc.
  *
  * <b>Name conventions</b>
  *                  [$contextpath] = array($roleid=>$roleid)
  * </code>
  *
- * Role definitions are stored like this
- * (no cap merge is done - so it's compact)
- *
- * <code>
- * $accessdata['rdef']["$contextpath:$roleid"]['mod/forum:viewpost'] = 1
- *                                            ['mod/forum:editallpost'] = -1
- *                                            ['mod/forum:startdiscussion'] = -1000
- * </code>
- *
- * See how has_capability_in_accessdata() walks up the tree.
- *
- * First we only load rdef and ra down to the course level, but not below.
- * This keeps accessdata small and compact. Below-the-course ra/rdef
- * are loaded as needed. We keep track of which courses we have loaded ra/rdef in
- * <code>
- * $accessdata['loaded'] = array($courseid1=>1, $courseid2=>1)
- * </code>
- *
  * <b>Stale accessdata</b>
  *
  * For the logged-in user, accessdata is long-lived.
@@ -200,9 +180,9 @@ if (!defined('CONTEXT_CACHE_MAX_SIZE')) {
  */
 global $ACCESSLIB_PRIVATE;
 $ACCESSLIB_PRIVATE = new stdClass();
+$ACCESSLIB_PRIVATE->cacheroledefs    = array(); // Holds site-wide role definitions.
 $ACCESSLIB_PRIVATE->dirtycontexts    = null;    // Dirty contexts cache, loaded from DB once per page
 $ACCESSLIB_PRIVATE->accessdatabyuser = array(); // Holds the cache of $accessdata structure for users (including $USER)
-$ACCESSLIB_PRIVATE->rolepermissions  = array(); // role permissions cache - helps a lot with mem usage
 
 /**
  * Clears accesslib's private caches. ONLY BE USED BY UNIT TESTS
@@ -239,7 +219,10 @@ function accesslib_clear_all_caches($resetcontexts) {
 
     $ACCESSLIB_PRIVATE->dirtycontexts    = null;
     $ACCESSLIB_PRIVATE->accessdatabyuser = array();
-    $ACCESSLIB_PRIVATE->rolepermissions  = array();
+    $ACCESSLIB_PRIVATE->cacheroledefs    = array();
+
+    $cache = cache::make('core', 'roledefs');
+    $cache->purge();
 
     if ($resetcontexts) {
         context_helper::reset_caches();
@@ -247,69 +230,111 @@ function accesslib_clear_all_caches($resetcontexts) {
 }
 
 /**
- * Gets the accessdata for role "sitewide" (system down to course)
+ * Clears accesslib's private cache of a specific role or roles. ONLY BE USED FROM THIS LIBRARY FILE!
+ *
+ * This reset does not touch global $USER.
+ *
+ * @access private
+ * @param int|array $roles
+ * @return void
+ */
+function accesslib_clear_role_cache($roles) {
+    global $ACCESSLIB_PRIVATE;
+
+    if (!is_array($roles)) {
+        $roles = [$roles];
+    }
+
+    foreach ($roles as $role) {
+        if (isset($ACCESSLIB_PRIVATE->cacheroledefs[$role])) {
+            unset($ACCESSLIB_PRIVATE->cacheroledefs[$role]);
+        }
+    }
+
+    $cache = cache::make('core', 'roledefs');
+    $cache->delete_many($roles);
+}
+
+/**
+ * Role is assigned at system context.
  *
  * @access private
  * @param int $roleid
  * @return array
  */
 function get_role_access($roleid) {
-    global $DB, $ACCESSLIB_PRIVATE;
+    $accessdata = get_empty_accessdata();
+    $accessdata['ra']['/'.SYSCONTEXTID] = array((int)$roleid => (int)$roleid);
+    return $accessdata;
+}
 
-    /* Get it in 1 DB query...
-     * - relevant role caps at the root and down
-     *   to the course level - but not below
-     */
+/**
+ * Fetch raw "site wide" role definitions.
+ * Even MUC static acceleration cache appears a bit slow for this.
+ * Important as can be hit hundreds of times per page.
+ *
+ * @param array $roleids List of role ids to fetch definitions for.
+ * @return array Complete definition for each requested role.
+ */
+function get_role_definitions(array $roleids) {
+    global $ACCESSLIB_PRIVATE;
 
-    //TODO: MUC - this could be cached in shared memory to speed up first page loading, web crawlers, etc.
+    if (empty($roleids)) {
+        return array();
+    }
 
-    $accessdata = get_empty_accessdata();
+    // Grab all keys we have not yet got in our static cache.
+    if ($uncached = array_diff($roleids, array_keys($ACCESSLIB_PRIVATE->cacheroledefs))) {
+        $cache = cache::make('core', 'roledefs');
+        $ACCESSLIB_PRIVATE->cacheroledefs += array_filter($cache->get_many($uncached));
 
-    $accessdata['ra']['/'.SYSCONTEXTID] = array((int)$roleid => (int)$roleid);
+        // Check we have the remaining keys from the MUC.
+        if ($uncached = array_diff($roleids, array_keys($ACCESSLIB_PRIVATE->cacheroledefs))) {
+            $uncached = get_role_definitions_uncached($uncached);
+            $ACCESSLIB_PRIVATE->cacheroledefs += $uncached;
+            $cache->set_many($uncached);
+        }
+    }
 
-    // Overrides for the role IN ANY CONTEXTS down to COURSE - not below -.
+    // Return just the roles we need.
+    return array_intersect_key($ACCESSLIB_PRIVATE->cacheroledefs, array_flip($roleids));
+}
 
-    /*
-    $sql = "SELECT ctx.path,
-                   rc.capability, rc.permission
-              FROM {context} ctx
-              JOIN {role_capabilities} rc ON rc.contextid = ctx.id
-         LEFT JOIN {context} cctx
-                   ON (cctx.contextlevel = ".CONTEXT_COURSE." AND ctx.path LIKE ".$DB->sql_concat('cctx.path',"'/%'").")
-             WHERE rc.roleid = ? AND cctx.id IS NULL";
-    $params = array($roleid);
-    */
+/**
+ * Query raw "site wide" role definitions.
+ *
+ * @param array $roleids List of role ids to fetch definitions for.
+ * @return array Complete definition for each requested role.
+ */
+function get_role_definitions_uncached(array $roleids) {
+    global $DB;
+
+    if (empty($roleids)) {
+        return array();
+    }
 
-    // Note: the commented out query is 100% accurate but slow, so let's cheat instead by hardcoding the blocks mess directly.
+    list($sql, $params) = $DB->get_in_or_equal($roleids);
+    $rdefs = array();
 
-    $sql = "SELECT COALESCE(ctx.path, bctx.path) AS path, rc.capability, rc.permission
+    $sql = "SELECT ctx.path, rc.roleid, rc.capability, rc.permission
               FROM {role_capabilities} rc
-         LEFT JOIN {context} ctx ON (ctx.id = rc.contextid AND ctx.contextlevel <= ".CONTEXT_COURSE.")
-         LEFT JOIN ({context} bctx
-                    JOIN {block_instances} bi ON (bi.id = bctx.instanceid)
-                    JOIN {context} pctx ON (pctx.id = bi.parentcontextid AND pctx.contextlevel < ".CONTEXT_COURSE.")
-                   ) ON (bctx.id = rc.contextid AND bctx.contextlevel = ".CONTEXT_BLOCK.")
-             WHERE rc.roleid = :roleid AND (ctx.id IS NOT NULL OR bctx.id IS NOT NULL)";
-    $params = array('roleid'=>$roleid);
-
-    // we need extra caching in CLI scripts and cron
+              JOIN {context} ctx ON rc.contextid = ctx.id
+             WHERE rc.roleid $sql
+          ORDER BY ctx.path, rc.roleid, rc.capability";
     $rs = $DB->get_recordset_sql($sql, $params);
-    foreach ($rs as $rd) {
-        $k = "{$rd->path}:{$roleid}";
-        $accessdata['rdef'][$k][$rd->capability] = (int)$rd->permission;
-    }
-    $rs->close();
 
-    // share the role definitions
-    foreach ($accessdata['rdef'] as $k=>$unused) {
-        if (!isset($ACCESSLIB_PRIVATE->rolepermissions[$k])) {
-            $ACCESSLIB_PRIVATE->rolepermissions[$k] = $accessdata['rdef'][$k];
+    foreach ($rs as $rd) {
+        if (!isset($rdefs[$rd->roleid][$rd->path])) {
+            if (!isset($rdefs[$rd->roleid])) {
+                $rdefs[$rd->roleid] = array();
+            }
+            $rdefs[$rd->roleid][$rd->path] = array();
         }
-        $accessdata['rdef_count']++;
-        $accessdata['rdef'][$k] =& $ACCESSLIB_PRIVATE->rolepermissions[$k];
+        $rdefs[$rd->roleid][$rd->path][$rd->capability] = (int) $rd->permission;
     }
 
-    return $accessdata;
+    $rs->close();
+    return $rdefs;
 }
 
 /**
@@ -487,13 +512,6 @@ function has_capability($capability, context $context, $user = null, $doanything
         $access =& $ACCESSLIB_PRIVATE->accessdatabyuser[$userid];
     }
 
-
-    // Load accessdata for below-the-course context if necessary,
-    // all contexts at and above all courses are already loaded
-    if ($context->contextlevel != CONTEXT_COURSE and $coursecontext = $context->get_course_context(false)) {
-        load_course_context($userid, $coursecontext, $access);
-    }
-
     return has_capability_in_accessdata($capability, $context, $access);
 }
 
@@ -742,11 +760,13 @@ function has_capability_in_accessdata($capability, context $context, array &$acc
     }
 
     // Now find out what access is given to each role, going bottom-->up direction
+    $rdefs = get_role_definitions(array_keys($roles));
     $allowed = false;
+
     foreach ($roles as $roleid => $ignored) {
         foreach ($paths as $path) {
-            if (isset($accessdata['rdef']["{$path}:$roleid"][$capability])) {
-                $perm = (int)$accessdata['rdef']["{$path}:$roleid"][$capability];
+            if (isset($rdefs[$roleid][$path][$capability])) {
+                $perm = (int)$rdefs[$roleid][$path][$capability];
                 if ($perm === CAP_PROHIBIT) {
                     // any CAP_PROHIBIT found means no permission for the user
                     return false;
@@ -790,39 +810,22 @@ function require_capability($capability, context $context, $userid = null, $doan
 }
 
 /**
- * Return a nested array showing role assignments
- * all relevant role capabilities for the user at
- * site/course_category/course levels
- *
- * We do _not_ delve deeper than courses because the number of
- * overrides at the module/block levels can be HUGE.
- *
- * [ra]   => [/path][roleid]=roleid
- * [rdef] => [/path:roleid][capability]=permission
+ * Return a nested array showing all role assignments for the user.
+ * [ra] => [contextpath][roleid] = roleid
  *
  * @access private
  * @param int $userid - the id of the user
  * @return array access info array
  */
-function get_user_access_sitewide($userid) {
-    global $CFG, $DB, $ACCESSLIB_PRIVATE;
-
-    /* Get in a few cheap DB queries...
-     * - role assignments
-     * - relevant role caps
-     *   - above and within this user's RAs
-     *   - below this user's RAs - limited to course level
-     */
+function get_user_roles_sitewide_accessdata($userid) {
+    global $CFG, $DB;
 
-    // raparents collects paths & roles we need to walk up the parenthood to build the minimal rdef
-    $raparents = array();
     $accessdata = get_empty_accessdata();
 
     // start with the default role
     if (!empty($CFG->defaultuserroleid)) {
         $syscontext = context_system::instance();
         $accessdata['ra'][$syscontext->path][(int)$CFG->defaultuserroleid] = (int)$CFG->defaultuserroleid;
-        $raparents[$CFG->defaultuserroleid][$syscontext->id] = $syscontext->id;
     }
 
     // load the "default frontpage role"
@@ -830,258 +833,27 @@ function get_user_access_sitewide($userid) {
         $frontpagecontext = context_course::instance(get_site()->id);
         if ($frontpagecontext->path) {
             $accessdata['ra'][$frontpagecontext->path][(int)$CFG->defaultfrontpageroleid] = (int)$CFG->defaultfrontpageroleid;
-            $raparents[$CFG->defaultfrontpageroleid][$frontpagecontext->id] = $frontpagecontext->id;
         }
     }
 
-    // preload every assigned role at and above course context
+    // Preload every assigned role.
     $sql = "SELECT ctx.path, ra.roleid, ra.contextid
               FROM {role_assignments} ra
-              JOIN {context} ctx
-                   ON ctx.id = ra.contextid
-         LEFT JOIN {block_instances} bi
-                   ON (ctx.contextlevel = ".CONTEXT_BLOCK." AND bi.id = ctx.instanceid)
-         LEFT JOIN {context} bpctx
-                   ON (bpctx.id = bi.parentcontextid)
-             WHERE ra.userid = :userid
-                   AND (ctx.contextlevel <= ".CONTEXT_COURSE." OR bpctx.contextlevel < ".CONTEXT_COURSE.")";
-    $params = array('userid'=>$userid);
-    $rs = $DB->get_recordset_sql($sql, $params);
+              JOIN {context} ctx ON ctx.id = ra.contextid
+             WHERE ra.userid = :userid";
+
+    $rs = $DB->get_recordset_sql($sql, array('userid' => $userid));
+
     foreach ($rs as $ra) {
         // RAs leafs are arrays to support multi-role assignments...
         $accessdata['ra'][$ra->path][(int)$ra->roleid] = (int)$ra->roleid;
-        $raparents[$ra->roleid][$ra->contextid] = $ra->contextid;
     }
-    $rs->close();
-
-    if (empty($raparents)) {
-        return $accessdata;
-    }
-
-    // now get overrides of interesting roles in all interesting child contexts
-    // hopefully we will not run out of SQL limits here,
-    // users would have to have very many roles at/above course context...
-    $sqls = array();
-    $params = array();
-
-    static $cp = 0;
-    foreach ($raparents as $roleid=>$ras) {
-        $cp++;
-        list($sqlcids, $cids) = $DB->get_in_or_equal($ras, SQL_PARAMS_NAMED, 'c'.$cp.'_');
-        $params = array_merge($params, $cids);
-        $params['r'.$cp] = $roleid;
-        $sqls[] = "(SELECT ctx.path, rc.roleid, rc.capability, rc.permission
-                     FROM {role_capabilities} rc
-                     JOIN {context} ctx
-                          ON (ctx.id = rc.contextid)
-                     JOIN {context} pctx
-                          ON (pctx.id $sqlcids
-                              AND (ctx.id = pctx.id
-                                   OR ctx.path LIKE ".$DB->sql_concat('pctx.path',"'/%'")."
-                                   OR pctx.path LIKE ".$DB->sql_concat('ctx.path',"'/%'")."))
-                LEFT JOIN {block_instances} bi
-                          ON (ctx.contextlevel = ".CONTEXT_BLOCK." AND bi.id = ctx.instanceid)
-                LEFT JOIN {context} bpctx
-                          ON (bpctx.id = bi.parentcontextid)
-                    WHERE rc.roleid = :r{$cp}
-                          AND (ctx.contextlevel <= ".CONTEXT_COURSE." OR bpctx.contextlevel < ".CONTEXT_COURSE.")
-                   )";
-    }
-
-    // fixed capability order is necessary for rdef dedupe
-    $rs = $DB->get_recordset_sql(implode("\nUNION\n", $sqls). "ORDER BY capability", $params);
 
-    foreach ($rs as $rd) {
-        $k = $rd->path.':'.$rd->roleid;
-        $accessdata['rdef'][$k][$rd->capability] = (int)$rd->permission;
-    }
     $rs->close();
 
-    // share the role definitions
-    foreach ($accessdata['rdef'] as $k=>$unused) {
-        if (!isset($ACCESSLIB_PRIVATE->rolepermissions[$k])) {
-            $ACCESSLIB_PRIVATE->rolepermissions[$k] = $accessdata['rdef'][$k];
-        }
-        $accessdata['rdef_count']++;
-        $accessdata['rdef'][$k] =& $ACCESSLIB_PRIVATE->rolepermissions[$k];
-    }
-
     return $accessdata;
 }
 
-/**
- * Add to the access ctrl array the data needed by a user for a given course.
- *
- * This function injects all course related access info into the accessdata array.
- *
- * @access private
- * @param int $userid the id of the user
- * @param context_course $coursecontext course context
- * @param array $accessdata accessdata array (modified)
- * @return void modifies $accessdata parameter
- */
-function load_course_context($userid, context_course $coursecontext, &$accessdata) {
-    global $DB, $CFG, $ACCESSLIB_PRIVATE;
-
-    if (empty($coursecontext->path)) {
-        // weird, this should not happen
-        return;
-    }
-
-    if (isset($accessdata['loaded'][$coursecontext->instanceid])) {
-        // already loaded, great!
-        return;
-    }
-
-    $roles = array();
-
-    if (empty($userid)) {
-        if (!empty($CFG->notloggedinroleid)) {
-            $roles[$CFG->notloggedinroleid] = $CFG->notloggedinroleid;
-        }
-
-    } else if (isguestuser($userid)) {
-        if ($guestrole = get_guest_role()) {
-            $roles[$guestrole->id] = $guestrole->id;
-        }
-
-    } else {
-        // Interesting role assignments at, above and below the course context
-        list($parentsaself, $params) = $DB->get_in_or_equal($coursecontext->get_parent_context_ids(true), SQL_PARAMS_NAMED, 'pc_');
-        $params['userid'] = $userid;
-        $params['children'] = $coursecontext->path."/%";
-        $sql = "SELECT ra.*, ctx.path
-                  FROM {role_assignments} ra
-                  JOIN {context} ctx ON ra.contextid = ctx.id
-                 WHERE ra.userid = :userid AND (ctx.id $parentsaself OR ctx.path LIKE :children)";
-        $rs = $DB->get_recordset_sql($sql, $params);
-
-        // add missing role definitions
-        foreach ($rs as $ra) {
-            $accessdata['ra'][$ra->path][(int)$ra->roleid] = (int)$ra->roleid;
-            $roles[$ra->roleid] = $ra->roleid;
-        }
-        $rs->close();
-
-        // add the "default frontpage role" when on the frontpage
-        if (!empty($CFG->defaultfrontpageroleid)) {
-            $frontpagecontext = context_course::instance(get_site()->id);
-            if ($frontpagecontext->id == $coursecontext->id) {
-                $roles[$CFG->defaultfrontpageroleid] = $CFG->defaultfrontpageroleid;
-            }
-        }
-
-        // do not forget the default role
-        if (!empty($CFG->defaultuserroleid)) {
-            $roles[$CFG->defaultuserroleid] = $CFG->defaultuserroleid;
-        }
-    }
-
-    if (!$roles) {
-        // weird, default roles must be missing...
-        $accessdata['loaded'][$coursecontext->instanceid] = 1;
-        return;
-    }
-
-    // now get overrides of interesting roles in all interesting contexts (this course + children + parents)
-    $params = array('pathprefix' => $coursecontext->path . '/%');
-    list($parentsaself, $rparams) = $DB->get_in_or_equal($coursecontext->get_parent_context_ids(true), SQL_PARAMS_NAMED, 'pc_');
-    $params = array_merge($params, $rparams);
-    list($roleids, $rparams) = $DB->get_in_or_equal($roles, SQL_PARAMS_NAMED, 'r_');
-    $params = array_merge($params, $rparams);
-
-    $sql = "SELECT ctx.path, rc.roleid, rc.capability, rc.permission
-                 FROM {context} ctx
-                 JOIN {role_capabilities} rc ON rc.contextid = ctx.id
-                WHERE rc.roleid $roleids
-                  AND (ctx.id $parentsaself OR ctx.path LIKE :pathprefix)
-             ORDER BY rc.capability"; // fixed capability order is necessary for rdef dedupe
-    $rs = $DB->get_recordset_sql($sql, $params);
-
-    $newrdefs = array();
-    foreach ($rs as $rd) {
-        $k = $rd->path.':'.$rd->roleid;
-        if (isset($accessdata['rdef'][$k])) {
-            continue;
-        }
-        $newrdefs[$k][$rd->capability] = (int)$rd->permission;
-    }
-    $rs->close();
-
-    // share new role definitions
-    foreach ($newrdefs as $k=>$unused) {
-        if (!isset($ACCESSLIB_PRIVATE->rolepermissions[$k])) {
-            $ACCESSLIB_PRIVATE->rolepermissions[$k] = $newrdefs[$k];
-        }
-        $accessdata['rdef_count']++;
-        $accessdata['rdef'][$k] =& $ACCESSLIB_PRIVATE->rolepermissions[$k];
-    }
-
-    $accessdata['loaded'][$coursecontext->instanceid] = 1;
-
-    // we want to deduplicate the USER->access from time to time, this looks like a good place,
-    // because we have to do it before the end of session
-    dedupe_user_access();
-}
-
-/**
- * Add to the access ctrl array the data needed by a role for a given context.
- *
- * The data is added in the rdef key.
- * This role-centric function is useful for role_switching
- * and temporary course roles.
- *
- * @access private
- * @param int $roleid the id of the user
- * @param context $context needs path!
- * @param array $accessdata accessdata array (is modified)
- * @return array
- */
-function load_role_access_by_context($roleid, context $context, &$accessdata) {
-    global $DB, $ACCESSLIB_PRIVATE;
-
-    /* Get the relevant rolecaps into rdef
-     * - relevant role caps
-     *   - at ctx and above
-     *   - below this ctx
-     */
-
-    if (empty($context->path)) {
-        // weird, this should not happen
-        return;
-    }
-
-    list($parentsaself, $params) = $DB->get_in_or_equal($context->get_parent_context_ids(true), SQL_PARAMS_NAMED, 'pc_');
-    $params['roleid'] = $roleid;
-    $params['childpath'] = $context->path.'/%';
-
-    $sql = "SELECT ctx.path, rc.capability, rc.permission
-              FROM {role_capabilities} rc
-              JOIN {context} ctx ON (rc.contextid = ctx.id)
-             WHERE rc.roleid = :roleid AND (ctx.id $parentsaself OR ctx.path LIKE :childpath)
-          ORDER BY rc.capability"; // fixed capability order is necessary for rdef dedupe
-    $rs = $DB->get_recordset_sql($sql, $params);
-
-    $newrdefs = array();
-    foreach ($rs as $rd) {
-        $k = $rd->path.':'.$roleid;
-        if (isset($accessdata['rdef'][$k])) {
-            continue;
-        }
-        $newrdefs[$k][$rd->capability] = (int)$rd->permission;
-    }
-    $rs->close();
-
-    // share new role definitions
-    foreach ($newrdefs as $k=>$unused) {
-        if (!isset($ACCESSLIB_PRIVATE->rolepermissions[$k])) {
-            $ACCESSLIB_PRIVATE->rolepermissions[$k] = $newrdefs[$k];
-        }
-        $accessdata['rdef_count']++;
-        $accessdata['rdef'][$k] =& $ACCESSLIB_PRIVATE->rolepermissions[$k];
-    }
-}
-
 /**
  * Returns empty accessdata structure.
  *
@@ -1091,10 +863,6 @@ function load_role_access_by_context($roleid, context $context, &$accessdata) {
 function get_empty_accessdata() {
     $accessdata               = array(); // named list
     $accessdata['ra']         = array();
-    $accessdata['rdef']       = array();
-    $accessdata['rdef_count'] = 0;       // this bloody hack is necessary because count($array) is slooooowwww in PHP
-    $accessdata['rdef_lcc']   = 0;       // rdef_count during the last compression
-    $accessdata['loaded']     = array(); // loaded course contexts
     $accessdata['time']       = time();
     $accessdata['rsw']        = array();
 
@@ -1112,11 +880,7 @@ function get_empty_accessdata() {
 function get_user_accessdata($userid, $preloadonly=false) {
     global $CFG, $ACCESSLIB_PRIVATE, $USER;
 
-    if (!empty($USER->access['rdef']) and empty($ACCESSLIB_PRIVATE->rolepermissions)) {
-        // share rdef from USER session with rolepermissions cache in order to conserve memory
-        foreach ($USER->access['rdef'] as $k=>$v) {
-            $ACCESSLIB_PRIVATE->rolepermissions[$k] =& $USER->access['rdef'][$k];
-        }
+    if (isset($USER->access)) {
         $ACCESSLIB_PRIVATE->accessdatabyuser[$USER->id] = $USER->access;
     }
 
@@ -1138,7 +902,8 @@ function get_user_accessdata($userid, $preloadonly=false) {
             }
 
         } else {
-            $accessdata = get_user_access_sitewide($userid); // includes default role and frontpage role
+            // Includes default role and frontpage role.
+            $accessdata = get_user_roles_sitewide_accessdata($userid);
         }
 
         $ACCESSLIB_PRIVATE->accessdatabyuser[$userid] = $accessdata;
@@ -1151,45 +916,6 @@ function get_user_accessdata($userid, $preloadonly=false) {
     }
 }
 
-/**
- * Try to minimise the size of $USER->access by eliminating duplicate override storage,
- * this function looks for contexts with the same overrides and shares them.
- *
- * @access private
- * @return void
- */
-function dedupe_user_access() {
-    global $USER;
-
-    if (CLI_SCRIPT) {
-        // no session in CLI --> no compression necessary
-        return;
-    }
-
-    if (empty($USER->access['rdef_count'])) {
-        // weird, this should not happen
-        return;
-    }
-
-    // the rdef is growing only, we never remove stuff from it, the rdef_lcc helps us to detect new stuff in rdef
-    if ($USER->access['rdef_count'] - $USER->access['rdef_lcc'] > 10) {
-        // do not compress after each change, wait till there is more stuff to be done
-        return;
-    }
-
-    $hashmap = array();
-    foreach ($USER->access['rdef'] as $k=>$def) {
-        $hash = sha1(serialize($def));
-        if (isset($hashmap[$hash])) {
-            $USER->access['rdef'][$k] =& $hashmap[$hash];
-        } else {
-            $hashmap[$hash] =& $USER->access['rdef'][$k];
-        }
-    }
-
-    $USER->access['rdef_lcc'] = $USER->access['rdef_count'];
-}
-
 /**
  * A convenience function to completely load all the capabilities
  * for the current user. It is called from has_capability() and functions change permissions.
@@ -1216,9 +942,6 @@ function load_all_capabilities() {
     unset($USER->access);
     $USER->access = get_user_accessdata($USER->id);
 
-    // deduplicate the overrides to minimize session size
-    dedupe_user_access();
-
     // Clear to force a refresh
     unset($USER->mycourses);
 
@@ -1296,12 +1019,7 @@ function load_temp_course_role(context_course $coursecontext, $roleid) {
         return;
     }
 
-    // load course stuff first
-    load_course_context($USER->id, $coursecontext, $USER->access);
-
     $USER->access['ra'][$coursecontext->path][(int)$roleid] = (int)$roleid;
-
-    load_role_access_by_context($roleid, $coursecontext, $USER->access);
 }
 
 /**
@@ -1536,6 +1254,9 @@ function delete_role($roleid) {
     $event->add_record_snapshot('role', $role);
     $event->trigger();
 
+    // Reset any cache of this role, including MUC.
+    accesslib_clear_role_cache($roleid);
+
     return true;
 }
 
@@ -1587,6 +1308,10 @@ function assign_capability($capability, $permission, $roleid, $contextid, $overw
             $DB->insert_record('role_capabilities', $cap);
         }
     }
+
+    // Reset any cache of this role, including MUC.
+    accesslib_clear_role_cache($roleid);
+
     return true;
 }
 
@@ -1614,6 +1339,10 @@ function unassign_capability($capability, $roleid, $contextid = null) {
     } else {
         $DB->delete_records('role_capabilities', array('capability'=>$capability, 'roleid'=>$roleid));
     }
+
+    // Reset any cache of this role, including MUC.
+    accesslib_clear_role_cache($roleid);
+
     return true;
 }
 
@@ -2341,6 +2070,9 @@ function reset_role_capabilities($roleid) {
         assign_capability($cap, $permission, $roleid, $systemcontext->id);
     }
 
+    // Reset any cache of this role, including MUC.
+    accesslib_clear_role_cache($roleid);
+
     // Mark the system context dirty.
     context_system::instance()->mark_dirty();
 }
@@ -4099,27 +3831,8 @@ function get_roles_on_exact_context(context $context) {
 function role_switch($roleid, context $context) {
     global $USER;
 
-    //
-    // Plan of action
-    //
-    // - Add the ghost RA to $USER->access
-    //   as $USER->access['rsw'][$path] = $roleid
-    //
-    // - Make sure $USER->access['rdef'] has the roledefs
-    //   it needs to honour the switcherole
-    //
-    // Roledefs will get loaded "deep" here - down to the last child
-    // context. Note that
-    //
-    // - When visiting subcontexts, our selective accessdata loading
-    //   will still work fine - though those ra/rdefs will be ignored
-    //   appropriately while the switch is in place
-    //
-    // - If a switcherole happens at a category with tons of courses
-    //   (that have many overrides for switched-to role), the session
-    //   will get... quite large. Sometimes you just can't win.
-    //
-    // To un-switch just unset($USER->access['rsw'][$path])
+    // Add the ghost RA to $USER->access as $USER->access['rsw'][$path] = $roleid.
+    // To un-switch just unset($USER->access['rsw'][$path]).
     //
     // Note: it is not possible to switch to roles that do not have course:view
 
@@ -4127,7 +3840,6 @@ function role_switch($roleid, context $context) {
         load_all_capabilities();
     }
 
-
     // Add the switch RA
     if ($roleid == 0) {
         unset($USER->access['rsw'][$context->path]);
@@ -4136,9 +3848,6 @@ function role_switch($roleid, context $context) {
 
     $USER->access['rsw'][$context->path] = $roleid;
 
-    // Load roledefs
-    load_role_access_by_context($roleid, $context, $USER->access);
-
     return true;
 }
 
@@ -4545,6 +4254,9 @@ function role_cap_duplicate($sourcerole, $targetrole) {
         $cap->roleid = $targetrole;
         $DB->insert_record('role_capabilities', $cap);
     }
+
+    // Reset any cache of this role, including MUC.
+    accesslib_clear_role_cache($targetrole);
 }
 
 /**
@@ -5279,11 +4991,18 @@ abstract class context extends stdClass implements IteratorAggregate {
         require_once($CFG->dirroot.'/grade/grading/lib.php');
         grading_manager::delete_all_for_context($this->_id);
 
+        $ids = $DB->get_fieldset_select('role_capabilities', 'DISTINCT roleid', 'contextid = ?', array($this->_id));
+
         // now delete stuff from role related tables, role_unassign_all
         // and unenrol should be called earlier to do proper cleanup
         $DB->delete_records('role_assignments', array('contextid'=>$this->_id));
         $DB->delete_records('role_capabilities', array('contextid'=>$this->_id));
         $DB->delete_records('role_names', array('contextid'=>$this->_id));
+
+        if ($ids) {
+            // Reset any cache of these roles, including MUC.
+            accesslib_clear_role_cache($ids);
+        }
     }
 
     /**
index 74a92b3..7fc7d30 100644 (file)
@@ -10519,3 +10519,121 @@ class admin_setting_scsscode extends admin_setting_configtextarea {
         return true;
     }
 }
+
+
+/**
+ * Administration setting to define a list of file types.
+ *
+ * @copyright 2016 Jonathon Fowler <fowlerj@usq.edu.au>
+ * @copyright 2017 David Mudrák <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class admin_setting_filetypes extends admin_setting_configtext {
+
+    /** @var array Allow selection from these file types only. */
+    protected $onlytypes = [];
+
+    /** @var bool Allow selection of 'All file types' (will be stored as '*'). */
+    protected $allowall = true;
+
+    /** @var core_form\filetypes_util instance to use as a helper. */
+    protected $util = null;
+
+    /**
+     * Constructor.
+     *
+     * @param string $name Unique ascii name like 'mycoresetting' or 'myplugin/mysetting'
+     * @param string $visiblename Localised label of the setting
+     * @param string $description Localised description of the setting
+     * @param string $defaultsetting Default setting value.
+     * @param array $options Setting widget options, an array with optional keys:
+     *   'onlytypes' => array Allow selection from these file types only; for example ['onlytypes' => ['web_image']].
+     *   'allowall' => bool Allow to select 'All file types', defaults to true. Does not apply if onlytypes are set.
+     */
+    public function __construct($name, $visiblename, $description, $defaultsetting = '', array $options = []) {
+
+        parent::__construct($name, $visiblename, $description, $defaultsetting, PARAM_RAW);
+
+        if (array_key_exists('onlytypes', $options) && is_array($options['onlytypes'])) {
+            $this->onlytypes = $options['onlytypes'];
+        }
+
+        if (!$this->onlytypes && array_key_exists('allowall', $options)) {
+            $this->allowall = (bool)$options['allowall'];
+        }
+
+        $this->util = new \core_form\filetypes_util();
+    }
+
+    /**
+     * Normalize the user's input and write it to the database as comma separated list.
+     *
+     * Comma separated list as a text representation of the array was chosen to
+     * make this compatible with how the $CFG->courseoverviewfilesext values are stored.
+     *
+     * @param string $data Value submitted by the admin.
+     * @return string Epty string if all good, error message otherwise.
+     */
+    public function write_setting($data) {
+        return parent::write_setting(implode(',', $this->util->normalize_file_types($data)));
+    }
+
+    /**
+     * Validate data before storage
+     *
+     * @param string $data The setting values provided by the admin
+     * @return bool|string True if ok, the string if error found
+     */
+    public function validate($data) {
+
+        // No need to call parent's validation here as we are PARAM_RAW.
+
+        if ($this->util->is_whitelisted($data, $this->onlytypes)) {
+            return true;
+
+        } else {
+            $troublemakers = $this->util->get_not_whitelisted($data, $this->onlytypes);
+            return get_string('filetypesnotwhitelisted', 'core_form', implode(' ', $troublemakers));
+        }
+    }
+
+    /**
+     * Return an HTML string for the setting element.
+     *
+     * @param string $data The current setting value
+     * @param string $query Admin search query to be highlighted
+     * @return string HTML to be displayed
+     */
+    public function output_html($data, $query='') {
+        global $OUTPUT, $PAGE;
+
+        $default = $this->get_defaultsetting();
+        $context = (object) [
+            'id' => $this->get_id(),
+            'name' => $this->get_full_name(),
+            'value' => $data,
+            'descriptions' => $this->util->describe_file_types($data),
+        ];
+        $element = $OUTPUT->render_from_template('core_admin/setting_filetypes', $context);
+
+        $PAGE->requires->js_call_amd('core_form/filetypes', 'init', [
+            $this->get_id(),
+            $this->visiblename,
+            $this->onlytypes,
+            $this->allowall,
+        ]);
+
+        return format_admin_setting($this, $this->visiblename, $element, $this->description, true, '', $default, $query);
+    }
+
+    /**
+     * Should the values be always displayed in LTR mode?
+     *
+     * We always return true here because these values are not RTL compatible.
+     *
+     * @return bool True because these values are not RTL compatible.
+     */
+    public function get_force_ltr() {
+        return true;
+    }
+}
index 907a905..5a06600 100644 (file)
Binary files a/lib/amd/build/fragment.min.js and b/lib/amd/build/fragment.min.js differ
index d0d47f8..0036edc 100644 (file)
Binary files a/lib/amd/build/templates.min.js and b/lib/amd/build/templates.min.js differ
index 8fb4df9..61a07a3 100644 (file)
Binary files a/lib/amd/build/user_date.min.js and b/lib/amd/build/user_date.min.js differ
index 28627fc..0a29f68 100644 (file)
@@ -45,10 +45,7 @@ define(['jquery', 'core/ajax'], function($, ajax) {
             });
         }
 
-        // Ajax stuff.
-        var deferred = $.Deferred();
-
-        var promises = ajax.call([{
+        return ajax.call([{
             methodname: 'core_get_fragment',
             args: {
                 component: component,
@@ -56,14 +53,7 @@ define(['jquery', 'core/ajax'], function($, ajax) {
                 contextid: contextid,
                 args: formattedparams
             }
-        }], false);
-
-        promises[0].done(function(data) {
-            deferred.resolve(data);
-        }).fail(function(ex) {
-            deferred.reject(ex);
-        });
-        return deferred.promise();
+        }])[0];
     };
 
     return /** @alias module:core/fragment */{
@@ -81,7 +71,7 @@ define(['jquery', 'core/ajax'], function($, ajax) {
          */
         loadFragment: function(component, callback, contextid, params) {
             var promise = $.Deferred();
-            $.when(loadFragment(component, callback, contextid, params)).then(function(data) {
+            loadFragment(component, callback, contextid, params).then(function(data) {
                 var jsNodes = $(data.javascript);
                 var allScript = '';
                 jsNodes.each(function(index, scriptNode) {
@@ -111,6 +101,7 @@ define(['jquery', 'core/ajax'], function($, ajax) {
                     }
                 });
                 promise.resolve(data.html, allScript);
+                return;
             }).fail(function(ex) {
                 promise.reject(ex);
             });
index e2c2f05..8dae596 100644 (file)
@@ -170,7 +170,7 @@ define(['core/mustache',
                 ready.reject('Invalid icon system specified' + config.iconsystemmodule);
             } else {
                 iconSystem = system;
-                system.init().then(ready.resolve);
+                system.init().then(ready.resolve).catch(notification.exception);
             }
         });
 
@@ -698,7 +698,7 @@ define(['core/mustache',
                 ready.reject('Invalid icon system specified' + config.iconsystem);
             } else {
                 iconSystem = system;
-                system.init().then(ready.resolve);
+                system.init().then(ready.resolve).catch(notification.exception);
             }
         });
 
index d739f54..826ce41 100644 (file)
@@ -127,8 +127,9 @@ define(['jquery', 'core/ajax', 'core/sessionstorage', 'core/config'],
                 addToLocalStorage(key, value);
                 date.deferred.resolve(value);
             });
+            return;
         })
-        .fail(function(ex) {
+        .catch(function(ex) {
             // If we failed to retrieve the dates then reject the date's
             // deferred objects to make sure they don't hang.
             dates.forEach(function(date) {
index 45d9779..509ec44 100644 (file)
@@ -53,10 +53,20 @@ class external extends external_api {
                 array('component' => new external_value(PARAM_COMPONENT, 'component containing the template'),
                       'template' => new external_value(PARAM_ALPHANUMEXT, 'name of the template'),
                       'themename' => new external_value(PARAM_ALPHANUMEXT, 'The current theme.'),
+                      'includecomments' => new external_value(PARAM_BOOL, 'Include comments or not', VALUE_DEFAULT, false)
                          )
             );
     }
 
+    /**
+     * Remove comments from mustache template.
+     * @param string $templatestr
+     * @return mixed
+     */
+    protected static function strip_template_comments($templatestr) {
+        return preg_replace('/(?={{!)(.*)(}})/sU', '', $templatestr);
+    }
+
     /**
      * Return a mustache template, and all the strings it requires.
      *
@@ -65,17 +75,19 @@ class external extends external_api {
      * @param string $themename The name of the current theme.
      * @return string the template
      */
-    public static function load_template($component, $template, $themename) {
+    public static function load_template($component, $template, $themename, $includecomments = false) {
         global $DB, $CFG, $PAGE;
 
         $params = self::validate_parameters(self::load_template_parameters(),
                                             array('component' => $component,
                                                   'template' => $template,
-                                                  'themename' => $themename));
+                                                  'themename' => $themename,
+                                                  'includecomments' => $includecomments));
 
         $component = $params['component'];
         $template = $params['template'];
         $themename = $params['themename'];
+        $includecomments = $params['includecomments'];
 
         $templatename = $component . '/' . $template;
 
@@ -83,6 +95,11 @@ class external extends external_api {
         $filename = mustache_template_finder::get_template_filepath($templatename, $themename);
         $templatestr = file_get_contents($filename);
 
+        // Remove comments from template.
+        if (!$includecomments) {
+            $templatestr = self::strip_template_comments($templatestr);
+        }
+
         return $templatestr;
     }
 
index 164a767..55626a4 100644 (file)
@@ -203,7 +203,7 @@ function cron_run_single_task(\core\task\scheduled_task $task) {
     }
     $component = $task->get_component();
     if ($plugininfo = core_plugin_manager::instance()->get_plugin_info($component)) {
-        if (!$plugininfo->is_enabled() && !$task->get_run_if_component_disabled()) {
+        if ($plugininfo->is_enabled() === false && !$task->get_run_if_component_disabled()) {
             echo "Component is not enabled ($component).\n";
             return false;
         }
index 5285572..42d98d4 100644 (file)
@@ -247,6 +247,15 @@ $definitions = array(
         'simpledata' => true,
     ),
 
+    // Cache system-wide role definitions.
+    'roledefs' => array(
+        'mode' => cache_store::MODE_APPLICATION,
+        'simplekeys' => true,
+        'simpledata' => true,
+        'staticacceleration' => true,
+        'staticaccelerationsize' => 30,
+    ),
+
     // Caches plugins existing functions by function name and file.
     // Set static acceleration size to 5 to load a few functions.
     'plugin_functions' => array(
index 7af71ca..002640e 100644 (file)
@@ -469,6 +469,15 @@ $functions = array(
         'type'        => 'write',
         'classpath'   => 'files/externallib.php',
     ),
+    'core_form_get_filetypes_browser_data' => array(
+        'classname' => 'core_form\external',
+        'methodname' => 'get_filetypes_browser_data',
+        'classpath' => '',
+        'description' => 'Provides data for the filetypes element browser.',
+        'type' => 'read',
+        'loginrequired' => false,
+        'ajax' => true,
+    ),
     'core_get_component_strings' => array(
         'classname' => 'core_external',
         'methodname' => 'get_component_strings',
index 0675772..a9e900f 100644 (file)
@@ -6309,3 +6309,77 @@ function calendar_cron() {
 
     return true;
 }
+
+/**
+ * Previous internal API, it was not supposed to be used anywhere.
+ *
+ * @access private
+ * @deprecated since Moodle 3.4 and removed immediately. MDL-49398.
+ * @param int $userid the id of the user
+ * @param context_course $coursecontext course context
+ * @param array $accessdata accessdata array (modified)
+ * @return void modifies $accessdata parameter
+ */
+function load_course_context($userid, context_course $coursecontext, &$accessdata) {
+    throw new coding_exception('load_course_context() is removed. Do not use private functions or data structures.');
+}
+
+/**
+ * Previous internal API, it was not supposed to be used anywhere.
+ *
+ * @access private
+ * @deprecated since Moodle 3.4 and removed immediately. MDL-49398.
+ * @param int $roleid the id of the user
+ * @param context $context needs path!
+ * @param array $accessdata accessdata array (is modified)
+ * @return array
+ */
+function load_role_access_by_context($roleid, context $context, &$accessdata) {
+    throw new coding_exception('load_role_access_by_context() is removed. Do not use private functions or data structures.');
+}
+
+/**
+ * Previous internal API, it was not supposed to be used anywhere.
+ *
+ * @access private
+ * @deprecated since Moodle 3.4 and removed immediately. MDL-49398.
+ * @return void
+ */
+function dedupe_user_access() {
+    throw new coding_exception('dedupe_user_access() is removed. Do not use private functions or data structures.');
+}
+
+/**
+ * Previous internal API, it was not supposed to be used anywhere.
+ * Return a nested array showing role assignments
+ * and all relevant role capabilities for the user.
+ *
+ * [ra]   => [/path][roleid]=roleid
+ * [rdef] => ["$contextpath:$roleid"][capability]=permission
+ *
+ * @access private
+ * @deprecated since Moodle 3.4. MDL-49398.
+ * @param int $userid - the id of the user
+ * @return array access info array
+ */
+function get_user_access_sitewide($userid) {
+    debugging('get_user_access_sitewide() is deprecated. Do not use private functions or data structures.', DEBUG_DEVELOPER);
+
+    $accessdata = get_user_accessdata($userid);
+    $accessdata['rdef'] = array();
+    $roles = array();
+
+    foreach ($accessdata['ra'] as $path => $pathroles) {
+        $roles = array_merge($pathroles, $roles);
+    }
+
+    $rdefs = get_role_definitions($roles);
+
+    foreach ($rdefs as $roleid => $rdef) {
+        foreach ($rdef as $path => $caps) {
+            $accessdata['rdef']["$path:$roleid"] = $caps;
+        }
+    }
+
+    return $accessdata;
+}
diff --git a/lib/form/amd/build/filetypes.min.js b/lib/form/amd/build/filetypes.min.js
new file mode 100644 (file)
index 0000000..614cb69
Binary files /dev/null and b/lib/form/amd/build/filetypes.min.js differ
diff --git a/lib/form/amd/src/filetypes.js b/lib/form/amd/src/filetypes.js
new file mode 100644 (file)
index 0000000..049214c
--- /dev/null
@@ -0,0 +1,306 @@
+// 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/>.
+
+/**
+ * This module allows to enhance the form elements MoodleQuickForm_filetypes
+ *
+ * @module     core_form/filetypes
+ * @package    core_form
+ * @copyright  2017 David Mudrak <david@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      3.3
+ */
+define(['jquery', 'core/log', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/ajax',
+        'core/templates', 'core/tree'],
+    function($, Log, Str, ModalFactory, ModalEvents, Ajax, Templates, Tree) {
+
+    "use strict";
+
+    /**
+     * Constructor of the FileTypes instances.
+     *
+     * @constructor
+     * @param {String} elementId The id of the form element to enhance
+     * @param {String} elementLabel The label of the form element used as the modal selector title
+     * @param {String} onlyTypes Limit the list of offered types to this
+     * @param {Bool} allowAll Allow presence of the "All file types" item
+     */
+    var FileTypes = function(elementId, elementLabel, onlyTypes, allowAll) {
+
+        this.elementId = elementId;
+        this.elementLabel = elementLabel;
+        this.onlyTypes = onlyTypes;
+        this.allowAll = allowAll;
+
+        this.inputField = $('#' + elementId);
+        this.wrapperBrowserTrigger = $('[data-filetypesbrowser="' + elementId + '"]');
+        this.wrapperDescriptions = $('[data-filetypesdescriptions="' + elementId + '"]');
+
+        if (!this.wrapperBrowserTrigger.length) {
+            // This is a valid case. Most probably the element is frozen and
+            // the filetypes browser should not be available.
+            return;
+        }
+
+        if (!this.inputField.length || !this.wrapperDescriptions.length) {
+            Log.error('core_form/filetypes: Unexpected DOM structure, unable to enhance filetypes field ' + elementId);
+            return;
+        }
+
+        this.prepareBrowserTrigger()
+            .then(function() {
+                return this.prepareBrowserModal();
+            }.bind(this))
+
+            .then(function() {
+                return this.prepareBrowserTree();
+            }.bind(this));
+    };
+
+    /**
+     * Create and set the browser trigger widget (this.browserTrigger).
+     *
+     * @method prepareBrowserTrigger
+     * @returns {Promise}
+     */
+    FileTypes.prototype.prepareBrowserTrigger = function() {
+        return Templates.render('core_form/filetypes-trigger', {})
+            .then(function(html) {
+                this.wrapperBrowserTrigger.html(html);
+                this.browserTrigger = this.wrapperBrowserTrigger.find('[data-filetypeswidget="browsertrigger"]');
+            }.bind(this));
+    };
+
+    /**
+     * Create and set the modal for displaying the browser (this.browserModal).
+     *
+     * @method prepareBrowserModal
+     * @returns {Promise}
+     */
+    FileTypes.prototype.prepareBrowserModal = function() {
+        return ModalFactory.create({
+            type: ModalFactory.types.SAVE_CANCEL,
+            title: this.elementLabel
+
+        }).then(function(modal) {
+            this.browserModal = modal;
+        }.bind(this))
+
+        .then(function() {
+            // Because we have custom conditional modal trigger, we need to
+            // handle the focus after closing ourselves, too.
+            this.browserModal.getRoot().on(ModalEvents.hidden, function() {
+                this.browserTrigger.focus();
+            }.bind(this));
+
+            this.browserModal.getRoot().on(ModalEvents.save, function() {
+                this.saveBrowserModal();
+            }.bind(this));
+        }.bind(this));
+
+    };
+
+    /**
+     * Create and set the tree in the browser modal's body.
+     *
+     * @method prepareBrowserTree
+     * @returns {Promise}
+     */
+    FileTypes.prototype.prepareBrowserTree = function() {
+
+        this.browserTrigger.on('click', function(e) {
+            e.preventDefault();
+
+            // We want to display the browser modal only when the associated input
+            // field is not frozen (disabled).
+            if (this.inputField.is('[disabled]')) {
+                return;
+            }
+
+            var bodyContent = this.loadBrowserModalBody();
+
+            bodyContent.then(function() {
+
+                // Turn the list of groups and extensions into the tree.
+                this.browserTree = new Tree(this.browserModal.getBody());
+
+                // Override the behaviour of the Enter and Space keys to toggle our checkbox,
+                // rather than toggle the tree node expansion status.
+                this.browserTree.handleKeyDown = function(item, e) {
+                    if (e.keyCode == this.browserTree.keys.enter || e.keyCode == this.browserTree.keys.space) {
+                        e.preventDefault();
+                        e.stopPropagation();
+                        this.toggleCheckbox(item.attr('data-filetypesbrowserkey'));
+                    } else {
+                        Tree.prototype.handleKeyDown.call(this.browserTree, item, e);
+                    }
+                }.bind(this);
+
+                if (this.allowAll) {
+                    // Hide all other items if "All file types" is enabled.
+                    this.hideOrShowItemsDependingOnAllowAll(this.browserModal.getRoot()
+                        .find('input[type="checkbox"][data-filetypesbrowserkey="*"]:first'));
+                    // And do the same whenever we click that checkbox.
+                    this.browserModal.getRoot().on('change', 'input[type="checkbox"][data-filetypesbrowserkey="*"]', function(e) {
+                        this.hideOrShowItemsDependingOnAllowAll($(e.currentTarget));
+                    }.bind(this));
+                }
+
+                // Synchronize checked status if the file extension is present in multiple groups.
+                this.browserModal.getRoot().on('change', 'input[type="checkbox"][data-filetypesbrowserkey]', function(e) {
+                    var checkbox = $(e.currentTarget);
+                    var key = checkbox.attr('data-filetypesbrowserkey');
+                    this.browserModal.getRoot().find('input[type="checkbox"][data-filetypesbrowserkey="' + key + '"]')
+                        .prop('checked', checkbox.prop('checked'));
+                }.bind(this));
+
+            }.bind(this))
+
+            .then(function() {
+                this.browserModal.show();
+            }.bind(this));
+
+            this.browserModal.setBody(bodyContent);
+
+        }.bind(this));
+
+        // Return a resolved promise.
+        return $.when();
+    };
+
+    /**
+     * Load the browser modal body contents.
+     *
+     * @returns {Promise}
+     */
+    FileTypes.prototype.loadBrowserModalBody = function() {
+
+        var args = {
+            onlytypes: this.onlyTypes.join(),
+            allowall: this.allowAll,
+            current: this.inputField.val()
+        };
+
+        return Ajax.call([{
+            methodname: 'core_form_get_filetypes_browser_data',
+            args: args
+
+        }])[0].then(function(browserData) {
+            return Templates.render('core_form/filetypes-browser', {
+                elementid: this.elementId,
+                groups: browserData.groups
+            });
+        }.bind(this));
+    };
+
+    /**
+     * Change the checked status of the given file type (group or extension).
+     *
+     * @method toggleCheckbox
+     * @param {String} key
+     */
+    FileTypes.prototype.toggleCheckbox = function(key) {
+
+        var checkbox = this.browserModal.getRoot().find('input[type="checkbox"][data-filetypesbrowserkey="' + key + '"]:first');
+
+        checkbox.prop('checked', !checkbox.prop('checked'));
+    };
+
+    /**
+     * Update the associated input field with selected file types.
+     *
+     * @method saveBrowserModal
+     */
+    FileTypes.prototype.saveBrowserModal = function() {
+
+        // Check the "All file types" first.
+        if (this.allowAll) {
+            var allcheckbox = this.browserModal.getRoot().find('input[type="checkbox"][data-filetypesbrowserkey="*"]');
+            if (allcheckbox.length && allcheckbox.prop('checked')) {
+                this.inputField.val('*');
+                this.updateDescriptions(['*']);
+                return;
+            }
+        }
+
+        // Iterate over all checked boxes and populate the list.
+        var newvalue = [];
+
+        this.browserModal.getRoot().find('input[type="checkbox"]').each(/** @this represents the checkbox */ function() {
+            var checkbox = $(this);
+            var key = checkbox.attr('data-filetypesbrowserkey');
+
+            if (checkbox.prop('checked')) {
+                newvalue.push(key);
+            }
+        });
+
+        // Remove duplicates (e.g. file types present in multiple groups).
+        newvalue = newvalue.filter(function(x, i, a) {
+            return a.indexOf(x) == i;
+        });
+
+        this.inputField.val(newvalue.join(' '));
+        this.updateDescriptions(newvalue);
+    };
+
+    /**
+     * Describe the selected filetypes in the form when saving the browser.
+     *
+     * @param {Array} keys List of keys to describe
+     * @returns {Promise}
+     */
+    FileTypes.prototype.updateDescriptions = function(keys) {
+
+        var descriptions = [];
+
+        keys.forEach(function(key) {
+            descriptions.push({
+                description: this.browserModal.getRoot().find('[data-filetypesname="' + key + '"]:first').text().trim(),
+                extensions: this.browserModal.getRoot().find('[data-filetypesextensions="' + key + '"]:first').text().trim()
+            });
+        }.bind(this));
+
+        var templatedata = {
+            hasdescriptions: (descriptions.length > 0),
+            descriptions: descriptions
+        };
+
+        return Templates.render('core_form/filetypes-descriptions', templatedata)
+            .then(function(html) {
+                this.wrapperDescriptions.html(html);
+            }.bind(this));
+    };
+
+    /**
+     * If "All file types" is checked, all other browser items are made hidden, and vice versa.
+     *
+     * @param {jQuery} allcheckbox The "All file types" checkbox.
+     */
+    FileTypes.prototype.hideOrShowItemsDependingOnAllowAll = function(allcheckbox) {
+        var others = this.browserModal.getRoot().find('[role="treeitem"][data-filetypesbrowserkey!="*"]');
+        if (allcheckbox.prop('checked')) {
+            others.hide();
+        } else {
+            others.show();
+        }
+    };
+
+    return {
+        init: function(elementId, elementLabel, onlyTypes, allowAll) {
+            new FileTypes(elementId, elementLabel, onlyTypes, allowAll);
+        }
+    };
+});
diff --git a/lib/form/classes/external.php b/lib/form/classes/external.php
new file mode 100644 (file)
index 0000000..40b1c73
--- /dev/null
@@ -0,0 +1,108 @@
+<?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/>.
+
+/**
+ * Provides the {@link core_form\external} class.
+ *
+ * @package     core_form
+ * @category    external
+ * @copyright   2017 David Mudrák <david@moodle.com>
+ * @copyright   2016 Jonathon Fowler <fowlerj@usq.edu.au>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_form;
+
+use external_api;
+use external_function_parameters;
+use external_multiple_structure;
+use external_single_structure;
+use external_value;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir.'/externallib.php');
+
+/**
+ * Implements the external functions provided by the core_form subsystem.
+ *
+ * @copyright 2017 David Mudrak <david@moodle.com>
+ * @copyright 2016 Jonathon Fowler <fowlerj@usq.edu.au>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class external extends external_api {
+
+    /**
+     * Describes the input paramaters of the get_filetypes_browser_data external function.
+     *
+     * @return external_description
+     */
+    public static function get_filetypes_browser_data_parameters() {
+        return new external_function_parameters([
+            'onlytypes' => new external_value(PARAM_RAW, 'Limit the browser to the given groups and extensions', VALUE_DEFAULT, ''),
+            'allowall' => new external_value(PARAM_BOOL, 'Allows to select All file types, does not apply with onlytypes are set.',
+                VALUE_DEFAULT, true),
+            'current' => new external_value(PARAM_RAW, 'Current types that should be selected.', VALUE_DEFAULT, ''),
+        ]);
+    }
+
+    /**
+     * Implements the get_filetypes_browser_data external function.
+     *
+     * @param string $onlytypes Allow selection from these file types only; for example 'web_image'.
+     * @param bool $allowall Allow to select 'All file types'. Does not apply if onlytypes is set.
+     * @param string $current Current values that should be selected.
+     * @return object
+     */
+    public static function get_filetypes_browser_data($onlytypes, $allowall, $current) {
+
+        $params = self::validate_parameters(self::get_filetypes_browser_data_parameters(),
+            compact('onlytypes', 'allowall', 'current'));
+
+        $util = new filetypes_util();
+
+        return ['groups' => $util->data_for_browser($params['onlytypes'], $params['allowall'], $params['current'])];
+    }
+
+    /**
+     * Describes the output of the get_filetypes_browser_data external function.
+     *
+     * @return external_description
+     */
+    public static function get_filetypes_browser_data_returns() {
+
+        $type = new external_single_structure([
+            'key' => new external_value(PARAM_RAW, 'The file type identifier'),
+            'name' => new external_value(PARAM_RAW, 'The file type name'),
+            'selected' => new external_value(PARAM_BOOL, 'Should it be marked as selected'),
+            'ext' => new external_value(PARAM_RAW, 'The file extension associated with the file type'),
+        ]);
+
+        $group = new external_single_structure([
+            'key' => new external_value(PARAM_RAW, 'The file type group identifier'),
+            'name' => new external_value(PARAM_RAW, 'The file type group name'),
+            'selectable' => new external_value(PARAM_BOOL, 'Can it be marked as selected'),
+            'selected' => new external_value(PARAM_BOOL, 'Should it be marked as selected'),
+            'ext' => new external_value(PARAM_RAW, 'The list of file extensions associated with the group'),
+            'expanded' => new external_value(PARAM_BOOL, 'Should the group start as expanded or collapsed'),
+            'types' => new external_multiple_structure($type, 'List of file types in the group'),
+        ]);
+
+        return new external_single_structure([
+            'groups' => new external_multiple_structure($group, 'List of file type groups'),
+        ]);
+    }
+}
diff --git a/lib/form/classes/filetypes_util.php b/lib/form/classes/filetypes_util.php
new file mode 100644 (file)
index 0000000..4eefc07
--- /dev/null
@@ -0,0 +1,516 @@
+<?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/>.
+
+/**
+ * Provides the {@link core_form\filetypes_util} class.
+ *
+ * @package     core_form
+ * @copyright   2017 David Mudrák <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_form;
+
+use core_collator;
+use core_filetypes;
+use core_text;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Utility class for handling with file types in the forms.
+ *
+ * This class is supposed to serve as a helper class for {@link MoodleQuickForm_filetypes}
+ * and {@link admin_setting_filetypes} classes.
+ *
+ * The file types can be specified in a syntax compatible with what filepicker
+ * and filemanager support via the "accepted_types" option: a list of extensions
+ * (e.g. ".doc"), mimetypes ("image/png") or groups ("audio").
+ *
+ * @copyright 2017 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class filetypes_util {
+
+    /** @var array Cache of all file type groups for the {@link self::get_groups_info()}. */
+    protected $cachegroups = null;
+
+    /**
+     * Converts the argument into an array (list) of file types.
+     *
+     * The list can be separated by whitespace, end of lines, commas, colons and semicolons.
+     * Empty values are not returned. Values are converted to lowercase.
+     * Duplicates are removed. Glob evaluation is not supported.
+     *
+     * The return value can be used as the accepted_types option for the filepicker.
+     *
+     * @param string|array $types List of file extensions, groups or mimetypes
+     * @return array of strings
+     */
+    public function normalize_file_types($types) {
+
+        if ($types === '') {
+            return [];
+        }
+
+        // Turn string into a list.
+        if (!is_array($types)) {
+            $types = preg_split('/[\s,;:"\']+/', $types, null, PREG_SPLIT_NO_EMPTY);
+        }
+
+        // Fix whitespace and normalize the syntax a bit.
+        foreach ($types as $i => $type) {
+            $type = str_replace('*.', '.', $type);
+            $type = core_text::strtolower($type);
+            $type = trim($type);
+
+            if ($type === '*') {
+                return ['*'];
+            }
+
+            $types[$i] = $type;
+        }
+
+        // Do not make the user think that globs (like ".doc?") would work.
+        foreach ($types as $i => $type) {
+            if (strpos($type, '*') !== false or strpos($type, '?') !== false) {
+                unset($types[$i]);
+            }
+        }
+
+        foreach ($types as $i => $type) {
+            if (substr($type, 0, 1) === '.') {
+                // It looks like an extension.
+                $type = '.'.ltrim($type, '.');
+                $types[$i] = clean_param($type, PARAM_FILE);
+            } else if ($this->looks_like_mimetype($type)) {
+                // All good, it looks like a mimetype.
+                continue;
+            } else if ($this->is_filetype_group($type)) {
+                // All good, it is a known type group.
+                continue;
+            } else {
+                // We assume the user typed something like "png" so we consider
+                // it an extension.
+                $types[$i] = '.'.$type;
+            }
+        }
+
+        $types = array_filter($types, 'strlen');
+        $types = array_keys(array_flip($types));
+
+        return $types;
+    }
+
+    /**
+     * Does the given file type looks like a valid MIME type?
+     *
+     * This does not check of the MIME type is actually registered here/known.
+     *
+     * @param string $type
+     * @return bool
+     */
+    public function looks_like_mimetype($type) {
+        return (bool)preg_match('~^[-\.a-z0-9]+/[a-z0-9]+([-\.\+][a-z0-9]+)*$~', $type);
+    }
+
+    /**
+     * Is the given string a known filetype group?
+     *
+     * @param string $type
+     * @return bool|object false or the group info
+     */
+    public function is_filetype_group($type) {
+
+        $info = $this->get_groups_info();
+
+        if (isset($info[$type])) {
+            return $info[$type];
+
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Provides a list of all known file type groups and their properties.
+     *
+     * @return array
+     */
+    public function get_groups_info() {
+
+        if ($this->cachegroups !== null) {
+            return $this->cachegroups;
+        }
+
+        $groups = [];
+
+        foreach (core_filetypes::get_types() as $ext => $info) {
+            if (isset($info['groups']) && is_array($info['groups'])) {
+                foreach ($info['groups'] as $group) {
+                    if (!isset($groups[$group])) {
+                        $groups[$group] = (object) [
+                            'extensions' => [],
+                            'mimetypes' => [],
+                        ];
+                    }
+                    $groups[$group]->extensions['.'.$ext] = true;
+                    if (isset($info['type'])) {
+                        $groups[$group]->mimetypes[$info['type']] = true;
+                    }
+                }
+            }
+        }
+
+        foreach ($groups as $group => $info) {
+            $info->extensions = array_keys($info->extensions);
+            $info->mimetypes = array_keys($info->mimetypes);
+        }
+
+        $this->cachegroups = $groups;
+        return $this->cachegroups;
+    }
+
+    /**
+     * Return a human readable name of the filetype group.
+     *
+     * @param string $group
+     * @return string
+     */
+    public function get_group_description($group) {
+
+        if (get_string_manager()->string_exists('group:'.$group, 'core_mimetypes')) {
+            return get_string('group:'.$group, 'core_mimetypes');
+        } else {
+            return s($group);
+        }
+    }
+
+    /**
+     * Describe the list of file types for human user.
+     *
+     * Given the list of file types, return a list of human readable
+     * descriptive names of relevant groups, types or file formats.
+     *
+     * @param string|array $types
+     * @return object
+     */
+    public function describe_file_types($types) {
+
+        $descriptions = [];
+        $types = $this->normalize_file_types($types);
+
+        foreach ($types as $type) {
+            if ($type === '*') {
+                $desc = get_string('filetypesany', 'core_form');
+                $descriptions[$desc] = [];
+            } else if ($group = $this->is_filetype_group($type)) {
+                $desc = $this->get_group_description($type);
+                $descriptions[$desc] = $group->extensions;
+
+            } else if ($this->looks_like_mimetype($type)) {
+                $desc = get_mimetype_description($type);
+                $descriptions[$desc] = file_get_typegroup('extension', [$type]);
+
+            } else {
+                $desc = get_mimetype_description(['filename' => 'fakefile'.$type]);
+                if (isset($descriptions[$desc])) {
+                    $descriptions[$desc][] = $type;
+                } else {
+                    $descriptions[$desc] = [$type];
+                }
+            }
+        }
+
+        $data = [];
+
+        foreach ($descriptions as $desc => $exts) {
+            sort($exts);
+            $data[] = (object)[
+                'description' => $desc,
+                'extensions' => join(' ', $exts),
+            ];
+        }
+
+        core_collator::asort_objects_by_property($data, 'description', core_collator::SORT_NATURAL);
+
+        return (object)[
+            'hasdescriptions' => !empty($data),
+            'descriptions' => array_values($data),
+        ];
+    }
+
+    /**
+     * Prepares data for the filetypes-browser.mustache
+     *
+     * @param string|array $onlytypes Allow selection from these file types only; for example 'web_image'.
+     * @param bool $allowall Allow to select 'All file types'. Does not apply with onlytypes are set.
+     * @param string|array $current Current values that should be selected.
+     * @return object
+     */
+    public function data_for_browser($onlytypes=null, $allowall=true, $current=null) {
+
+        $groups = [];
+        $current = $this->normalize_file_types($current);
+
+        // Firstly populate the tree of extensions categorized into groups.
+
+        foreach ($this->get_groups_info() as $groupkey => $groupinfo) {
+            if (empty($groupinfo->extensions)) {
+                continue;
+            }
+
+            $group = (object) [
+                'key' => $groupkey,
+                'name' => $this->get_group_description($groupkey),
+                'selectable' => true,
+                'selected' => in_array($groupkey, $current),
+                'ext' => implode(' ', $groupinfo->extensions),
+                'expanded' => false,
+            ];
+
+            $types = [];
+
+            foreach ($groupinfo->extensions as $extension) {
+                if ($onlytypes && !$this->is_whitelisted($extension, $onlytypes)) {
+                    $group->selectable = false;
+                    $group->expanded = true;
+                    $group->ext = '';
+                    continue;
+                }
+
+                $desc = get_mimetype_description(['filename' => 'fakefile'.$extension]);
+
+                if ($selected = in_array($extension, $current)) {
+                    $group->expanded = true;
+                }
+
+                $types[] = (object) [
+                    'key' => $extension,
+                    'name' => get_mimetype_description(['filename' => 'fakefile'.$extension]),
+                    'selected' => $selected,
+                    'ext' => $extension,
+                ];
+            }
+
+            if (empty($types)) {
+                continue;
+            }
+
+            core_collator::asort_objects_by_property($types, 'name', core_collator::SORT_NATURAL);
+
+            $group->types = array_values($types);
+            $groups[] = $group;
+        }
+
+        core_collator::asort_objects_by_property($groups, 'name', core_collator::SORT_NATURAL);
+
+        // Append all other uncategorized extensions.
+
+        $others = [];
+
+        foreach (core_filetypes::get_types() as $extension => $info) {
+            $extension = '.'.$extension;
+            if ($onlytypes && !$this->is_whitelisted($extension, $onlytypes)) {
+                continue;
+            }
+            if (!isset($info['groups']) || empty($info['groups'])) {
+                $others[] = (object) [
+                    'key' => $extension,
+                    'name' => get_mimetype_description(['filename' => 'fakefile'.$extension]),
+                    'selected' => in_array($extension, $current),
+                    'ext' => $extension,
+                ];
+            }
+        }
+
+        core_collator::asort_objects_by_property($others, 'name', core_collator::SORT_NATURAL);
+
+        if (!empty($others)) {
+            $groups[] = (object) [
+                'key' => '',
+                'name' => get_string('filetypesothers', 'core_form'),
+                'selectable' => false,
+                'selected' => false,
+                'ext' => '',
+                'types' => array_values($others),
+                'expanded' => true,
+            ];
+        }
+
+        if (empty($onlytypes) and $allowall) {
+            array_unshift($groups, (object) [
+                'key' => '*',
+                'name' => get_string('filetypesany', 'core_form'),
+                'selectable' => true,
+                'selected' => in_array('*', $current),
+                'ext' => null,
+                'types' => [],
+                'expanded' => false,
+            ]);
+        }
+
+        $groups = array_values($groups);
+
+        return $groups;
+    }
+
+    /**
+     * Expands the file types into the list of file extensions.
+     *
+     * The groups and mimetypes are expanded into the list of their associated file
+     * extensions. Depending on the $keepgroups and $keepmimetypes, the groups
+     * and mimetypes themselves are either kept in the list or removed.
+     *
+     * @param string|array $types
+     * @param bool $keepgroups Keep the group item in the list after expansion
+     * @param bool $keepmimetypes Keep the mimetype item in the list after expansion
+     * @return array list of extensions and eventually groups and types
+     */
+    public function expand($types, $keepgroups=false, $keepmimetypes=false) {
+
+        $expanded = [];
+
+        foreach ($this->normalize_file_types($types) as $type) {
+            if ($group = $this->is_filetype_group($type)) {
+                foreach ($group->extensions as $ext) {
+                    $expanded[$ext] = true;
+                }
+                if ($keepgroups) {
+                    $expanded[$type] = true;
+                }
+
+            } else if ($this->looks_like_mimetype($type)) {
+                // A mime type expands to the associated extensions.
+                foreach (file_get_typegroup('extension', [$type]) as $ext) {
+                    $expanded[$ext] = true;
+                }
+                if ($keepmimetypes) {
+                    $expanded[$type] = true;
+                }
+
+            } else {
+                // Single extension expands to itself.
+                $expanded[$type] = true;
+            }
+        }
+
+        return array_keys($expanded);
+    }
+
+    /**
+     * Should the given file type be considered as a part of the given whitelist.
+     *
+     * If multiple types are provided, all of them must be part of the
+     * whitelist. Empty type is part of any whitelist. Any type is part of an
+     * empty whitelist.
+     *
+     * @param string|array $types File types to be checked
+     * @param string|array $whitelist An array or string of whitelisted types
+     * @return boolean
+     */
+    public function is_whitelisted($types, $whitelist) {
+        return empty($this->get_not_whitelisted($types, $whitelist));
+    }
+
+    /**
+     * Returns all types that are not part of the give whitelist.
+     *
+     * This is similar check to the {@link self::is_whitelisted()} but this one
+     * actually returns the extra types.
+     *
+     * @param string|array $types File types to be checked
+     * @param string|array $whitelist An array or string of whitelisted types
+     * @return array Types not present in the whitelist
+     */
+    public function get_not_whitelisted($types, $whitelist) {
+
+        $whitelistedtypes = $this->expand($whitelist, true, true);
+
+        if (empty($whitelistedtypes) || $whitelistedtypes == ['*']) {
+            return [];
+        }
+
+        $giventypes = $this->normalize_file_types($types);
+
+        if (empty($giventypes)) {
+            return [];
+        }
+
+        return array_diff($giventypes, $whitelistedtypes);
+    }
+
+    /**
+     * Is the given filename of an allowed file type?
+     *
+     * Empty whitelist is interpretted as "any file type is allowed" rather
+     * than "no file can be uploaded".
+     *
+     * @param string $filename the file name
+     * @param string|array $whitelist list of allowed file extensions
+     * @return boolean True if the file type is allowed, false if not
+     */
+    public function is_allowed_file_type($filename, $whitelist) {
+
+        $allowedextensions = $this->expand($whitelist);
+
+        if (empty($allowedextensions) || $allowedextensions == ['*']) {
+            return true;
+        }
+
+        $haystack = strrev(trim(core_text::strtolower($filename)));
+
+        foreach ($allowedextensions as $extension) {
+            if (strpos($haystack, strrev($extension)) === 0) {
+                // The file name ends with the extension.
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Returns file types from the list that are not recognized
+     *
+     * @param string|array $types list of user-defined file types
+     * @return array A list of unknown file types.
+     */
+    public function get_unknown_file_types($types) {
+        $unknown = [];
+
+        foreach ($this->normalize_file_types($types) as $type) {
+            if ($this->is_filetype_group($type)) {
+                // The type is a group that exists.
+                continue;
+            } else if ($this->looks_like_mimetype($type)) {
+                // If there's no extension associated with that mimetype, we consider it unknown.
+                if (empty(file_get_typegroup('extension', [$type]))) {
+                    $unknown[$type] = true;
+                }
+            } else {
+                $coretypes = core_filetypes::get_types();
+                $typecleaned = str_replace(".", "", $type);
+                if (empty($coretypes[$typecleaned])) {
+                    // If there's no extension, it doesn't exist.
+                    $unknown[$type] = true;
+                }
+            }
+        }
+
+        return array_keys($unknown);
+    }
+}
diff --git a/lib/form/filetypes.php b/lib/form/filetypes.php
new file mode 100644 (file)
index 0000000..025f128
--- /dev/null
@@ -0,0 +1,203 @@
+<?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/>.
+
+/**
+ * Provides the {@link MoodleQuickForm_filetypes} class.
+ *
+ * @package   core_form
+ * @copyright 2016 Jonathon Fowler <fowlerj@usq.edu.au>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use core_form\filetypes_util;
+
+defined('MOODLE_INTERNAL') || die;
+
+global $CFG;
+require_once($CFG->dirroot.'/lib/form/group.php');
+
+/**
+ * File types and type groups selection form element.
+ *
+ * @package   core_form
+ * @category  form
+ * @copyright 2016 Jonathon Fowler <fowlerj@usq.edu.au>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class MoodleQuickForm_filetypes extends MoodleQuickForm_group {
+
+    /** @var array Allow selection from these file types only. */
+    protected $onlytypes = [];
+
+    /** @var bool Allow selection of 'All file types' (will be stored as '*'). */
+    protected $allowall = true;
+
+    /** @var core_form\filetypes_util instance to use as a helper. */
+    protected $util = null;
+
+    /**
+     * Constructor
+     *
+     * @param string $elementname Element's name
+     * @param string $elementlabel Label(s) for an element
+     * @param array $options element options:
+     *   'onlytypes': Allow selection from these file types only; for example ['onlytypes' => ['web_image']].
+     *   'allowall': Allow to select 'All file types', defaults to true. Does not apply with onlytypes are set.
+     * @param array|string $attributes Either a typical HTML attribute string or an associative array
+     */
+    public function __construct($elementname = null, $elementlabel = null, $options = null, $attributes = null) {
+
+        parent::__construct($elementname, $elementlabel);
+        $this->_type = 'filetypes';
+
+        // Hard-frozen elements do not get the name populated automatically,
+        // which leads to PHP notice. Add it explicitly here.
+        $this->setAttributes(array('name' => $elementname));
+        $this->updateAttributes($attributes);
+
+        if (is_array($options) && $options) {
+            if (array_key_exists('onlytypes', $options) && is_array($options['onlytypes'])) {
+                $this->onlytypes = $options['onlytypes'];
+            }
+            if (!$this->onlytypes && array_key_exists('allowall', $options)) {
+                $this->allowall = (bool)$options['allowall'];
+            }
+        }
+
+        $this->util = new filetypes_util();
+    }
+
+    /**
+     * Assemble the elements of the form control.
+     */
+    public function _createElements() {
+
+        $this->_generateId();
+
+        $this->setElements([
+            $this->createFormElement('text', 'filetypes', '', [
+                'id' => $this->getAttribute('id'),
+            ]),
+
+            $this->createFormElement('static', 'browser', null,
+                '<span data-filetypesbrowser="'.$this->getAttribute('id').'"></span>'),
+
+            $this->createFormElement('static', 'descriptions', null,
+                '<div data-filetypesdescriptions="'.$this->getAttribute('id').'"></div>')
+        ]);
+    }
+
+    /**
+     * Return the selected file types.
+     *
+     * @param array $submitted submitted values
+     * @param bool $assoc if true the retured value is associated array
+     * @return array
+     */
+    public function exportValue(&$submitted, $assoc = false) {
+
+        $value = '';
+        $filetypeselement = null;
+
+        foreach ($this->_elements as $key => $element) {
+            if ($element->_attributes['name'] === 'filetypes') {
+                $filetypeselement = $this->_elements[$key];
+            }
+        }
+
+        if ($filetypeselement) {
+            $formval = $filetypeselement->exportValue($submitted[$this->getName()], false);
+            if ($formval) {
+                $value = $this->util->normalize_file_types($formval);
+                if ($value === ['*'] && !$this->allowall) {
+                    $value = [];
+                }
+                $value = implode(',', $value);
+            }
+        }
+
+        return $this->_prepareValue($value, $assoc);
+    }
+
+    /**
+     * Accepts a renderer (called shortly before the renderer's toHtml() method).
+     *
+     * @param HTML_QuickForm_Renderer $renderer An HTML_QuickForm_Renderer object
+     * @param bool $required Whether a group is required
+     * @param string $error An error message associated with a group
+     */
+    public function accept(&$renderer, $required = false, $error = null) {
+        global $PAGE;
+
+        $PAGE->requires->js_call_amd('core_form/filetypes', 'init', [
+            $this->getAttribute('id'),
+            $this->getLabel(),
+            $this->onlytypes,
+            $this->allowall,
+        ]);
+
+        if ($this->isFrozen()) {
+            // Don't render the choose button if the control is frozen.
+            foreach ($this->_elements as $key => $element) {
+                if ($element->_attributes['name'] === 'browser') {
+                    unset($this->_elements[$key]);
+                }
+            }
+        }
+
+        parent::accept($renderer, $required, $error);
+    }
+
+    /**
+     * Called by HTML_QuickForm whenever form event is made on this element
+     *
+     * @param string $event Name of event
+     * @param mixed $arg event arguments
+     * @param object $caller calling object
+     * @return bool
+     */
+    public function onQuickFormEvent($event, $arg, &$caller) {
+        global $OUTPUT;
+
+        switch ($event) {
+            case 'updateValue':
+                $value = $this->_findValue($caller->_constantValues);
+                if (null === $value) {
+                    if ($caller->isSubmitted()) {
+                        $value = $this->_findValue($caller->_submitValues);
+                    } else {
+                        $value = (string)$this->_findValue($caller->_defaultValues);
+                    }
+                }
+                if (!is_array($value)) {
+                    $value = array('filetypes' => $value);
+                }
+                if ($value['filetypes'] !== null) {
+                    $filetypes = $this->util->normalize_file_types($value['filetypes']);
+                    if ($filetypes === ['*'] && !$this->allowall) {
+                        $filetypes = [];
+                    }
+                    $value['descriptions'] = '<div data-filetypesdescriptions="'.$this->getAttribute('id').'">' .
+                        $OUTPUT->render_from_template('core_form/filetypes-descriptions',
+                            $this->util->describe_file_types($filetypes)).'</div>';
+                }
+                $this->setValue($value);
+                return true;
+        }
+
+        return parent::onQuickFormEvent($event, $arg, $caller);
+    }
+}
diff --git a/lib/form/templates/filetypes-browser.mustache b/lib/form/templates/filetypes-browser.mustache
new file mode 100644 (file)
index 0000000..acfd680
--- /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/>.
+}}
+{{!
+    @template core_form/filetypes-browser
+
+    The template is used to render the content of the UI widget allowing the
+    user to select individual file types or their groups.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * groups
+        * key
+        * name
+        * selectable
+        * expanded
+        * ext
+        * types
+            * key
+            * name
+            * selected
+            * expanded
+            * ext
+
+    Example context (json):
+    {
+        "groups": [
+            {
+                "key": "web_file",
+                "name": "Web files",
+                "selectable": true,
+                "selected": false,
+                "ext": ".css .html .xhtml .htm .js",
+                "types": [
+                    {
+                        "key": ".css",
+                        "name": "Cascading Style-Sheet",
+                        "selected": true,
+                        "ext": ".css"
+                    },
+                    {
+                        "key": ".htm",
+                        "name": "HTML text",
+                        "selected": false,
+                        "ext": ".htm .xhtml .html"
+                    }
+                ]
+            },
+            {
+                "key": "web_audio",
+                "name": "Web-safe audio files",
+                "selectable": false,
+                "selected": false,
+                "ext": null,
+                "types": [
+                    {
+                        "key": "audio/mp3",
+                        "name": "Audio file (MP3)",
+                        "selected": false,
+                        "ext": ".mp3"
+                    }
+                ]
+            }
+        ]
+    }
+}}
+<div data-filetypesbrowserbody="{{elementid}}" role="tree">
+{{#groups}}
+    <div data-filetypesbrowserkey="{{key}}" role="treeitem" aria-expanded="{{#expanded}}true{{/expanded}}{{^expanded}}false{{/expanded}}">
+        <label>
+            {{#selectable}}
+            <input data-filetypesbrowserkey="{{key}}" type="checkbox" {{#selected}}checked{{/selected}}>
+            <strong data-filetypesname="{{key}}">{{name}}</strong>
+            <small class="muted" data-filetypesextensions="{{key}}">
+                {{ext}}
+            </small>
+            {{/selectable}}
+            {{^selectable}}
+            <strong>{{name}}</strong>
+            {{/selectable}}
+        </label>
+        {{#types.0}}
+        {{! The tree provides its own accessibility controls, these two links are here for mouse users.
+            For that reason, we hide them from assistive technologies. }}
+        <small aria-hidden="true" data-filetypesbrowserfeature="hideifexpanded" class="pull-right float-right"><a href="#">{{#str}}expand{{/str}}</a></small>
+        <small aria-hidden="true" data-filetypesbrowserfeature="hideifcollapsed" class="pull-right float-right"><a href="#">{{#str}}collapse{{/str}}</a></small>
+        {{/types.0}}
+        <ul class="unstyled list-unstyled" role="group">
+            {{#types}}
+            <li data-filetypesbrowserkey="{{key}}" style="margin-left: 2em" role="treeitem">
+                <label>
+                    <input data-filetypesbrowserkey="{{key}}" type="checkbox" {{#selected}}checked{{/selected}}>
+                    <span data-filetypesname="{{key}}">{{name}}</span>
+                    <small class="muted" data-filetypesextensions="{{key}}">
+                        {{ext}}
+                    </small>
+                </label>
+            </li>
+            {{/types}}
+        </ul>
+        <hr style="clear:both">
+    </div>
+{{/groups}}
+</div>
diff --git a/lib/form/templates/filetypes-descriptions.mustache b/lib/form/templates/filetypes-descriptions.mustache
new file mode 100644 (file)
index 0000000..affe8e6
--- /dev/null
@@ -0,0 +1,60 @@
+{{!
+    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_form/filetypes-descriptions
+
+    Template to describe chosen file types.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * hasdescriptions (bool)
+    * descriptions (array)
+        * description (string)
+        * extensions (string)
+
+    Example context (json):
+    {
+        "hasdescriptions": true,
+        "descriptions": [
+            {
+                "description": "Image (JPEG)",
+                "extensions": ".jpeg .jpe .jpg"
+            },
+            {
+                "description": "Image (GIF)",
+                "extensions": ".gif"
+            }
+        ]
+    }
+}}
+<div class="form-filetypes-descriptions">
+{{#hasdescriptions}}
+    <ul class="list-unstyled unstyled">
+        {{#descriptions}}
+        <li>{{description}} <small class="text-muted muted">{{extensions}}</small></li>
+        {{/descriptions}}
+    </ul>
+{{/hasdescriptions}}
+{{^hasdescriptions}}
+    <p>{{#str}}noselection, form{{/str}}</p>
+{{/hasdescriptions}}
+</div>
diff --git a/lib/form/templates/filetypes-trigger.mustache b/lib/form/templates/filetypes-trigger.mustache
new file mode 100644 (file)
index 0000000..7103b89
--- /dev/null
@@ -0,0 +1,39 @@
+{{!
+    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_form/filetypes-trigger
+
+    Renders the button to open the file types browser element.
+
+    This template uses <input> element of the type "button" as that one seems
+    to be well styled by default (especially when compared to alternatives like
+    <button> that are not aligned nicely with the input field.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * filetypeswidget - set to "browsertrigger" to mark the actual clickable element
+
+    Context variables required for this template:
+    * none
+
+    Example context (json):
+    {
+    }
+}}
+<input type="button" class="btn btn-secondary" data-filetypeswidget="browsertrigger" value="{{#str}} choose, core {{/str}}" />
diff --git a/lib/form/tests/external_test.php b/lib/form/tests/external_test.php
new file mode 100644 (file)
index 0000000..46643e8
--- /dev/null
@@ -0,0 +1,57 @@
+<?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/>.
+
+/**
+ * Provides the {@link core_form\external_testcase} class.
+ *
+ * @package     core_form
+ * @category    test
+ * @copyright   2017 David Mudrák <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_form;
+
+use advanced_testcase;
+use external_api;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+/**
+ * Test cases for the {@link core_form\external} class.
+ *
+ * @copyright 2017 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class external_testcase extends advanced_testcase {
+
+    /**
+     * Test the core_form_get_filetypes_browser_data external function
+     */
+    public function test_get_filetypes_browser_data() {
+
+        $data = external::get_filetypes_browser_data('', true, '');
+        $data = external_api::clean_returnvalue(external::get_filetypes_browser_data_returns(), $data);
+        $data = json_decode(json_encode($data));
+
+        // The actual data are tested in filetypes_util_test.php, here we just
+        // make sure that the external function wrapper seems to work.
+        $this->assertInternalType('object', $data);
+        $this->assertInternalType('array', $data->groups);
+    }
+}
diff --git a/lib/form/tests/filetypes_util_test.php b/lib/form/tests/filetypes_util_test.php
new file mode 100644 (file)
index 0000000..d5419cd
--- /dev/null
@@ -0,0 +1,464 @@
+<?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/>.
+
+/**
+ * Provides the {@link core_form\filetypes_util_testcase} class.
+ *
+ * @package     core_form
+ * @category    test
+ * @copyright   2017 David Mudrák <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_form;
+
+use advanced_testcase;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+/**
+ * Test cases for the {@link core_form\filetypes_util} class.
+ *
+ * @copyright 2017 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class filetypes_util_testcase extends advanced_testcase {
+
+    /**
+     * Test normalizing list of extensions.
+     */
+    public function test_normalize_file_types() {
+
+        $this->resetAfterTest(true);
+        $util = new filetypes_util();
+
+        $this->assertSame(['.odt'], $util->normalize_file_types('.odt'));
+        $this->assertSame(['.odt'], $util->normalize_file_types('odt'));
+        $this->assertSame(['.odt'], $util->normalize_file_types('.ODT'));
+        $this->assertSame(['.doc', '.jpg', '.mp3'], $util->normalize_file_types('doc, jpg, mp3'));
+        $this->assertSame(['.doc', '.jpg', '.mp3'], $util->normalize_file_types(['.doc', '.jpg', '.mp3']));
+        $this->assertSame(['.doc', '.jpg', '.mp3'], $util->normalize_file_types('doc, *.jpg, mp3'));
+        $this->assertSame(['.doc', '.jpg', '.mp3'], $util->normalize_file_types(['doc ', ' JPG ', '.mp3']));
+        $this->assertSame(['.rtf', '.pdf', '.docx'],
+            $util->normalize_file_types("RTF,.pdf\n...DocX,,,;\rPDF\trtf ...Rtf"));
+        $this->assertSame(['.tgz', '.tar.gz'], $util->normalize_file_types('tgz,TAR.GZ tar.gz .tar.gz tgz TGZ'));
+        $this->assertSame(['.notebook'], $util->normalize_file_types('"Notebook":notebook;NOTEBOOK;,\'NoTeBook\''));
+        $this->assertSame([], $util->normalize_file_types(''));
+        $this->assertSame([], $util->normalize_file_types([]));
+        $this->assertSame(['.0'], $util->normalize_file_types(0));
+        $this->assertSame(['.0'], $util->normalize_file_types('0'));
+        $this->assertSame(['.odt'], $util->normalize_file_types('*.odt'));
+        $this->assertSame([], $util->normalize_file_types('.'));
+        $this->assertSame(['.foo'], $util->normalize_file_types('. foo'));
+        $this->assertSame(['*'], $util->normalize_file_types('*'));
+        $this->assertSame([], $util->normalize_file_types('*~'));
+        $this->assertSame(['.pdf', '.ps'], $util->normalize_file_types('pdf *.ps foo* *bar .r??'));
+        $this->assertSame(['*'], $util->normalize_file_types('pdf *.ps foo* * *bar .r??'));
+    }
+
+    /**
+     * Test MIME type formal recognition.
+     */
+    public function test_looks_like_mimetype() {
+
+        $this->resetAfterTest(true);
+        $util = new filetypes_util();
+
+        $this->assertTrue($util->looks_like_mimetype('type/subtype'));
+        $this->assertTrue($util->looks_like_mimetype('type/x-subtype'));
+        $this->assertTrue($util->looks_like_mimetype('type/x-subtype+xml'));
+        $this->assertTrue($util->looks_like_mimetype('type/vnd.subtype.xml'));
+        $this->assertTrue($util->looks_like_mimetype('type/vnd.subtype+xml'));
+
+        $this->assertFalse($util->looks_like_mimetype('.gif'));
+        $this->assertFalse($util->looks_like_mimetype('audio'));
+        $this->assertFalse($util->looks_like_mimetype('foo/bar/baz'));
+    }
+
+    /**
+     * Test getting/checking group.
+     */
+    public function test_is_filetype_group() {
+
+        $this->resetAfterTest(true);
+        $util = new filetypes_util();
+
+        $audio = $util->is_filetype_group('audio');
+        $this->assertNotFalse($audio);
+        $this->assertInternalType('array', $audio->extensions);
+        $this->assertInternalType('array', $audio->mimetypes);
+
+        $this->assertFalse($util->is_filetype_group('.gif'));
+        $this->assertFalse($util->is_filetype_group('somethingveryunlikelytoeverexist'));
+    }
+
+
+    /**
+     * Test describing list of extensions.
+     */
+    public function test_describe_file_types() {
+
+        $this->resetAfterTest(true);
+        $util = new filetypes_util();
+
+        force_current_language('en');
+
+        // Check that it is able to describe individual file extensions.
+        $desc = $util->describe_file_types('jpg .jpeg *.jpe PNG;.gif,  mudrd8mz');
+        $this->assertTrue($desc->hasdescriptions);
+
+        $desc = $desc->descriptions;
+        $this->assertEquals(4, count($desc));
+
+        $this->assertEquals('File', $desc[0]->description);
+        $this->assertEquals('.mudrd8mz', $desc[0]->extensions);
+
+        $this->assertEquals('Image (JPEG)', $desc[2]->description);
+        $this->assertContains('.jpg', $desc[2]->extensions);
+        $this->assertContains('.jpeg', $desc[2]->extensions);
+        $this->assertContains('.jpe', $desc[2]->extensions);
+
+        // Check that it can describe groups and mimetypes too.
+        $desc = $util->describe_file_types('audio text/plain');
+        $this->assertTrue($desc->hasdescriptions);
+
+        $desc = $desc->descriptions;
+        $this->assertEquals(2, count($desc));
+
+        $this->assertEquals('Audio files', $desc[0]->description);
+        $this->assertContains('.mp3', $desc[0]->extensions);
+        $this->assertContains('.wav', $desc[0]->extensions);
+        $this->assertContains('.ogg', $desc[0]->extensions);
+
+        $this->assertEquals('Text file', $desc[1]->description);
+        $this->assertContains('.txt', $desc[1]->extensions);
+
+        // Empty.
+        $desc = $util->describe_file_types('');
+        $this->assertFalse($desc->hasdescriptions);
+        $this->assertEmpty($desc->descriptions);
+
+        // Any.
+        $desc = $util->describe_file_types('*');
+        $this->assertTrue($desc->hasdescriptions);
+        $this->assertNotEmpty($desc->descriptions[0]->description);
+        $this->assertEmpty($desc->descriptions[0]->extensions);
+
+        // Unknown mimetype.
+        $desc = $util->describe_file_types('application/x-something-really-unlikely-ever-exist');
+        $this->assertTrue($desc->hasdescriptions);
+        $this->assertEquals('application/x-something-really-unlikely-ever-exist', $desc->descriptions[0]->description);
+        $this->assertEmpty($desc->descriptions[0]->extensions);
+    }
+
+    /**
+     * Test expanding mime types into extensions.
+     */
+    public function test_expand() {
+
+        $this->resetAfterTest(true);
+        $util = new filetypes_util();
+
+        $this->assertSame([], $util->expand(''));
+
+        $expanded = $util->expand('document .cdr text/plain');
+        $this->assertNotContains('document', $expanded);
+        $this->assertNotContains('text/plain', $expanded);
+        $this->assertContains('.doc', $expanded);
+        $this->assertContains('.odt', $expanded);
+        $this->assertContains('.txt', $expanded);
+        $this->assertContains('.cdr', $expanded);
+
+        $expanded = $util->expand('document .cdr text/plain', true, false);
+        $this->assertContains('document', $expanded);
+        $this->assertNotContains('text/plain', $expanded);
+        $this->assertContains('.doc', $expanded);
+        $this->assertContains('.odt', $expanded);
+        $this->assertContains('.txt', $expanded);
+        $this->assertContains('.cdr', $expanded);
+
+        $expanded = $util->expand('document .cdr text/plain', false, true);
+        $this->assertNotContains('document', $expanded);
+        $this->assertContains('text/plain', $expanded);
+        $this->assertContains('.doc', $expanded);
+        $this->assertContains('.odt', $expanded);
+        $this->assertContains('.txt', $expanded);
+        $this->assertContains('.cdr', $expanded);
+
+        $this->assertSame([], $util->expand('foo/bar', true, false));
+        $this->assertSame(['foo/bar'], $util->expand('foo/bar', true, true));
+    }
+
+    /**
+     * Test checking that a type is among others.
+     */
+    public function test_is_whitelisted() {
+
+        $this->resetAfterTest(true);
+        $util = new filetypes_util();
+
+        // These should be intuitively true.
+        $this->assertTrue($util->is_whitelisted('txt', 'text/plain'));
+        $this->assertTrue($util->is_whitelisted('txt', 'doc txt rtf'));
+        $this->assertTrue($util->is_whitelisted('.txt', '.doc;.txt;.rtf'));
+        $this->assertTrue($util->is_whitelisted('audio', 'text/plain audio video'));
+        $this->assertTrue($util->is_whitelisted('text/plain', 'text/plain audio video'));
+        $this->assertTrue($util->is_whitelisted('jpg jpe jpeg', 'image/jpeg'));
+        $this->assertTrue($util->is_whitelisted(['jpg', 'jpe', '.png'], 'image'));
+
+        // These should be intuitively false.
+        $this->assertFalse($util->is_whitelisted('.gif', 'text/plain'));
+
+        // Not all text/plain formats are in the document group.
+        $this->assertFalse($util->is_whitelisted('text/plain', 'document'));
+
+        // Not all documents (and also the group itself) is not a plain text.
+        $this->assertFalse($util->is_whitelisted('document', 'text/plain'));
+
+        // This may look wrong at the first sight as you might expect that the
+        // mimetype should simply map to an extension ...
+        $this->assertFalse($util->is_whitelisted('image/jpeg', '.jpg'));
+
+        // But it is principally same situation as this (there is no 1:1 mapping).
+        $this->assertFalse($util->is_whitelisted('.c', '.txt'));
+        $this->assertTrue($util->is_whitelisted('.txt .c', 'text/plain'));
+        $this->assertFalse($util->is_whitelisted('text/plain', '.c'));
+
+        // Any type is included if the filter is empty.
+        $this->assertTrue($util->is_whitelisted('txt', ''));
+        $this->assertTrue($util->is_whitelisted('txt', '*'));
+
+        // Empty value is part of any whitelist.
+        $this->assertTrue($util->is_whitelisted('', '.txt'));
+    }
+
+    /**
+     * Test getting types not present in a whitelist.
+     */
+    public function test_get_not_whitelisted() {
+
+        $this->resetAfterTest(true);
+        $util = new filetypes_util();
+
+        $this->assertEmpty($util->get_not_whitelisted('txt', 'text/plain'));
+        $this->assertEmpty($util->get_not_whitelisted('txt', '.doc .txt .rtf'));
+        $this->assertEmpty($util->get_not_whitelisted('txt', 'text/plain'));
+        $this->assertEmpty($util->get_not_whitelisted(['jpg', 'jpe', 'jpeg'], 'image/jpeg'));
+        $this->assertEmpty($util->get_not_whitelisted('', 'foo/bar'));
+        $this->assertEmpty($util->get_not_whitelisted('.foobar', ''));
+        $this->assertEmpty($util->get_not_whitelisted('.foobar', '*'));
+
+        // Returned list is normalized so extensions have the dot added.
+        $this->assertContains('.exe', $util->get_not_whitelisted('exe', '.c .h'));
+
+        // If this looks wrong to you, see {@link test_is_whitelisted()} for more details on this behaviour.
+        $this->assertContains('image/jpeg', $util->get_not_whitelisted('image/jpeg', '.jpg .jpeg'));
+    }
+
+    /**
+     * Test populating the tree for the browser.
+     */
+    public function test_data_for_browser() {
+
+        $this->resetAfterTest(true);
+        $util = new filetypes_util();
+
+        $data = $util->data_for_browser();
+        $this->assertContainsOnly('object', $data);
+        foreach ($data as $group) {
+            $this->assertObjectHasAttribute('key', $group);
+            $this->assertObjectHasAttribute('types', $group);
+            if ($group->key !== '') {
+                $this->assertTrue($group->selectable);
+            }
+        }
+
+        // All these three files are in both "image" and also "web_image"
+        // groups. We display both groups.
+        $data = $util->data_for_browser('jpg png gif', true, '.gif');
+        $this->assertEquals(2, count($data));
+        $this->assertTrue($data[0]->key !== $data[1]->key);
+        foreach ($data as $group) {
+            $this->assertTrue(($group->key === 'image' || $group->key === 'web_image'));
+            $this->assertEquals(3, count($group->types));
+            $this->assertFalse($group->selectable);
+            foreach ($group->types as $ext) {
+                if ($ext->key === '.gif') {
+                    $this->assertTrue($ext->selected);
+                } else {
+                    $this->assertFalse($ext->selected);
+                }
+            }
+        }
+
+        // There is a group web_image which is a subset of the group image. The
+        // file extensions that fall into both groups will be displayed twice.
+        $data = $util->data_for_browser('web_image');
+        foreach ($data as $group) {
+            $this->assertTrue(($group->key === 'image' || $group->key === 'web_image'));
+        }
+
+        // Check that "All file types" are displayed first.
+        $data = $util->data_for_browser();
+        $group = array_shift($data);
+        $this->assertEquals('*', $group->key);
+
+        // Check that "All file types" is not displayed if should not.
+        $data = $util->data_for_browser(null, false);
+        $group = array_shift($data);
+        $this->assertNotEquals('*', $group->key);
+
+        // Groups with an extension selected start expanded. The "Other files"
+        // starts expanded. The rest start collapsed.
+        $data = $util->data_for_browser(null, false, '.png');
+        foreach ($data as $group) {
+            if ($group->key === 'document') {
+                $this->assertfalse($group->expanded);
+            } else if ($group->key === '') {
+                $this->assertTrue($group->expanded);
+            }
+            foreach ($group->types as $ext) {
+                foreach ($group->types as $ext) {
+                    if ($ext->key === '.png') {
+                        $this->assertTrue($ext->selected);
+                        $this->assertTrue($group->expanded);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Data provider for testing test_is_allowed_file_type.
+     *
+     * @return array
+     */
+    public function is_allowed_file_type_provider() {
+        return [
+            'Filetype not in extension whitelist' => [
+                'filename' => 'test.xml',
+                'whitelist' => '.png .jpg',
+                'expected' => false
+            ],
+            'Filetype not in mimetype whitelist' => [
+                'filename' => 'test.xml',
+                'whitelist' => 'image/png',
+                'expected' => false
+            ],
+            'Filetype not in group whitelist' => [
+                'filename' => 'test.xml',
+                'whitelist' => 'web_file',
+                'expected' => false
+            ],
+            'Filetype in whitelist as extension' => [
+                'filename' => 'test.xml',
+                'whitelist' => 'xml',
+                'expected' => true
+            ],
+            'Empty whitelist should allow all' => [
+                'filename' => 'test.xml',
+                'whitelist' => '',
+                'expected' => true
+            ],
+            'Filetype in whitelist but later on' => [
+                'filename' => 'test.xml',
+                'whitelist' => 'gif;jpeg,image/png xml xlsx',
+                'expected' => true
+            ],
+            'Filetype in whitelist as mimetype' => [
+                'filename' => 'test.xml',
+                'whitelist' => 'image/png application/xml',
+                'expected' => true
+            ],
+            'Filetype in whitelist as group' => [
+                'filename' => 'test.html',
+                'whitelist' => 'video,web_file',
+                'expected' => true
+            ],
+        ];
+    }
+
+    /**
+     * Test is_allowed_file_type().
+     * @dataProvider is_allowed_file_type_provider
+     * @param string $filename The filename to check
+     * @param string $whitelist The space , or ; separated list of types supported
+     * @param boolean $expected The expected result. True if the file is allowed, false if not.
+     */
+    public function test_is_allowed_file_type($filename, $whitelist, $expected) {
+        $util = new filetypes_util();
+        $this->assertSame($expected, $util->is_allowed_file_type($filename, $whitelist));
+    }
+
+    /**
+     * Data provider for testing test_get_unknown_file_types.
+     *
+     * @return array
+     */
+    public function get_unknown_file_types_provider() {
+        return [
+            'Unknown extension' => [
+                'filetypes' => '.rat',
+                'expected' => ['.rat']
+            ],
+            'Multiple unknown extensions' => [
+                'filetypes' => '.ricefield .rat',
+                'expected' => ['.ricefield', '.rat']
+            ],
+            'Existant extension' => [
+                'filetypes' => '.xml',
+                'expected' => []
+            ],
+            'Existant group' => [
+                'filetypes' => 'web_file',
+                'expected' => []
+            ],
+            'Nonexistant mimetypes' => [
+                'filetypes' => 'ricefield/rat',
+                'expected' => ['ricefield/rat']
+            ],
+            'Existant mimetype' => [
+                'filetypes' => 'application/xml',
+                'expected' => []
+            ],
+            'Multiple unknown mimetypes' => [
+                'filetypes' => 'ricefield/rat cam/ball',
+                'expected' => ['ricefield/rat', 'cam/ball']
+            ],
+            'Strange characters in unknown extension/group' => [
+                'filetypes' => '©ç√√ß∂å√©åß©√',
+                'expected' => ['.©ç√√ß∂å√©åß©√']
+            ],
+            'Some existant some not' => [
+                'filetypes' => '.txt application/xml web_file ©ç√√ß∂å√©åß©√ .png ricefield/rat document',
+                'expected' => ['.©ç√√ß∂å√©åß©√', 'ricefield/rat']
+            ],
+        ];
+    }
+
+    /**
+     * Test get_unknown_file_types().
+     * @dataProvider get_unknown_file_types_provider
+     * @param string $filetypes The filetypes to check
+     * @param array $expected The expected result. The list of non existant file types.
+     */
+    public function test_get_unknown_file_types($filetypes, $expected) {
+        $util = new filetypes_util();
+        $this->assertSame($expected, $util->get_unknown_file_types($filetypes));
+    }
+}
index e2c8b8e..cce38da 100644 (file)
@@ -3105,6 +3105,7 @@ MoodleQuickForm::registerElementType('duration', "$CFG->libdir/form/duration.php
 MoodleQuickForm::registerElementType('editor', "$CFG->libdir/form/editor.php", 'MoodleQuickForm_editor');
 MoodleQuickForm::registerElementType('filemanager', "$CFG->libdir/form/filemanager.php", 'MoodleQuickForm_filemanager');
 MoodleQuickForm::registerElementType('filepicker', "$CFG->libdir/form/filepicker.php", 'MoodleQuickForm_filepicker');
+MoodleQuickForm::registerElementType('filetypes', "$CFG->libdir/form/filetypes.php", 'MoodleQuickForm_filetypes');
 MoodleQuickForm::registerElementType('grading', "$CFG->libdir/form/grading.php", 'MoodleQuickForm_grading');
 MoodleQuickForm::registerElementType('group', "$CFG->libdir/form/group.php", 'MoodleQuickForm_group');
 MoodleQuickForm::registerElementType('header', "$CFG->libdir/form/header.php", 'MoodleQuickForm_header');
index 7406646..0aae68d 100644 (file)
@@ -4294,7 +4294,7 @@ EOD;
         $context->cookieshelpiconformatted = $this->help_icon('cookiesenabled');
         $context->errorformatted = $this->error_text($context->error);
 
-        return $this->render_from_template('core/login', $context);
+        return $this->render_from_template('core/loginform', $context);
     }
 
     /**
index 409b796..65ea2f0 100644 (file)
@@ -265,7 +265,7 @@ class Parser {
                        if (preg_match('/[0-9a-fA-F]/Su', $this->peek()) === 0) {
                                return $this->consume(1);
                        }
-                       $sUnicode = $this->consumeExpression('/^[0-9a-fA-F]{1,6}/u');
+                       $sUnicode = $this->consumeExpression('/^[0-9a-fA-F]{1,6}/u', 6);
                        if ($this->strlen($sUnicode) < 6) {
                                //Consume whitespace after incomplete unicode escape
                                if (preg_match('/\\s/isSu', $this->peek())) {
@@ -565,9 +565,10 @@ class Parser {
                }
        }
 
-       private function consumeExpression($mExpression) {
+       private function consumeExpression($mExpression, $iMaxLength = null) {
                $aMatches = null;
-               if (preg_match($mExpression, $this->inputLeft(), $aMatches, PREG_OFFSET_CAPTURE) === 1) {
+               $sInput = $iMaxLength !== null ? $this->peek($iMaxLength) : $this->inputLeft();
+               if (preg_match($mExpression, $sInput, $aMatches, PREG_OFFSET_CAPTURE) === 1) {
                        return $this->consume($aMatches[0][0]);
                }
                throw new UnexpectedTokenException($mExpression, $this->peek(5), 'expression', $this->iLineNo);
index 16b02e2..f63dd81 100644 (file)
@@ -1,7 +1,7 @@
 PHP CSS Parser
 --------------
 
-Import git ref: 50a802f562e71236140e2a8903b097c28d6101de
+Import git ref: c3b01ef0a85824e86fd86a74a8154d8d5c34b0ff
                 (master)
 
 Downloaded from: https://github.com/sabberworm/PHP-CSS-Parser
similarity index 99%
rename from lib/templates/login.mustache
rename to lib/templates/loginform.mustache
index a0545c3..1d65ea1 100644 (file)
@@ -15,7 +15,7 @@
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @template core/login
+    @template core/loginform
 
     Moodle template for the login page.
 
index 359ece3..b9c32ad 100644 (file)
@@ -57,12 +57,8 @@ class core_accesslib_testcase extends advanced_testcase {
         $this->setAdminUser();
         load_all_capabilities();
 
-        $this->assertNotEmpty($ACCESSLIB_PRIVATE->rolepermissions);
-        $this->assertNotEmpty($ACCESSLIB_PRIVATE->rolepermissions);
         $this->assertNotEmpty($ACCESSLIB_PRIVATE->accessdatabyuser);
         accesslib_clear_all_caches_for_unit_testing();
-        $this->assertEmpty($ACCESSLIB_PRIVATE->rolepermissions);
-        $this->assertEmpty($ACCESSLIB_PRIVATE->rolepermissions);
         $this->assertEmpty($ACCESSLIB_PRIVATE->dirtycontexts);
         $this->assertEmpty($ACCESSLIB_PRIVATE->accessdatabyuser);
     }
@@ -93,9 +89,9 @@ class core_accesslib_testcase extends advanced_testcase {
 
             $this->assertTrue(is_array($access));
             $this->assertTrue(is_array($access['ra']));
-            $this->assertTrue(is_array($access['rdef']));
-            $this->assertTrue(isset($access['rdef_count']));
-            $this->assertTrue(is_array($access['loaded']));
+            $this->assertFalse(isset($access['rdef']));
+            $this->assertFalse(isset($access['rdef_count']));
+            $this->assertFalse(isset($access['loaded']));
             $this->assertTrue(isset($access['time']));
             $this->assertTrue(is_array($access['rsw']));
         }
diff --git a/lib/tests/output_external_test.php b/lib/tests/output_external_test.php
new file mode 100644 (file)
index 0000000..35acb97
--- /dev/null
@@ -0,0 +1,147 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for lib/classes/output/external.php
+ * @author    Guy Thomas <gthomas@moodlerooms.com>
+ * @copyright Copyright (c) 2017 Blackboard Inc.
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\output\external;
+
+require_once(__DIR__.'/../../lib/externallib.php');
+require_once(__DIR__.'/../../lib/mustache/src/Mustache/Tokenizer.php');
+require_once(__DIR__.'/../../lib/mustache/src/Mustache/Parser.php');
+
+/**
+ * Class core_output_external_testcase - test \core\output\external class.
+ * @package   core
+ * @author    Guy Thomas <gthomas@moodlerooms.com>
+ * @copyright Copyright (c) 2017 Blackboard Inc.
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_output_external_testcase extends base_testcase {
+
+    /**
+     * Ensure that stripping comments from templates does not mutilate the template body.
+     */
+    public function test_strip_template_comments() {
+
+        $templatebody = <<<'TBD'
+        <h1>{{# str }} pluginname, mod_lemmings {{/ str }}</h1>
+        <div>{{test}}</div>
+        <div>{{{unescapedtest}}}</div>
+        {{#lemmings}}
+            <div>
+                <h2>{{name}}</h2>
+                {{> mod_lemmings/lemmingprofile }}
+                {{# pix }} t/edit, core, Edit Lemming {{/ pix }}
+            </div>
+        {{/lemmings}}
+        {{^lemmings}}Sorry, no lemmings today{{/lemmings}}
+        <div id="{{ uniqid }}-tab-container">
+            {{# tabheader }}
+                <ul role="tablist" class="nav nav-tabs">
+                    {{# iconlist }}
+                        {{# icons }}
+                            {{> core/pix_icon }}
+                        {{/ icons }}
+                    {{/ iconlist }}
+                </ul>
+            {{/ tabheader }}
+            {{# tabbody }}
+                <div class="tab-content">
+                    {{# tabcontent }}
+                        {{# tabs }}
+                            {{> core/notification_info}}
+                        {{/ tabs }}
+                    {{/ tabcontent }}
+                </div>
+            {{/ tabbody }}
+        </div>
+        {{#js}}
+            require(['jquery','core/tabs'], function($, tabs) {
+
+                var container = $("#{{ uniqid }}-tab-container");
+                tabs.create(container);
+            });
+        {{/js}}
+TBD;
+        $templatewithcomment = <<<TBC
+        {{!
+            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 mod_lemmings/lemmings
+
+            Lemmings template.
+
+            The purpose of this template is to render a lot of lemmings.
+
+            Classes required for JS:
+            * none
+
+            Data attributes required for JS:
+            * none
+
+            Context variables required for this template:
+            * attributes Array of name / value pairs.
+
+            Example context (json):
+            {
+                "lemmings": [
+                    { "name": "Lemmy Winks", "age" : 1, "size" : "big" },
+                    { "name": "Rocky", "age" : 2, "size" : "small" }
+                ]
+            }
+
+        }}
+        $templatebody
+        {{!
+            Here's some more comment text
+            Note, there is no need to test bracketed variables inside comments as gherkin does not support that!
+            See this issue: https://github.com/mustache/spec/issues/8
+        }}
+TBC;
+
+        // Ensure that the template when stripped of comments just includes the body.
+        $stripped = phpunit_util::call_internal_method(null, 'strip_template_comments',
+                [$templatewithcomment], 'core\output\external');
+        $this->assertEquals(trim($templatebody), trim($stripped));
+
+        $tokenizer = new Mustache_Tokenizer();
+        $tokens = $tokenizer->scan($templatebody);
+        $parser = new Mustache_Parser();
+        $tree = $parser->parse($tokens);
+        $this->assertNotEmpty($tree);
+    }
+}
index c923bfb..09c2839 100644 (file)
@@ -1,6 +1,16 @@
 This files describes API changes in core libraries and APIs,
 information provided here is intended especially for developers.
 
+=== 3.4 ===
+
+* Added new moodleform element 'filetypes' and new admin setting widget 'admin_setting_filetypes'. These new widgets
+  allow users to define a list of file types; either by typing them manually or selecting them from a list. The widgets
+  directly support the syntax used to feed the 'accepted_types' option of the filemanager and filepicker elements. File
+  types can be specified as extensions (.jpg or just jpg), mime types (text/plain) or groups (image).
+* Removed accesslib private functions: load_course_context(), load_role_access_by_context(), dedupe_user_access() (MDL-49398).
+* Internal "accessdata" structure format has changed to improve ability to perform role definition caching (MDL-49398).
+* Role definitions are no longer cached in user session (MDL-49398).
+
 === 3.3.1 ===
 
 * ldap_get_entries_moodle() now always returns lower-cased attribute names in the returned entries.
index fc81446..cf4f53b 100644 (file)
@@ -137,6 +137,54 @@ class plugin_misplaced_exception extends moodle_exception {
     }
 }
 
+/**
+ * Static class monitors performance of upgrade steps.
+ */
+class core_upgrade_time {
+    /** @var float Time at start of current upgrade (plugin/system) */
+    protected static $before;
+    /** @var float Time at end of last savepoint */
+    protected static $lastsavepoint;
+
+    /**
+     * Records current time at the start of the current upgrade item, e.g. plugin.
+     */
+    public static function record_start() {
+        self::$before = microtime(true);
+        self::$lastsavepoint = self::$before;
+    }
+
+    /**
+     * Records current time at the end of a given numbered step.
+     *
+     * @param float $version Version number (may have decimals, or not)
+     */
+    public static function record_savepoint($version) {
+        global $CFG, $OUTPUT;
+
+        // In developer debug mode we show a notification after each individual save point.
+        if ($CFG->debugdeveloper) {
+            $time = microtime(true);
+
+            $notification = new \core\output\notification($version . ': ' .
+                    get_string('successduration', '', format_float($time - self::$lastsavepoint, 2)),
+                    \core\output\notification::NOTIFY_SUCCESS);
+            $notification->set_show_closebutton(false);
+            echo $OUTPUT->render($notification);
+            self::$lastsavepoint = $time;
+        }
+    }
+
+    /**
+     * Gets the time since the record_start function was called, rounded to 2 digits.
+     *
+     * @return float Elapsed time
+     */
+    public static function get_elapsed() {
+        return microtime(true) - self::$before;
+    }
+}
+
 /**
  * Sets maximum expected time needed for upgrade task.
  * Please always make sure that upgrade will not run longer!
@@ -224,6 +272,8 @@ function upgrade_main_savepoint($result, $version, $allowabort=true) {
     // reset upgrade timeout to default
     upgrade_set_timeout();
 
+    core_upgrade_time::record_savepoint($version);
+
     // this is a safe place to stop upgrades if user aborts page loading
     if ($allowabort and connection_aborted()) {
         die;
@@ -268,6 +318,8 @@ function upgrade_mod_savepoint($result, $version, $modname, $allowabort=true) {
     // reset upgrade timeout to default
     upgrade_set_timeout();
 
+    core_upgrade_time::record_savepoint($version);
+
     // this is a safe place to stop upgrades if user aborts page loading
     if ($allowabort and connection_aborted()) {
         die;
@@ -312,6 +364,8 @@ function upgrade_block_savepoint($result, $version, $blockname, $allowabort=true
     // reset upgrade timeout to default
     upgrade_set_timeout();
 
+    core_upgrade_time::record_savepoint($version);
+
     // this is a safe place to stop upgrades if user aborts page loading
     if ($allowabort and connection_aborted()) {
         die;
@@ -352,6 +406,8 @@ function upgrade_plugin_savepoint($result, $version, $type, $plugin, $allowabort
     // Reset upgrade timeout to default
     upgrade_set_timeout();
 
+    core_upgrade_time::record_savepoint($version);
+
     // This is a safe place to stop upgrades if user aborts page loading
     if ($allowabort and connection_aborted()) {
         die;
@@ -1501,6 +1557,7 @@ function print_upgrade_part_start($plugin, $installation, $verbose) {
             echo $OUTPUT->heading($plugin);
         }
     }
+    core_upgrade_time::record_start();
     if ($installation) {
         if (empty($plugin) or $plugin == 'moodle') {
             // no need to log - log table not yet there ;-)
@@ -1538,7 +1595,10 @@ function print_upgrade_part_end($plugin, $installation, $verbose) {
         }
     }
     if ($verbose) {
-        $notification = new \core\output\notification(get_string('success'), \core\output\notification::NOTIFY_SUCCESS);
+        $duration = core_upgrade_time::get_elapsed();
+        $notification = new \core\output\notification(
+                get_string('successduration', '', format_float($duration, 2)),
+                \core\output\notification::NOTIFY_SUCCESS);
         $notification->set_show_closebutton(false);
         echo $OUTPUT->render($notification);
         print_upgrade_separator();
index d9bbeb6..9a6fab9 100644 (file)
Binary files a/message/amd/build/message_area_messages.min.js and b/message/amd/build/message_area_messages.min.js differ
index 587f42c..fcaaef7 100644 (file)
Binary files a/message/amd/build/message_area_search.min.js and b/message/amd/build/message_area_search.min.js differ
index 1015e13..4c5711b 100644 (file)
@@ -455,9 +455,8 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/cust
                     }
                 });
             });
-
             if (requests.length > 0) {
-                Ajax.call(requests)[requests.length - 1].then(function() {
+                $.when(Ajax.call(requests)).then(function() {
                     // Store the last message on the page, and the last message being deleted.
                     var updatemessage = null;
                     var messages = this.messageArea.find(SELECTORS.MESSAGE);
@@ -491,7 +490,7 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/cust
 
                     // Trigger event letting other modules know messages were deleted.
                     this.messageArea.trigger(Events.MESSAGESDELETED, [this._getUserId(), updatemessage]);
-                }.bind(this)Notification.exception);
+                }.bind(this)).catch(Notification.exception);
             } else {
                 // Trigger event letting other modules know messages were deleted.
                 this.messageArea.trigger(Events.MESSAGESDELETED, this._getUserId());
@@ -526,49 +525,47 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/cust
          * @private
          */
         Messages.prototype._deleteAllMessages = function() {
-            // Create the confirmation modal if we haven't already.
-            if (!this._confirmationModal) {
-                Str.get_strings([
-                    {key: 'confirm'},
-                    {key: 'deleteallconfirm', component: 'message'}
-                ]).done(function(s) {
-                    ModalFactory.create({
-                        title: s[0],
-                        type: ModalFactory.types.CONFIRM,
-                        body: s[1]
-                    }, this.messageArea.find(SELECTORS.DELETEALLMESSAGES))
-                        .done(function(modal) {
-                            this._confirmationModal = modal;
-
-                            // Only delete the conversation if the user agreed in the confirmation modal.
-                            modal.getRoot().on(ModalEvents.yes, function() {
-                                var otherUserId = this._getUserId();
-                                var request = {
-                                    methodname: 'core_message_delete_conversation',
-                                    args: {
-                                        userid: this.messageArea.getCurrentUserId(),
-                                        otheruserid: otherUserId
-                                    }
-                                };
-
-                                // Delete the conversation.
-                                Ajax.call([request])[0].then(function() {
-                                    // Clear the message area.
-                                    this.messageArea.find(SELECTORS.MESSAGESAREA).empty();
-                                    // Let the app know a conversation was deleted.
-                                    this.messageArea.trigger(Events.CONVERSATIONDELETED, otherUserId);
-                                    this._hideDeleteAction();
-                                }.bind(this), Notification.exception);
-                            }.bind(this));
-
-                            // Display the confirmation.
-                            modal.show();
-                        }.bind(this));
-                }.bind(this));
-            } else {
-                // Otherwise just show the existing modal.
+            if (this._confirmationModal) {
+                // Just show the existing modal.
                 this._confirmationModal.show();
+                return;
             }
+
+            Str.get_strings([
+                {key: 'confirm'},
+                {key: 'deleteallconfirm', component: 'message'}
+            ]).then(function(s) {
+                return ModalFactory.create({
+                    title: s[0],
+                    type: ModalFactory.types.CONFIRM,
+                    body: s[1]
+                }, this.messageArea.find(SELECTORS.DELETEALLMESSAGES));
+            }.bind(this)).then(function(modal) {
+                this._confirmationModal = modal;
+                // Only delete the conversation if the user agreed in the confirmation modal.
+                modal.getRoot().on(ModalEvents.yes, function() {
+                    var otherUserId = this._getUserId();
+                    var request = {
+                        methodname: 'core_message_delete_conversation',
+                        args: {
+                            userid: this.messageArea.getCurrentUserId(),
+                            otheruserid: otherUserId
+                        }
+                    };
+
+                    // Delete the conversation.
+                    Ajax.call([request])[0].then(function() {
+                        // Clear the message area.
+                        this.messageArea.find(SELECTORS.MESSAGESAREA).empty();
+                        // Let the app know a conversation was deleted.
+                        this.messageArea.trigger(Events.CONVERSATIONDELETED, otherUserId);
+                        this._hideDeleteAction();
+                    }.bind(this)).catch(Notification.exception);
+                }.bind(this));
+
+                // Display the confirmation.
+                modal.show();
+            }.bind(this)).catch(Notification.exception);
         };
 
         /**
index 584f4f8..46960d7 100644 (file)
@@ -385,7 +385,7 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
         this.messageArea.find(SELECTORS.SEARCHFILTER).html(text);
         Str.get_string('removecoursefilter', 'message', text).then(function(languagestring) {
             this.messageArea.find(SELECTORS.SEARCHFILTERAREA).attr('aria-label', languagestring);
-        }.bind(this));
+        }.bind(this)).catch(Notification.exception);
     };
 
     /**
index 5bd300e..313d911 100644 (file)
Binary files a/message/output/popup/amd/build/message_popover_controller.min.js and b/message/output/popup/amd/build/message_popover_controller.min.js differ
index ee21e2f..0361c24 100644 (file)
Binary files a/message/output/popup/amd/build/notification_area_control_area.min.js and b/message/output/popup/amd/build/notification_area_control_area.min.js differ
index b0ea994..dadaa7f 100644 (file)
Binary files a/message/output/popup/amd/build/notification_popover_controller.min.js and b/message/output/popup/amd/build/notification_popover_controller.min.js differ
index 8787b28..5699698 100644 (file)
@@ -151,7 +151,7 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/str',
             this.unreadCount = count;
             this.renderUnreadCount();
             this.updateButtonAriaLabel();
-        }.bind(this));
+        }.bind(this)).catch(Notification.exception);
     };
 
     /**
@@ -165,38 +165,27 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/str',
      */
     MessagePopoverController.prototype.renderMessages = function(messages, container) {
         var promises = [];
-        var allhtml = [];
-        var alljs = [];
-
-        if (messages.length) {
-            $.each(messages, function(index, message) {
-                message.contexturl = URL.relativeUrl('/message/index.php', {
-                    user: this.userId,
-                    id: message.userid,
-                });
-
-                message.profileurl = URL.relativeUrl('/user/profile.php', {
-                    id: message.userid,
-                });
-
-                var promise = Templates.render('message_popup/message_content_item', message);
-                promises.push(promise);
-
-                promise.then(function(html, js) {
-                    allhtml[index] = html;
-                    alljs[index] = js;
-                });
-            }.bind(this));
-        }
 
-        return $.when.apply($.when, promises).then(function() {
-            if (messages.length) {
-                $.each(messages, function(index) {
-                    container.append(allhtml[index]);
-                    Templates.runTemplateJS(alljs[index]);
-                });
-            }
-        });
+        $.each(messages, function(index, message) {
+            message.contexturl = URL.relativeUrl('/message/index.php', {
+                user: this.userId,
+                id: message.userid,
+            });
+
+            message.profileurl = URL.relativeUrl('/user/profile.php', {
+                id: message.userid,
+            });
+
+            var promise = Templates.render('message_popup/message_content_item', message)
+            .then(function(html, js) {
+                container.append(html);
+                Templates.runTemplateJS(js);
+                return;
+            });
+            promises.push(promise);
+        }.bind(this));
+
+        return $.when.apply($, promises);
     };
 
     /**
index ee19a0e..5feea45 100644 (file)
@@ -307,39 +307,27 @@ define(['jquery', 'core/templates', 'core/notification', 'core/custom_interactio
      */
     ControlArea.prototype.renderNotifications = function(notifications) {
         var promises = [];
-        var allhtml = [];
-        var alljs = [];
         var container = this.getContent();
 
-        if (notifications.length) {
-            $.each(notifications, function(index, notification) {
-                // Need to remove the contexturl so the item isn't rendered
-                // as a link.
-                var contextUrl = notification.contexturl;
-                delete notification.contexturl;
-
-                var promise = Templates.render(TEMPLATES.NOTIFICATION, notification);
-
-                promises.push(promise);
-                promise.then(function(html, js) {
-                    allhtml[index] = html;
-                    alljs[index] = js;
-                    // Restore it for the cache.
-                    notification.contexturl = contextUrl;
-                    this.setCacheNotification(notification);
-                }.bind(this))
-                .fail(DebugNotification.exception);
+        $.each(notifications, function(index, notification) {
+            // Need to remove the contexturl so the item isn't rendered
+            // as a link.
+            var contextUrl = notification.contexturl;
+            delete notification.contexturl;
+
+            var promise = Templates.render(TEMPLATES.NOTIFICATION, notification)
+            .then(function(html, js) {
+                container.append(html);
+                Templates.runTemplateJS(js);
+                // Restore it for the cache.
+                notification.contexturl = contextUrl;
+                this.setCacheNotification(notification);
+                return;
             }.bind(this));
-        }
+            promises.push(promise);
+        }.bind(this));
 
-        return $.when.apply($.when, promises).then(function() {
-            if (notifications.length) {
-                $.each(notifications, function(index) {
-                    container.append(allhtml[index]);
-                    Templates.runTemplateJS(alljs[index]);
-                });
-            }
-        });
+        return $.when.apply($, promises);
     };
 
     /**
index 4036b70..2d5a2c4 100644 (file)
@@ -200,7 +200,7 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/str', 'core/url',
             this.unreadCount = count;
             this.renderUnreadCount();
             this.updateButtonAriaLabel();
-        }.bind(this));
+        }.bind(this)).catch(DebugNotification.exception);
     };
 
     /**
@@ -226,39 +226,27 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/str', 'core/url',
      */
     NotificationPopoverController.prototype.renderNotifications = function(notifications, container) {
         var promises = [];
-        var allhtml = [];
-        var alljs = [];
-
-        if (notifications.length) {
-            $.each(notifications, function(index, notification) {
-                // Determine what the offset was when loading this notification.
-                var offset = this.getOffset() - this.limit;
-                // Update the view more url to contain the offset to allow the notifications
-                // page to load to the correct position in the list of notifications.
-                notification.viewmoreurl = URL.relativeUrl('/message/output/popup/notifications.php', {
-                    notificationid: notification.id,
-                    offset: offset,
-                });
-
-                var promise = Templates.render('message_popup/notification_content_item', notification);
-                promises.push(promise);
-
-                promise.then(function(html, js) {
-                    allhtml[index] = html;
-                    alljs[index] = js;
-                })
-                .fail(DebugNotification.exception);
-            }.bind(this));
-        }
 
-        return $.when.apply($.when, promises).then(function() {
-            if (notifications.length) {
-                $.each(notifications, function(index) {
-                    container.append(allhtml[index]);
-                    Templates.runTemplateJS(alljs[index]);
-                });
-            }
-        });
+        $.each(notifications, function(index, notification) {
+            // Determine what the offset was when loading this notification.
+            var offset = this.getOffset() - this.limit;
+            // Update the view more url to contain the offset to allow the notifications
+            // page to load to the correct position in the list of notifications.
+            notification.viewmoreurl = URL.relativeUrl('/message/output/popup/notifications.php', {
+                notificationid: notification.id,
+                offset: offset,
+            });
+
+            var promise = Templates.render('message_popup/notification_content_item', notification)
+            .then(function(html, js) {
+                container.append(html);
+                Templates.runTemplateJS(js);
+                return;
+            });
+            promises.push(promise);
+        }.bind(this));
+
+        return $.when.apply($, promises);
     };
 
     /**
index f1d055e..1323c77 100644 (file)
@@ -107,6 +107,7 @@ define(['core/ajax', 'jquery', 'core/templates'], function(ajax, $, templates) {
                 }
 
                 success(users);
+                return;
             }).catch(failure);
         }
     };
index 24af320..fd5ca49 100644 (file)
@@ -183,7 +183,7 @@ if ($mform->is_cancelled()) {
         unset($fromform->id);
         $fromform->id = $DB->insert_record('assign_overrides', $fromform);
         if ($groupmode) {
-            $fromform->sortorder = $fromform->id;
+            $fromform->sortorder = 1;
 
             $overridecountgroup = $DB->count_records('assign_overrides',
                 array('userid' => null, 'assignid' => $assigninstance->id));
@@ -196,7 +196,7 @@ if ($mform->is_cancelled()) {
             }
 
             $DB->update_record('assign_overrides', $fromform);
-
+            reorder_group_overrides($assigninstance->id);
         }
 
         // Determine which override created event to fire.
index 252b247..d9da1db 100644 (file)
@@ -4,7 +4,7 @@ Feature: Set entries required as a completion condition for a data item
   As a teacher
   I need to set entries required to mark the database activity as completed
 
-Scenario: Two entries required to complete the activity
+  Scenario: Two entries required to complete the activity
     Given the following "users" exist:
       | username | firstname | lastname | email |
       | student1 | Student | 1 | student1@example.com |
index dfa1bb9..0312415 100644 (file)
Binary files a/mod/lti/amd/build/contentitem.min.js and b/mod/lti/amd/build/contentitem.min.js differ
index 8a09aa8..177c7db 100644 (file)
Binary files a/mod/lti/amd/build/tool_card_controller.min.js and b/mod/lti/amd/build/tool_card_controller.min.js differ
index 42e9808..a6dad92 100644 (file)
@@ -46,42 +46,41 @@ define(
              * @param {object} postData The data to be sent for the content item selection request.
              */
             init: function(url, postData) {
-                var dialogueTitle = '';
-                str.get_string('selectcontent', 'lti').then(function(title) {
-                    dialogueTitle = title;
-                    var context = {
-                        url: url,
-                        postData: postData
-                    };
+                var context = {
+                    url: url,
+                    postData: postData
+                };
+                var bodyPromise = templates.render('mod_lti/contentitem', context);
 
-                    var body = templates.render('mod_lti/contentitem', context);
-                    if (dialogue) {
-                        // Set dialogue body.
-                        dialogue.setBody(body);
-                        // Display the dialogue.
-                        dialogue.show();
-                    } else {
-                        ModalFactory.create({
-                            title: dialogueTitle,
-                            body: body,
-                            large: true
-                        }).done(function(modal) {
-                            dialogue = modal;
+                if (dialogue) {
+                    // Set dialogue body.
+                    dialogue.setBody(bodyPromise);
+                    // Display the dialogue.
+                    dialogue.show();
+                    return;
+                }
 
-                            // Display the dialogue.
-                            dialogue.show();
+                str.get_string('selectcontent', 'lti').then(function(title) {
+                    return ModalFactory.create({
+                        title: title,
+                        body: bodyPromise,
+                        large: true
+                    });
+                }).then(function(modal) {
+                    dialogue = modal;
+                    // On hide handler.
+                    modal.getRoot().on(ModalEvents.hidden, function() {
+                        // Empty modal contents when it's hidden.
+                        modal.setBody('');
 
-                            // On hide handler.
-                            modal.getRoot().on(ModalEvents.hidden, function() {
-                                // Empty modal contents when it's hidden.
-                                modal.setBody('');
+                        // Fetch notifications.
+                        notification.fetchNotifications();
+                    });
 
-                                // Fetch notifications.
-                                notification.fetchNotifications();
-                            });
-                        });
-                    }
-                });
+                    // Display the dialogue.
+                    modal.show();
+                    return;
+                }).catch(notification.exception);
             }
         };
 
index 99042db..4050f72 100644 (file)
@@ -477,21 +477,19 @@ define(['jquery', 'core/ajax', 'core/notification', 'core/templates', 'mod_lti/t
             state: toolType.constants.state.configured
         });
 
-        promise.done(function(toolTypeData) {
+        promise.then(function(toolTypeData) {
             stopLoading(element);
-
-            var announcePromise = announceSuccess(element);
-            var renderPromise = templates.render('mod_lti/tool_card', toolTypeData);
-
-            $.when(renderPromise, announcePromise).then(function(renderResult) {
-                var html = renderResult[0];
-                var js = renderResult[1];
-
-                templates.replaceNode(element, html, js);
-            });
-        });
-
-        promise.fail(function() {
+            announceSuccess(element);
+            return toolTypeData;
+        }).then(function(toolTypeData) {
+            return templates.render('mod_lti/tool_card', toolTypeData);
+        }).then(function(renderResult) {
+            var html = renderResult[0];
+            var js = renderResult[1];
+
+            templates.replaceNode(element, html, js);
+            return;
+        }).catch(function() {
             stopLoading(element);
             announceFailure(element);
         });
index 984123e..7d5c9be 100644 (file)
@@ -167,4 +167,3 @@ Feature: Edit quiz page - remove multiple questions
 
     When I click on "Deselect all" "link"
     Then the field "selectquestion-3" matches value "0"
-
index 56bf846..5991756 100644 (file)
Binary files a/mod/survey/amd/build/validation.min.js and b/mod/survey/amd/build/validation.min.js differ
index 75880c2..5439e68 100644 (file)
@@ -49,11 +49,10 @@ define(['jquery', 'core/str', 'core/modal_factory', 'core/notification'], functi
                 if (form.find('input:radio[data-survey-default="true"]:checked').length !== 0) {
                     e.preventDefault();
                     // Display the modal error.
-                    modalPromise.then(function(modal) {
+                    return modalPromise.then(function(modal) {
                         modal.show();
-                        return;
+                        return false;
                     });
-                    return false;
                 }
 
                 return true;
index 3792266..19a7c66 100644 (file)
       "integrity": "sha1-f6qEWZ4P6kIvBLwy20kFQFGj8Ro=",
       "dev": true
     },
+    "eslint-plugin-promise": {
+      "version": "3.5.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-3.5.0.tgz",
+      "integrity": "sha1-ePu2/+BHIBYnVp6FpsU3OvKmj8o=",
+      "dev": true
+    },
     "espree": {
       "version": "3.4.3",
       "resolved": "https://registry.npmjs.org/espree/-/espree-3.4.3.tgz",
index 2a7d142..0ee1295 100644 (file)
@@ -5,6 +5,7 @@
   "devDependencies": {
     "async": "1.5.2",
     "eslint": "3.7.1",
+    "eslint-plugin-promise": "3.5.0",
     "gherkin-lint": "1.1.3",
     "grunt": "1.0.1",
     "grunt-contrib-less": "1.3.0",
index 154c490..280076e 100644 (file)
Binary files a/report/competency/amd/build/grading_popup.min.js and b/report/competency/amd/build/grading_popup.min.js differ
index 13ce49c..20d2160 100644 (file)
@@ -54,18 +54,15 @@ define(['jquery', 'core/notification', 'core/str', 'core/ajax', 'core/log', 'cor
         var requests = ajax.call([{
             methodname: 'tool_lp_data_for_user_competency_summary_in_course',
             args: {userid: userId, competencyid: competencyId, courseid: courseId},
-            done: this._contextLoaded.bind(this),
-            fail: notification.exception
+        }, {
+            methodname: 'core_competency_user_competency_viewed_in_course',
+            args: {userid: userId, competencyid: competencyId, courseid: courseId},
         }]);
 
-        // Log the user competency viewed in course event.
-        requests[0].then(function() {
-            ajax.call([{
-                methodname: 'core_competency_user_competency_viewed_in_course',
-                args: {userid: userId, competencyid: competencyId, courseid: courseId},
-                fail: notification.exception
-            }]);
-        });
+        $.when.apply($, requests).then(function() {
+            this._contextLoaded.bind(this);
+            return;
+        }).catch(notification.exception);
     };
 
     /**
index 4e5835e..e48bc63 100644 (file)
@@ -465,7 +465,7 @@ class core_renderer extends \core_renderer {
         $context->logourl = $url;
         $context->sitename = format_string($SITE->fullname, true, ['context' => context_course::instance(SITEID), "escape" => false]);
 
-        return $this->render_from_template('core/login', $context);
+        return $this->render_from_template('core/loginform', $context);
     }
 
     /**
index b77facc..e967e5f 100644 (file)
@@ -1,10 +1,10 @@
 /* Anchor link offset fix. This makes hash links scroll 60px down to account for the fixed header. */
 $fixed-header-y: $navbar-height;
 
-$drawer-width: 285px;
-$drawer-padding-x: 20px;
-$drawer-padding-y: 20px;
-$drawer-offscreen-gutter: 20px;
+$drawer-width: 285px !default;
+$drawer-padding-x: 20px !default;
+$drawer-padding-y: 20px !default;
+$drawer-offscreen-gutter: 20px !default;
 
 :target {
     padding-top: ($fixed-header-y + 30px) !important; /* stylelint-disable declaration-no-important */
index 899c8b8..e6903f2 100644 (file)
@@ -357,3 +357,12 @@ textarea[data-auto-rows] {
     background-position: center right ($input-height / 4);
     background-size: ($input-height / 2) ($input-height / 2);
 }
+
+// Styles for the JS file types browser provided by the "filetypes" element.
+[data-filetypesbrowserbody] {
+    [aria-expanded="false"] > [role="group"],
+    [aria-expanded="false"] [data-filetypesbrowserfeature="hideifcollapsed"],
+    [aria-expanded="true"] [data-filetypesbrowserfeature="hideifexpanded"] {
+        display: none;
+    }
+}
similarity index 99%
rename from theme/boost/templates/core/login.mustache
rename to theme/boost/templates/core/loginform.mustache
index 90fb24c..65d2139 100644 (file)
@@ -15,7 +15,7 @@
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @template core/login
+    @template core/loginform
 
     Moodle template for the login page.
 
diff --git a/theme/boost/templates/core_form/element-filetypes.mustache b/theme/boost/templates/core_form/element-filetypes.mustache
new file mode 100644 (file)
index 0000000..9a4e5cb
--- /dev/null
@@ -0,0 +1 @@
+{{> core_form/element-group }}
index 64a7c59..6906b66 100644 (file)
@@ -49,6 +49,10 @@ class behat_theme_boost_behat_course extends behat_course {
 
         // Find the menu.
         $menunode = $activitynode->find('css', 'a[data-toggle=dropdown]');
+        if (!$menunode) {
+            throw new ExpectationException(sprintf('Could not find actions menu for the activity "%s"', $activityname),
+                    $this->getSession());
+        }
         $expanded = $menunode->getAttribute('aria-expanded');
         if ($expanded == 'true') {
             return;
@@ -71,6 +75,10 @@ class behat_theme_boost_behat_course extends behat_course {
         $activitynode = $this->get_activity_node($activityname);
         // Find the menu.
         $menunode = $activitynode->find('css', 'a[data-toggle=dropdown]');
+        if (!$menunode) {
+            throw new ExpectationException(sprintf('Could not find actions menu for the activity "%s"', $activityname),
+                    $this->getSession());
+        }
         $expanded = $menunode->getAttribute('aria-expanded');
         if ($expanded != 'true') {
             return;
@@ -90,6 +98,10 @@ class behat_theme_boost_behat_course extends behat_course {
         $activitynode = $this->get_activity_node($activityname);
         // Find the menu.
         $menunode = $activitynode->find('css', 'a[data-toggle=dropdown]');
+        if (!$menunode) {
+            throw new ExpectationException(sprintf('Could not find actions menu for the activity "%s"', $activityname),
+                    $this->getSession());
+        }
         $expanded = $menunode->getAttribute('aria-expanded');
         if ($expanded != 'true') {
             throw new ExpectationException(sprintf("The action menu for '%s' is not open", $activityname), $this->getSession());
index 05a9788..008de8d 100644 (file)
@@ -507,3 +507,12 @@ div[data-passwordunmask="wrapper"] {
     line-height: 30px;
     margin-bottom: 10px;
 }
+
+// Styles for the JS file types browser provided by the "filetypes" element.
+[data-filetypesbrowserbody] {
+    [aria-expanded="false"] > [role="group"],
+    [aria-expanded="false"] [data-filetypesbrowserfeature="hideifcollapsed"],
+    [aria-expanded="true"] [data-filetypesbrowserfeature="hideifexpanded"] {
+        display: none;
+    }
+}
index d8496eb..fa74dbd 100644 (file)
@@ -16969,6 +16969,11 @@ div[data-passwordunmask="wrapper"] {
   line-height: 30px;
   margin-bottom: 10px;
 }
+[data-filetypesbrowserbody] [aria-expanded="false"] > [role="group"],
+[data-filetypesbrowserbody] [aria-expanded="false"] [data-filetypesbrowserfeature="hideifcollapsed"],
+[data-filetypesbrowserbody] [aria-expanded="true"] [data-filetypesbrowserfeature="hideifexpanded"] {
+  display: none;
+}
 body.modal-open {
   overflow: hidden;
 }
index 06a1e92..af4ff66 100644 (file)
@@ -7,6 +7,10 @@ information provided here is intended especially for theme designer.
   setup for this module have been moved from core standard_head_html to the bootstrapbase
   renderer. If your theme needs this javascript you will need to ensure the bootstrap renderer
   is called, or require the JS yourself
+* There was a mustache template login.mustache in /lib/templates/ rendering the login form
+  and a template with the same name in /theme/boost/templates/ rendering the pagelayout "login".
+  To prevent misunderstanding when overriding one of these templates in a Boost child theme,
+  the first one was renamed to loginform.mustache - see MDL-58970.
 
 === 3.2 ===
 
index 34f2f58..78c9275 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2017060100.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2017060600.00;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.