lib/geopattern-php/
lib/php-jwt/
lib/babel-polyfill/
+lib/emoji-data/
media/player/videojs/amd/src/video-lazy.js
media/player/videojs/amd/src/Youtube-lazy.js
media/player/videojs/videojs/
lib/geopattern-php/
lib/php-jwt/
lib/babel-polyfill/
+lib/emoji-data/
media/player/videojs/amd/src/video-lazy.js
media/player/videojs/amd/src/Youtube-lazy.js
media/player/videojs/videojs/
// "courserequests" settingpage.
$temp = new admin_settingpage('courserequest', new lang_string('courserequest'));
- $temp->add(new admin_setting_configcheckbox('enablecourserequests', new lang_string('enablecourserequests', 'admin'), new lang_string('configenablecourserequests', 'admin'), 0));
- $temp->add(new admin_settings_coursecat_select('defaultrequestcategory', new lang_string('defaultrequestcategory', 'admin'), new lang_string('configdefaultrequestcategory', 'admin'), 1));
- $temp->add(new admin_setting_configcheckbox('requestcategoryselection', new lang_string('requestcategoryselection', 'admin'), new lang_string('configrequestcategoryselection', 'admin'), 0));
+ $temp->add(new admin_setting_configcheckbox('enablecourserequests',
+ new lang_string('enablecourserequests', 'admin'),
+ new lang_string('configenablecourserequests', 'admin'), 1));
+ $temp->add(new admin_settings_coursecat_select('defaultrequestcategory',
+ new lang_string('defaultrequestcategory', 'admin'),
+ new lang_string('configdefaultrequestcategory', 'admin'), 1));
+ $temp->add(new admin_setting_configcheckbox('lockrequestcategory',
+ new lang_string('lockrequestcategory', 'admin'),
+ new lang_string('configlockrequestcategory', 'admin'), 0));
$temp->add(new admin_setting_users_with_capability('courserequestnotify', new lang_string('courserequestnotify', 'admin'), new lang_string('configcourserequestnotify2', 'admin'), array(), 'moodle/site:approvecourse'));
$ADMIN->add('courses', $temp);
$optionalsubsystems->add(new admin_setting_configcheckbox('enableanalytics', new lang_string('enableanalytics', 'admin'),
new lang_string('configenableanalytics', 'admin'), 1, 1, 0));
+
+ $fullunicodesupport = true;
+ if ($DB->get_dbfamily() == 'mysql') {
+ $collation = $DB->get_dbcollation();
+ $collationinfo = explode('_', $collation);
+ $charset = reset($collationinfo);
+ $fullunicodesupport = $charset === 'utf8mb4';
+ }
+
+ if ($fullunicodesupport) {
+ $optionalsubsystems->add(new admin_setting_configcheckbox(
+ 'allowemojipicker',
+ new lang_string('allowemojipicker', 'admin'),
+ new lang_string('configallowemojipicker', 'admin'),
+ 1
+ ));
+ } else {
+ $optionalsubsystems->add(new admin_setting_description(
+ 'allowemojipicker',
+ new lang_string('allowemojipicker', 'admin'),
+ new lang_string('configallowemojipickerincompatible', 'admin')
+ ));
+ }
}
--- /dev/null
+// 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/>.
+
+/**
+ * Potential contexts selector module.
+ *
+ * @module tool_analytics/potential-contexts
+ * @class potential-contexts
+ * @package tool_analytics
+ * @copyright 2019 David Monllao
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define(['jquery', 'core/ajax'], function($, Ajax) {
+
+ return /** @alias module:tool_analytics/potential-contexts */ {
+
+ processResults: function(selector, results) {
+ var contexts = [];
+ if ($.isArray(results)) {
+ $.each(results, function(index, context) {
+ contexts.push({
+ value: context.id,
+ label: context.name
+ });
+ });
+ return contexts;
+
+ } else {
+ return results;
+ }
+ },
+
+ transport: function(selector, query, success, failure) {
+ var promise;
+
+ let modelid = $(selector).attr('modelid') || null;
+ promise = Ajax.call([{
+ methodname: 'tool_analytics_potential_contexts',
+ args: {
+ query: query,
+ modelid: modelid
+ }
+ }]);
+
+ promise[0].then(success).fail(failure);
+ }
+
+ };
+
+});
--- /dev/null
+<?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/>.
+
+/**
+ * This is the external API for this component.
+ *
+ * @package tool_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_analytics;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once("$CFG->libdir/externallib.php");
+
+use external_api;
+use external_function_parameters;
+use external_value;
+use external_single_structure;
+use external_multiple_structure;
+
+/**
+ * This is the external API for this component.
+ *
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class external extends external_api {
+
+ const MAX_CONTEXTS_RETURNED = 100;
+
+ /**
+ * potential_contexts parameters.
+ *
+ * @since Moodle 3.8
+ * @return external_function_parameters
+ */
+ public static function potential_contexts_parameters() {
+ return new external_function_parameters(
+ array(
+ 'query' => new external_value(PARAM_NOTAGS, 'The model id', VALUE_DEFAULT),
+ 'modelid' => new external_value(PARAM_INT, 'The model id', VALUE_DEFAULT)
+ )
+ );
+ }
+
+ /**
+ * Return the contexts that match the provided query.
+ *
+ * @since Moodle 3.8
+ * @param string|null $query
+ * @param int|null $modelid
+ * @return array an array of contexts
+ */
+ public static function potential_contexts(?string $query = null, ?int $modelid = null) {
+
+ $params = self::validate_parameters(self::potential_contexts_parameters(), ['modelid' => $modelid, 'query' => $query]);
+
+ \core_analytics\manager::check_can_manage_models();
+
+ if ($params['modelid']) {
+ $model = new \core_analytics\model($params['modelid']);
+ $contexts = ($model->get_analyser(['notimesplitting' => true]))::potential_context_restrictions($params['query']);
+ } else {
+ $contexts = \core_analytics\manager::get_potential_context_restrictions(null, $params['query']);
+ }
+
+ $contextoptions = [];
+ $i = 0;
+ foreach ($contexts as $contextid => $contextname) {
+
+ if ($i === self::MAX_CONTEXTS_RETURNED) {
+ // Limited to MAX_CONTEXTS_RETURNED items.
+ break;
+ }
+
+ $contextoptions[] = ['id' => $contextid, 'name' => $contextname];
+ $i++;
+ }
+
+ return $contextoptions;
+ }
+
+ /**
+ * potential_contexts return
+ *
+ * @since Moodle 3.8
+ * @return external_description
+ */
+ public static function potential_contexts_returns() {
+ return new external_multiple_structure(
+ new external_single_structure([
+ 'id' => new external_value(PARAM_INT, 'ID of the context'),
+ 'name' => new external_value(PARAM_NOTAGS, 'The context name')
+ ])
+ );
+ }
+}
$mform->addElement('select', 'timesplitting', get_string('timesplittingmethod', 'analytics'), $timesplittings);
$mform->addHelpButton('timesplitting', 'timesplittingmethod', 'analytics');
+ // Contexts restriction.
+ if (!empty($this->_customdata['supportscontexts'])) {
+
+ $options = [
+ 'ajax' => 'tool_analytics/potential-contexts',
+ 'multiple' => true,
+ 'noselectionstring' => get_string('all')
+ ];
+
+ if (!empty($this->_customdata['id'])) {
+ $options['modelid'] = $this->_customdata['id'];
+ $contexts = $this->load_current_contexts();
+ } else {
+ // No need to preload any selected contexts.
+ $contexts = [];
+ }
+
+ $mform->addElement('autocomplete', 'contexts', get_string('contexts', 'tool_analytics'), $contexts, $options);
+ $mform->setType('contexts', PARAM_INT);
+ $mform->addHelpButton('contexts', 'contexts', 'tool_analytics');
+ }
+
// Predictions processor.
if (!$this->_customdata['staticmodel']) {
$defaultprocessor = \core_analytics\manager::get_predictions_processor_name(
public function validation($data, $files) {
$errors = parent::validation($data, $files);
+ $targetclass = \tool_analytics\output\helper::option_to_class($data['target']);
+ $target = \core_analytics\manager::get_target($targetclass);
+
if (!empty($data['timesplitting'])) {
$timesplittingclass = \tool_analytics\output\helper::option_to_class($data['timesplitting']);
if (\core_analytics\manager::is_valid($timesplittingclass, '\core_analytics\local\time_splitting\base') === false) {
$errors['timesplitting'] = get_string('errorinvalidtimesplitting', 'analytics');
}
- $targetclass = \tool_analytics\output\helper::option_to_class($data['target']);
$timesplitting = \core_analytics\manager::get_time_splitting($timesplittingclass);
- $target = \core_analytics\manager::get_target($targetclass);
if (!$target->can_use_timesplitting($timesplitting)) {
$errors['timesplitting'] = get_string('invalidtimesplitting', 'tool_analytics');
}
}
+ if (!empty($data['contexts'])) {
+
+ $analyserclass = $target->get_analyser_class();
+ if (!$potentialcontexts = $analyserclass::potential_context_restrictions()) {
+ $errors['contexts'] = get_string('errornocontextrestrictions', 'analytics');
+ } else {
+
+ // Flip the contexts array so we can just diff by key.
+ $selectedcontexts = array_flip($data['contexts']);
+ $invalidcontexts = array_diff_key($selectedcontexts, $potentialcontexts);
+ if (!empty($invalidcontexts)) {
+ $errors['contexts'] = get_string('errorinvalidcontexts', 'analytics');
+ }
+ }
+ }
+
if (!$this->_customdata['staticmodel']) {
if (empty($data['indicators'])) {
$errors['indicators'] = get_string('errornoindicators', 'analytics');
return $errors;
}
+
+ /**
+ * Load the currently selected context options.
+ *
+ * @return array
+ */
+ protected function load_current_contexts() {
+ $contexts = [];
+ foreach ($this->_customdata['contexts'] as $context) {
+ $contexts[$context->id] = $context->get_context_name(true, true);
+ }
+
+ return $contexts;
+ }
}
$offset = $this->page * $this->perpage;
- $analysables = $this->model->get_analyser(['notimesplitting' => true])->get_analysables_iterator();
+ $contexts = $this->model->get_contexts();
+ $analysables = $this->model->get_analyser(['notimesplitting' => true])->get_analysables_iterator(null, $contexts);
$skipped = 0;
$enoughresults = false;
--list List models
--non-interactive Not interactive questions
--analysisinterval Restrict the evaluation to 1 single analysis interval (Optional)
---filter Analyser dependant. e.g. A courseid would evaluate the model using a single course (Optional)
--mode 'configuration' or 'trainedmodel'. You can only use mode=trainedmodel when the trained" .
" model was imported" . "
--reuse-prev-analysed Reuse recently analysed courses instead of analysing the whole site. Set it to false while" .
-h, --help Print out this help
Example:
-\$ php admin/tool/analytics/cli/evaluate_model.php --modelid=1 --analysisinterval='\\core\\analytics\\time_splitting\\quarters' --filter=123,321
+\$ php admin/tool/analytics/cli/evaluate_model.php --modelid=1 --analysisinterval='\\core\\analytics\\time_splitting\\quarters'
";
// Now get cli options.
'mode' => 'configuration',
'reuse-prev-analysed' => true,
'non-interactive' => false,
- 'filter' => false
),
array(
'h' => 'help',
exit(0);
}
-// Reformat them as an array.
-if ($options['filter'] !== false) {
- $options['filter'] = explode(',', $options['filter']);
-}
-
if ($options['mode'] !== 'configuration' && $options['mode'] !== 'trainedmodel') {
cli_error('Error: The provided mode is not supported');
}
$renderer = $PAGE->get_renderer('tool_analytics');
$analyseroptions = array(
- 'filter' => $options['filter'],
'timesplitting' => $options['analysisinterval'],
'reuseprevanalysed' => $options['reuse-prev-analysed'],
'mode' => $options['mode'],
return (!$target->based_on_assumptions());
});
+// Set 'supportscontexts' to true as at this stage we don't know if the contexts are supported by
+// the selected target.
$customdata = array(
'trainedmodel' => false,
'staticmodel' => false,
'indicators' => \core_analytics\manager::get_all_indicators(),
'timesplittings' => \core_analytics\manager::get_all_time_splittings(),
'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors(),
+ 'supportscontexts' => true,
);
$mform = new \tool_analytics\output\form\edit_model(null, $customdata);
$indicators = array_diff_key($indicators, $invalidindicators);
}
- // Update the model with the valid list of indicators.
- $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
+ // Update the model with the rest of the data provided in the form.
+ $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor, $data->contexts);
$message = '';
$messagetype = \core\output\notification::NOTIFY_SUCCESS;
--- /dev/null
+<?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/>.
+
+/**
+ * Tool analytics webservice definitions.
+ *
+ * @package tool_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$functions = array(
+
+ 'tool_analytics_potential_contexts' => array(
+ 'classname' => 'tool_analytics\external',
+ 'methodname' => 'potential_contexts',
+ 'description' => 'Retrieve the list of potential contexts for a model.',
+ 'type' => 'read',
+ 'ajax' => true,
+ 'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE)
+ ),
+);
$string['componentcore'] = 'Core';
$string['componentselect'] = 'Select all models provided by the component \'{$a}\'';
$string['componentselectnone'] = 'Unselect all';
+$string['contexts'] = 'Contexts';
+$string['contexts_help'] = 'The model will be limited to this set of contexts. No context restrictions will be applied if no contexts are selected.';
$string['createmodel'] = 'Create model';
$string['currenttimesplitting'] = 'Current analysis interval';
$string['delete'] = 'Delete';
'targetname' => $model->get_target()->get_name(),
'indicators' => $model->get_potential_indicators(),
'timesplittings' => $potentialtimesplittings,
- 'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors()
+ 'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors(),
+ 'supportscontexts' => ($model->get_analyser(['notimesplitting' => true]))::context_restriction_support(),
+ 'contexts' => $model->get_contexts(),
);
$mform = new \tool_analytics\output\form\edit_model(null, $customdata);
$predictionsprocessor = false;
}
- $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
+ $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor, $data->contexts);
redirect($returnurl);
}
$callable = array('\tool_analytics\output\helper', 'class_to_option');
$modelobj->indicators = array_map($callable, json_decode($modelobj->indicators));
$modelobj->timesplitting = \tool_analytics\output\helper::class_to_option($modelobj->timesplitting);
+ if ($modelobj->contextids) {
+ $modelobj->contexts = array_map($callable, json_decode($modelobj->contextids));
+ }
$modelobj->predictionsprocessor = \tool_analytics\output\helper::class_to_option($modelobj->predictionsprocessor);
$mform->set_data($modelobj);
$mform->display();
--- /dev/null
+<?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/>.
+
+/**
+ * Tool analytics external functions tests.
+ *
+ * @package tool_analytics
+ * @category external
+ * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com}
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since Moodle 3.8
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+require_once(__DIR__ . '/../../../../analytics/tests/fixtures/test_indicator_max.php');
+require_once(__DIR__ . '/../../../../analytics/tests/fixtures/test_target_course_level_shortname.php');
+
+/**
+ * Tool analytics external functions tests
+ *
+ * @package tool_analytics
+ * @category external
+ * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com}
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since Moodle 3.8
+ */
+class tool_analytics_external_testcase extends externallib_advanced_testcase {
+
+ /**
+ * test_potential_contexts description
+ */
+ public function test_potential_contexts() {
+ $this->resetAfterTest();
+
+ $this->setAdminUser();
+
+ // Include the all context levels so the misc. category get included.
+ $this->assertCount(1, \tool_analytics\external::potential_contexts());
+
+ // The frontpage is not included.
+ $this->assertCount(0, \tool_analytics\external::potential_contexts('PHPUnit'));
+
+ $target = \core_analytics\manager::get_target('test_target_course_level_shortname');
+ $indicators = ['test_indicator_max' => \core_analytics\manager::get_indicator('test_indicator_max')];
+ $model = \core_analytics\model::create($target, $indicators);
+
+ $this->assertCount(1, \tool_analytics\external::potential_contexts(null, $model->get_id()));
+ }
+
+ /**
+ * test_potential_contexts description
+ *
+ * @expectedException required_capability_exception
+ */
+ public function test_potential_contexts_no_manager() {
+ $this->resetAfterTest();
+
+ $user = $this->getDataGenerator()->create_user();
+ $this->setUser($user);
+
+ $this->assertCount(2, \tool_analytics\external::potential_contexts());
+ }
+}
defined('MOODLE_INTERNAL') || die();
-$plugin->version = 2019052000; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version = 2019052002; // The current plugin version (Date: YYYYMMDDXX).
$plugin->requires = 2019051100; // Requires this Moodle version.
$plugin->component = 'tool_analytics'; // Full name of the plugin (used for diagnostics).
--- /dev/null
+username,firstname,lastname,email
+student1,Student,One,s1@example.com
+student2,Student,Two,s2@example.com
+student3,Student,Three,s3@example.com
\ No newline at end of file
$string['encoding'] = 'Encoding';
$string['errormnetadd'] = 'Can not add remote users';
$string['errors'] = 'Errors';
+$string['examplecsv'] = 'Example text file';
+$string['examplecsv_help'] = 'To use the example text file, download it then open it with a text or spreadsheet editor. Leave the first line unchanged, then edit the following lines (records) and add your user data, adding more lines as necessary. Save the file as CSV then upload it.
+
+The example text file may also be used for testing, as you are able to preview user data and can choose to cancel the action before user accounts are created.';
$string['invalidupdatetype'] = 'This option cannot be selected with the chosen upload type.';
$string['invaliduserdata'] = 'Invalid data detected for user {$a} and it has been automatically cleaned.';
$string['invalidtheme'] = 'Theme "{$a}" is not installed and will be ignored.';
* Each line of the file contains one record
* Each record is a series of data separated by commas (or other delimiters)
* The first record contains a list of fieldnames defining the format of the rest of the file
-* Required fieldnames are username, password, firstname, lastname, email';
+* Required fieldnames are username, password, firstname, lastname, email
+
+<a href="https://docs.moodle.org/en/Upload_users" target="_blank">More help</a>';
$string['uploaduserspreview'] = 'Upload users preview';
$string['uploadusersresult'] = 'Upload users results';
$string['uploaduser:uploaduserpictures'] = 'Upload user pictures';
$mform->addElement('header', 'settingsheader', get_string('upload'));
+ $url = new moodle_url('example.csv');
+ $link = html_writer::link($url, 'example.csv');
+ $mform->addElement('static', 'examplecsv', get_string('examplecsv', 'tool_uploaduser'), $link);
+ $mform->addHelpButton('examplecsv', 'examplecsv', 'tool_uploaduser');
+
$mform->addElement('filepicker', 'userfile', get_string('file'));
$mform->addRule('userfile', null, 'required');
/**
* Runs the analysis.
*
+ * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
* @return null
*/
- public function run() {
+ public function run(array $contexts = []) {
$options = $this->analyser->get_options();
} else {
$action = 'prediction';
}
- $analysables = $this->analyser->get_analysables_iterator($action);
+ $analysables = $this->analyser->get_analysables_iterator($action, $contexts);
$processedanalysables = $this->get_processed_analysables();
* to ease to implementation of get_analysables_iterator: get_iterator_sql and order_sql.
*
* @param string|null $action 'prediction', 'training' or null if no specific action needed.
+ * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty.
* @return \Iterator
*/
- public function get_analysables_iterator(?string $action = null) {
+ public function get_analysables_iterator(?string $action = null, array $contexts = []) {
debugging('Please overwrite get_analysables_iterator with your own implementation, we only keep this default
implementation for backwards compatibility purposes with get_analysables(). note that $action param will
/**
* Returns labelled data (training and evaluation).
*
+ * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
* @return \stored_file[]
*/
- public function get_labelled_data() {
+ public function get_labelled_data(array $contexts = []) {
// Delegates all processing to the analysis.
$result = new \core_analytics\local\analysis\result_file($this->get_modelid(), true, $this->get_options());
$analysis = new \core_analytics\analysis($this, true, $result);
- $analysis->run();
+ $analysis->run($contexts);
return $result->get();
}
/**
* Returns unlabelled data (prediction).
*
+ * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
* @return \stored_file[]
*/
- public function get_unlabelled_data() {
+ public function get_unlabelled_data(array $contexts = []) {
// Delegates all processing to the analysis.
$result = new \core_analytics\local\analysis\result_file($this->get_modelid(), false, $this->get_options());
$analysis = new \core_analytics\analysis($this, false, $result);
- $analysis->run();
+ $analysis->run($contexts);
return $result->get();
}
/**
* Returns indicator calculations as an array.
+ *
+ * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
* @return array
*/
- public function get_static_data() {
+ public function get_static_data(array $contexts = []) {
// Delegates all processing to the analysis.
$result = new \core_analytics\local\analysis\result_array($this->get_modelid(), false, $this->get_options());
$analysis = new \core_analytics\analysis($this, false, $result);
- $analysis->run();
+ $analysis->run($contexts);
return $result->get();
}
return false;
}
+ /**
+ * Returns an array of context levels that can be used to restrict the contexts used during analysis.
+ *
+ * The contexts provided to self::get_analysables_iterator will match these contextlevels.
+ *
+ * @return array Array of context levels or an empty array if context restriction is not supported.
+ */
+ public static function context_restriction_support(): array {
+ return [];
+ }
+
+ /**
+ * Returns the possible contexts used by the analyser.
+ *
+ * This method uses separate logic for each context level because to iterate through
+ * the list of contexts calling get_context_name for each of them would be expensive
+ * in performance terms.
+ *
+ * This generic implementation returns all the contexts in the site for the provided context level.
+ * Overwrite it for specific restrictions in your analyser.
+ *
+ * @param string|null $query Context name filter.
+ * @return int[]
+ */
+ public static function potential_context_restrictions(string $query = null) {
+ return \core_analytics\manager::get_potential_context_restrictions(static::context_restriction_support(), $query);
+ }
+
/**
* Get the sql of a default implementation of the iterator.
*
* @param int $contextlevel The context level of the analysable
* @param string|null $action
* @param string|null $tablealias The table alias
+ * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables if empty.
* @return array [0] => sql and [1] => params array
*/
- protected function get_iterator_sql(string $tablename, int $contextlevel, ?string $action = null, ?string $tablealias = null) {
+ protected function get_iterator_sql(string $tablename, int $contextlevel, ?string $action = null, ?string $tablealias = null,
+ array $contexts = []) {
+ global $DB;
if (!$tablealias) {
$tablealias = 'analysable';
$params = $params + ['action' => $action];
}
- // Adding the 1 = 1 just to have the WHERE part so that all further conditions added by callers can be
- // appended to $sql with and ' AND'.
$sql = 'SELECT ' . $select . '
FROM {' . $tablename . '} ' . $tablealias . '
' . $usedanalysablesjoin . '
- JOIN {context} ctx ON (ctx.contextlevel = :contextlevel AND ctx.instanceid = ' . $tablealias . '.id)
- WHERE 1 = 1';
+ JOIN {context} ctx ON (ctx.contextlevel = :contextlevel AND ctx.instanceid = ' . $tablealias . '.id) ';
+
+ if (!$contexts) {
+ // Adding the 1 = 1 just to have the WHERE part so that all further conditions
+ // added by callers can be appended to $sql with and ' AND'.
+ $sql .= 'WHERE 1 = 1';
+ } else {
+
+ $contextsqls = [];
+ foreach ($contexts as $context) {
+ $paramkey1 = 'paramctxlike' . $context->id;
+ $paramkey2 = 'paramctxeq' . $context->id;
+ $contextsqls[] = $DB->sql_like('ctx.path', ':' . $paramkey1);
+ $contextsqls[] = 'ctx.path = :' . $paramkey2;
+
+ // This includes the context itself.
+ $params[$paramkey1] = $context->path . '/%';
+ $params[$paramkey2] = $context->path;
+ }
+ $sql .= 'WHERE (' . implode(' OR ', $contextsqls) . ')';
+ }
return [$sql, $params];
}
* Return the list of courses to analyse.
*
* @param string|null $action 'prediction', 'training' or null if no specific action needed.
+ * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty.
* @return \Iterator
*/
- public function get_analysables_iterator(?string $action = null) {
+ public function get_analysables_iterator(?string $action = null, array $contexts = []) {
global $DB;
- list($sql, $params) = $this->get_iterator_sql('course', CONTEXT_COURSE, $action, 'c');
-
- // This will be updated to filter by context as part of MDL-64739.
- if (!empty($this->options['filter'])) {
- $courses = array();
- foreach ($this->options['filter'] as $courseid) {
- $courses[$courseid] = intval($courseid);
- }
-
- list($coursesql, $courseparams) = $DB->get_in_or_equal($courses, SQL_PARAMS_NAMED);
- $sql .= " AND c.id $coursesql";
- $params = $params + $courseparams;
- }
+ list($sql, $params) = $this->get_iterator_sql('course', CONTEXT_COURSE, $action, 'c', $contexts);
$ordersql = $this->order_sql('sortorder', 'ASC', 'c');
return \core_analytics\course::instance($record, $context);
});
}
-}
\ No newline at end of file
+
+ /**
+ * Can be limited to course categories or specific courses.
+ *
+ * @return array
+ */
+ public static function context_restriction_support(): array {
+ return [CONTEXT_COURSE, CONTEXT_COURSECAT];
+ }
+}
* Return the list of analysables to analyse.
*
* @param string|null $action 'prediction', 'training' or null if no specific action needed.
+ * @param \context[] $contexts Ignored here.
* @return \Iterator
*/
- public function get_analysables_iterator(?string $action = null) {
+ public function get_analysables_iterator(?string $action = null, array $contexts = []) {
// We can safely ignore $action as we have 1 single analysable element in this analyser.
return new \ArrayIterator([new \core_analytics\site()]);
}
$usedanalysablesanalysableids = array_flip($usedanalysablesanalysableids);
$analyser = $model->get_analyser(array('notimesplitting' => true));
+
+ // We do not honour the list of contexts in this model as it can contain stale records.
$analysables = $analyser->get_analysables_iterator();
$analysableids = [];
return [$target, $indicators];
}
+
+ /**
+ * Return the context restrictions that can be applied to the provided context levels.
+ *
+ * @throws \coding_exception
+ * @param array|null $contextlevels The list of context levels provided by the analyser. Null if all of them.
+ * @param string|null $query
+ * @return array Associative array with contextid as key and the short version of the context name as value.
+ */
+ public static function get_potential_context_restrictions(?array $contextlevels = null, string $query = null) {
+ global $DB;
+
+ if (empty($contextlevels) && !is_null($contextlevels)) {
+ return false;
+ }
+
+ if (!is_null($contextlevels)) {
+ foreach ($contextlevels as $contextlevel) {
+ if ($contextlevel !== CONTEXT_COURSE && $contextlevel !== CONTEXT_COURSECAT) {
+ throw new \coding_exception('Only CONTEXT_COURSE and CONTEXT_COURSECAT are supported at the moment.');
+ }
+ }
+ }
+
+ $contexts = [];
+
+ // We have a separate process for each context level for performance reasons (to iterate through mdl_context calling
+ // get_context_name() would be too slow).
+ $contextsystem = \context_system::instance();
+ if (is_null($contextlevels) || in_array(CONTEXT_COURSECAT, $contextlevels)) {
+
+ $sql = "SELECT cc.id, cc.name, ctx.id AS contextid
+ FROM {course_categories} cc
+ JOIN {context} ctx ON ctx.contextlevel = :ctxlevel AND ctx.instanceid = cc.id";
+ $params = ['ctxlevel' => CONTEXT_COURSECAT];
+
+ if ($query) {
+ $sql .= " WHERE " . $DB->sql_like('cc.name', ':query', false, false);
+ $params['query'] = '%' . $query . '%';
+ }
+
+ $coursecats = $DB->get_recordset_sql($sql, $params);
+ foreach ($coursecats as $record) {
+ $contexts[$record->contextid] = get_string('category') . ': ' .
+ format_string($record->name, true, array('context' => $contextsystem));
+ }
+ $coursecats->close();
+ }
+
+ if (is_null($contextlevels) || in_array(CONTEXT_COURSE, $contextlevels)) {
+
+ $sql = "SELECT c.id, c.shortname, ctx.id AS contextid
+ FROM {course} c
+ JOIN {context} ctx ON ctx.contextlevel = :ctxlevel AND ctx.instanceid = c.id
+ WHERE c.id != :siteid";
+ $params = ['ctxlevel' => CONTEXT_COURSE, 'siteid' => SITEID];
+
+ if ($query) {
+ $sql .= ' AND (' . $DB->sql_like('c.fullname', ':query1', false, false) . ' OR ' .
+ $DB->sql_like('c.shortname', ':query2', false, false) . ')';
+ $params['query1'] = '%' . $query . '%';
+ $params['query2'] = '%' . $query . '%';
+ }
+
+ $courses = $DB->get_recordset_sql($sql, $params);
+ foreach ($courses as $record) {
+ $contexts[$record->contextid] = get_string('course') . ': ' .
+ format_string($record->shortname, true, array('context' => $contextsystem));
+ }
+ $courses->close();
+ }
+
+ return $contexts;
+ }
+
}
*/
protected $indicators = null;
+ /**
+ * @var \context[]
+ */
+ protected $contexts = null;
+
/**
* Unique Model id created from site info and last model modification.
*
* @param \core_analytics\local\indicator\base[]|false $indicators False to respect current indicators
* @param string|false $timesplittingid False to respect current time splitting method
* @param string|false $predictionsprocessor False to respect current predictors processor value
+ * @param int[]|false $contextids List of context ids for this model. False to respect the current list of contexts.
* @return void
*/
- public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false) {
+ public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false,
+ $contextids = false) {
global $USER, $DB;
\core_analytics\manager::check_can_manage_models();
$predictionsprocessor = $this->model->predictionsprocessor;
}
+ if ($contextids !== false) {
+ $contextsstr = json_encode($contextids);
+
+ // Reset the internal cache.
+ $this->contexts = null;
+ } else {
+ $contextsstr = $this->model->contextids;
+ }
+
if ($this->model->timesplitting !== $timesplittingid ||
$this->model->indicators !== $indicatorsstr ||
$this->model->predictionsprocessor !== $predictionsprocessor) {
$this->model->indicators = $indicatorsstr;
$this->model->timesplitting = $timesplittingid;
$this->model->predictionsprocessor = $predictionsprocessor;
+ $this->model->contextids = $contextsstr;
$this->model->timemodified = $now;
$this->model->usermodified = $USER->id;
// Before get_labelled_data call so we get an early exception if it is not ready.
$predictor = $this->get_predictions_processor();
- $datasets = $this->get_analyser()->get_labelled_data();
+ $datasets = $this->get_analyser()->get_labelled_data($this->get_contexts());
// No datasets generated.
if (empty($datasets)) {
// Before get_labelled_data call so we get an early exception if it is not ready.
$predictor = $this->get_predictions_processor();
- $datasets = $this->get_analyser()->get_labelled_data();
+ $datasets = $this->get_analyser()->get_labelled_data($this->get_contexts());
// No training if no files have been provided.
if (empty($datasets) || empty($datasets[$this->model->timesplitting])) {
// Before get_unlabelled_data call so we get an early exception if it is not ready.
$predictor = $this->get_predictions_processor();
- $samplesdata = $this->get_analyser()->get_unlabelled_data();
+ $samplesdata = $this->get_analyser()->get_unlabelled_data($this->get_contexts());
// Get the prediction samples file.
if (empty($samplesdata) || empty($samplesdata[$this->model->timesplitting])) {
} else {
// Predictions based on assumptions.
- $indicatorcalculations = $this->get_analyser()->get_static_data();
+ $indicatorcalculations = $this->get_analyser()->get_static_data($this->get_contexts());
// Get the prediction samples file.
if (empty($indicatorcalculations) || empty($indicatorcalculations[$this->model->timesplitting])) {
return [$allsampleids, $allsamplesdata];
}
+ /**
+ * Contexts where this model should be active.
+ *
+ * @return \context[] Empty array if there are no context restrictions.
+ */
+ public function get_contexts() {
+ if ($this->contexts !== null) {
+ return $this->contexts;
+ }
+
+ if (!$this->model->contextids) {
+ $this->contexts = [];
+ return $this->contexts;
+ }
+ $contextids = json_decode($this->model->contextids);
+
+ // We don't expect this list to be massive as contexts need to be selected manually using the edit model form.
+ $this->contexts = array_map(function($contextid) {
+ return \context::instance_by_id($contextid, IGNORE_MISSING);
+ }, $contextids);
+
+ return $this->contexts;
+ }
+
/**
* Purges the insights cache.
*/
$this->assertNotEmpty($indicators);
$this->assertContainsOnlyInstancesOf(\core_analytics\local\indicator\base::class, $indicators);
}
+
+ /**
+ * test_get_potential_context_restrictions description
+ */
+ public function test_get_potential_context_restrictions() {
+ $this->resetAfterTest();
+
+ // No potential context restrictions.
+ $this->assertFalse(\core_analytics\manager::get_potential_context_restrictions([]));
+
+ // Include the all context levels so the misc. category get included.
+ $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions());
+
+ $this->getDataGenerator()->create_course();
+ $this->getDataGenerator()->create_category();
+ $this->assertCount(3, \core_analytics\manager::get_potential_context_restrictions());
+ $this->assertCount(3, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE, CONTEXT_COURSECAT]));
+
+ $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE]));
+ $this->assertCount(2, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT]));
+
+ $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'Course category'));
+ $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'Course category 1'));
+ $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'Miscellaneous'));
+ $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE], 'Test course 1'));
+ $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE], 'Test course'));
+ }
}
array('modelid' => $model->get_id())));
}
+ /**
+ * test_model_contexts
+ */
+ public function test_model_contexts() {
+ global $DB;
+
+ $this->resetAfterTest(true);
+ $this->setAdminuser();
+
+ $misc = $DB->get_record('course_categories', ['name' => 'Miscellaneous']);
+ $miscctx = \context_coursecat::instance($misc->id);
+
+ $category = $this->getDataGenerator()->create_category();
+ $categoryctx = \context_coursecat::instance($category->id);
+
+ // One course per category.
+ $courseparams = array('shortname' => 'aaaaaa', 'fullname' => 'aaaaaa', 'visible' => 0,
+ 'category' => $category->id);
+ $course1 = $this->getDataGenerator()->create_course($courseparams);
+ $course1ctx = \context_course::instance($course1->id);
+ $courseparams = array('shortname' => 'bbbbbb', 'fullname' => 'bbbbbb', 'visible' => 0,
+ 'category' => $misc->id);
+ $course2 = $this->getDataGenerator()->create_course($courseparams);
+
+ $model = $this->add_perfect_model('test_static_target_shortname');
+
+ // Just 1 category.
+ $model->update(true, false, '\core\analytics\time_splitting\no_splitting', false, [$categoryctx->id]);
+ $this->assertCount(1, $model->predict()->predictions);
+
+ // Now with 2 categories.
+ $model->update(true, false, false, false, [$categoryctx->id, $miscctx->id]);
+
+ // The courses in the new category are processed.
+ $this->assertCount(1, $model->predict()->predictions);
+
+ // Clear the predictions generated by the model and predict() again.
+ $model->clear();
+ $this->assertCount(2, $model->predict()->predictions);
+
+ // Course context restriction.
+ $model->update(true, false, '\core\analytics\time_splitting\no_splitting', false, [$course1ctx->id]);
+
+ // Nothing new as the course was already analysed.
+ $result = $model->predict();
+ $this->assertTrue(empty($result->predictions));
+
+ $model->clear();
+ $this->assertCount(1, $model->predict()->predictions);
+ }
+
/**
* test_ml_training_and_prediction
*
* Predictions flagged as "Not useful" in models whose targets use analysers that provide multiple samples
per analysable (e.g. students at risk or no teaching) have been updated to "Incorrectly flagged".
* \core_analytics\predictor::delete_output_dir has a new 2nd parameter, $uniquemodelid.
+* Analyser's get_analysables_iterator and get_iterator_sql have a new $contexts parameter to limit the returned analysables to
+ the ones that depend on the provided contexts.
+* Analysers can implement a context_restriction_support() method to restrict models to a subset of the
+ contents in the site. Only CONTEXT_COURSE and CONTEXT_COURSECAT are supported.
=== 3.7 ===
if (!($bc = backup_ui::load_controller($backupid))) {
$bc = new backup_controller($type, $id, backup::FORMAT_MOODLE,
backup::INTERACTIVE_YES, $backupmode, $USER->id);
+ // The backup id did not relate to a valid controller so we made a new controller.
+ // Now we need to reset the backup id to match the new controller.
+ $backupid = $bc->get_backupid();
}
// Prepare a progress bar which can display optionally during long-running
return $scale;
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $instanceconfigs = !empty($this->config) ? $this->config : new stdClass();
+ $pluginconfigs = get_config('block_activity_results');
+
+ return (object) [
+ 'instance' => $instanceconfigs,
+ 'plugin' => $pluginconfigs,
+ ];
+ }
}
$this->content->text .= get_string('norecentblogentries', 'block_blog_recent');
}
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $configs = !empty($this->config) ? $this->config : new stdClass();
+
+ return (object) [
+ 'instance' => $configs,
+ 'plugin' => new stdClass(),
+ ];
+ }
}
}
return $this->content;
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $configs = !empty($this->config) ? $this->config : new stdClass();
+
+ return (object) [
+ 'instance' => $configs,
+ 'plugin' => new stdClass(),
+ ];
+ }
}
function block_blog_tags_sort($a, $b) {
Then "Calendar" "block" should exist
@javascript
- Scenario: View a global event in the calendar block
+ Scenario: View a site event in the calendar block
Given I log in as "admin"
And I create a calendar event with form data:
| id_eventtype | Site |
| id_eventtype | Course |
| id_name | Course Event |
And I am on "Course 1" course homepage
- And I follow "Hide global events"
+ And I follow "Hide site events"
And I hover over today in the calendar
Then I should not see "Site Event"
And I should see "Course Event"
I can add the calendar block to a course
@javascript
- Scenario: View a global event in the calendar block in a course
+ Scenario: View a site event in the calendar block in a course
Given the following "users" exist:
| username | firstname | lastname | email | idnumber |
| teacher1 | Teacher | 1 | teacher1@example.com | T1 |
I can view the event in the calendar
@javascript
- Scenario: View a global event in the calendar block on the dashboard
+ Scenario: View a site event in the calendar block on the dashboard
Given the following "users" exist:
| username | firstname | lastname | email | idnumber |
| student1 | Student | 1 | student1@example.com | S1 |
I can add the calendar block on the site front page
@javascript
- Scenario: View a global event in the calendar block on the front page
+ Scenario: View a site event in the calendar block on the front page
Given the following "users" exist:
| username | firstname | lastname | email | idnumber |
| student1 | Student | 1 | student1@example.com | S1 |
| teacher1 | C1 | editingteacher |
@javascript
- Scenario: View a global event in the calendar block
+ Scenario: View a site event in the calendar block
Given I log in as "admin"
And I create a calendar event with form data:
| id_eventtype | Site |
| student1 | Student | 1 | student1@example.com | S1 |
@javascript
- Scenario: View a global event in the upcoming events block on the dashboard
+ Scenario: View a site event in the upcoming events block on the dashboard
Given I log in as "admin"
And I create a calendar event with form data:
| id_eventtype | Site |
| teacher1 | Teacher | 1 | teacher1@example.com | T1 |
@javascript
- Scenario: View a global event in the upcoming events block on the frontpage
+ Scenario: View a site event in the upcoming events block on the frontpage
Given I log in as "admin"
And I create a calendar event with form data:
| id_eventtype | Site |
),
'Block contents (if required).', VALUE_OPTIONAL
),
+ 'configs' => new external_multiple_structure(
+ new external_single_structure(
+ array(
+ 'name' => new external_value(PARAM_RAW, 'Name.'),
+ 'value' => new external_value(PARAM_RAW, 'Value.'),
+ 'type' => new external_value(PARAM_ALPHA, 'Type (instance or plugin).'),
+ )
+ ),
+ 'Block instance and plugin configuration settings.', VALUE_OPTIONAL
+ ),
), 'Block information.'
);
}
if ($returncontents) {
$block['contents'] = (array) $blockinstances[$bc->blockinstanceid]->get_content_for_external($OUTPUT);
}
+ $configs = (array) $blockinstances[$bc->blockinstanceid]->get_config_for_external();
+ foreach ($configs as $type => $data) {
+ foreach ((array) $data as $name => $value) {
+ $block['configs'][] = [
+ 'name' => $name,
+ 'value' => $value,
+ 'type' => $type,
+ ];
+ }
+ }
+
$allblocks[] = $block;
}
}
public function get_aria_role() {
return 'navigation';
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ global $CFG;
+
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $configs = (object) [
+ 'adminview' => $CFG->block_course_list_adminview,
+ 'hideallcourseslink' => $CFG->block_course_list_hideallcourseslink
+ ];
+
+ return (object) [
+ 'instance' => new stdClass(),
+ 'plugin' => $configs,
+ ];
+ }
}
return $this->content;
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $configs = !empty($this->config) ? $this->config : new stdClass();
+
+ return (object) [
+ 'instance' => $configs,
+ 'plugin' => new stdClass(),
+ ];
+ }
}
return $attributes;
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ global $CFG;
+
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $instanceconfigs = !empty($this->config) ? $this->config : new stdClass();
+ $pluginconfigs = (object) ['allowcssclasses' => $CFG->block_html_allowcssclasses];
+
+ return (object) [
+ 'instance' => $instanceconfigs,
+ 'plugin' => $pluginconfigs,
+ ];
+ }
}
public function instance_can_be_docked() {
return parent::instance_can_be_docked() && isset($this->config->title) && !empty($this->config->title);
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $configs = !empty($this->config) ? $this->config : new stdClass();
+
+ return (object) [
+ 'instance' => $configs,
+ 'plugin' => new stdClass(),
+ ];
+ }
}
return $bc;
}
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * In some cases the configs will need formatting or be returned only if the current user has some capabilities enabled.
+ *
+ * @return stdClass the configs for both the block instance and plugin (as object with name -> value)
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ return (object) [
+ 'instance' => new stdClass(),
+ 'plugin' => new stdClass(),
+ ];
+ }
+
/**
* Convert the contents of the block to HTML.
*
public function has_config() {
return true;
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $configs = get_config('block_myoverview');
+
+ return (object) [
+ 'instance' => new stdClass(),
+ 'plugin' => $configs,
+ ];
+ }
}
public function before_delete() {
}
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $configs = !empty($this->config) ? $this->config : new stdClass();
+
+ return (object) [
+ 'instance' => $configs,
+ 'plugin' => new stdClass(),
+ ];
+ }
}
public function get_aria_role() {
return 'navigation';
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $configs = !empty($this->config) ? $this->config : new stdClass();
+
+ return (object) [
+ 'instance' => $configs,
+ 'plugin' => new stdClass(),
+ ];
+ }
}
return $this->content;
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ global $CFG;
+
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $configs = (object) [
+ 'timetosee' => $CFG->block_online_users_timetosee,
+ 'onlinestatushiding' => $CFG->block_online_users_onlinestatushiding
+ ];
+
+ return (object) [
+ 'instance' => new stdClass(),
+ 'plugin' => $configs,
+ ];
+ }
}
public function has_config() {
return true;
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $configs = get_config('block_recentlyaccessedcourses');
+
+ return (object) [
+ 'instance' => new stdClass(),
+ 'plugin' => $configs,
+ ];
+ }
}
return core_text::substr($title, 0, $max - 3) . '...';
}
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ global $CFG;
+
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $instanceconfigs = !empty($this->config) ? $this->config : new stdClass();
+ $pluginconfigs = (object) [
+ 'num_entries' => $CFG->block_rss_client_num_entries,
+ 'timeout' => $CFG->block_rss_client_timeout
+ ];
+
+ return (object) [
+ 'instance' => $instanceconfigs,
+ 'plugin' => $pluginconfigs,
+ ];
+ }
}
public function has_config() {
return true;
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $instanceconfigs = !empty($this->config) ? $this->config : new stdClass();
+ $pluginconfigs = get_config('block_section_links');
+
+ return (object) [
+ 'instance' => $instanceconfigs,
+ 'plugin' => $pluginconfigs,
+ ];
+ }
}
public function get_aria_role() {
return 'navigation';
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $configs = !empty($this->config) ? $this->config : new stdClass();
+
+ return (object) [
+ 'instance' => $configs,
+ 'plugin' => new stdClass(),
+ ];
+ }
}
public function has_config() {
return true;
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $configs = get_config('block_starredcourses');
+
+ return (object) [
+ 'instance' => new stdClass(),
+ 'plugin' => $configs,
+ ];
+ }
}
}
return $url;
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $configs = !empty($this->config) ? $this->config : new stdClass();
+
+ return (object) [
+ 'instance' => $configs,
+ 'plugin' => new stdClass(),
+ ];
+ }
}
return $oldcat;
}
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ // There is a private key, only admins can see it.
+ $pluginconfigs = get_config('block_tag_youtube');
+ if (!has_capability('moodle/site:config', context_system::instance())) {
+ unset($pluginconfigs->apikey);
+ }
+ $instanceconfigs = !empty($this->config) ? $this->config : new stdClass();
+
+ return (object) [
+ 'instance' => $instanceconfigs,
+ 'plugin' => $pluginconfigs,
+ ];
+ }
}
return $this->content;
}
+
+ /**
+ * Return the plugin config settings for external functions.
+ *
+ * @return stdClass the configs for both the block instance and plugin
+ * @since Moodle 3.8
+ */
+ public function get_config_for_external() {
+ // Return all settings for all users since it is safe (no private keys, etc..).
+ $configs = !empty($this->config) ? $this->config : new stdClass();
+
+ return (object) [
+ 'instance' => $configs,
+ 'plugin' => new stdClass(),
+ ];
+ }
}
$this->assertEquals('', $result['blocks'][0]['contents']['footer']);
$this->assertCount(1, $result['blocks'][0]['contents']['files']);
$this->assertEquals($newblock, $result['blocks'][0]['name']);
+ $configcounts = 0;
+ foreach ($result['blocks'][0]['configs'] as $config) {
+ if ($config['type'] = 'plugin' && $config['name'] == 'allowcssclasses' && $config['value'] == 0) {
+ $configcounts++;
+ } else if ($config['type'] = 'instance' && $config['name'] == 'text' && $config['value'] == $body) {
+ $configcounts++;
+ } else if ($config['type'] = 'instance' && $config['name'] == 'title' && $config['value'] == $title) {
+ $configcounts++;
+ } else if ($config['type'] = 'instance' && $config['name'] == 'format' && $config['value'] == 0) {
+ $configcounts++;
+ }
+ }
+ $this->assertEquals(4, $configcounts);
}
/**
$user = $this->getDataGenerator()->create_user();
$PAGE->set_url('/my/index.php'); // Need this because some internal API calls require the $PAGE url to be set.
+ // Force a setting change to check the returned blocks settings.
+ set_config('displaycategories', 0, 'block_recentlyaccessedcourses');
+
// Get the expected default blocks.
$alldefaultblocksordered = $DB->get_records_menu('block_instances',
array('pagetypepattern' => 'my-index'), 'defaultregion, defaultweight ASC', 'id, blockname');
// Check all the returned blocks are in the expected blocks array.
$this->assertContains($block['name'], $alldefaultblocksordered);
$returnedblocks[] = $block['name'];
+ // Check the configuration returned for this default block.
+ if ($block['name'] == 'recentlyaccessedcourses') {
+ $this->assertEquals('displaycategories', $block['configs'][0]['name']);
+ $this->assertEquals(0, $block['configs'][0]['value']);
+ $this->assertEquals('plugin', $block['configs'][0]['type']);
+ }
}
// Remove lp block.
array_shift($alldefaultblocksordered);
}}
<div class="border-bottom pb-2">
{{#eventsbyday}}
- <h5 class="h6 mt-3 mb-0 {{#past}}text-danger{{/past}}">{{#userdate}} {{dayTimestamp}}, {{#str}} strftimedayshort, core_langconfig {{/str}} {{/userdate}}</h5>
+ <h5 class="h6 mt-3 mb-0 {{#past}}text-danger{{/past}}">{{#userdate}} {{dayTimestamp}}, {{#str}} strftimedaydate, core_langconfig {{/str}} {{/userdate}}</h5>
{{> block_timeline/event-list-items }}
{{/eventsbyday}}
</div>
\ No newline at end of file
=== 3.8 ===
* Block block_community is no longer a part of core.
* Block block_participants is no longer a part of core.
+* Block plugins should overwrite get_config_for_external function to return the blocks settings viewable by the current user.
+ If the block plugin does not have any setting that could be considerated private (like a private/access key/token),
+ is ok to return all the settings via the get_config_for_external function.
=== 3.7 ===
* The block:addinstance capability is no longer required if the block can only be added to a dashboard.
$user = $event->get_user();
$userid = $user ? $user->get('id') : null;
$isactivityevent = !empty($coursemodule);
- $isglobalevent = ($course && $courseid == SITEID);
+ $issiteevent = ($course && $courseid == SITEID);
$iscategoryevent = ($category && !empty($categoryid));
$iscourseevent = ($course && !empty($courseid) && $courseid != SITEID && empty($groupid));
$isgroupevent = ($group && !empty($groupid));
} else {
$alttext = get_string('activityevent', 'calendar');
}
- } else if ($isglobalevent) {
+ } else if ($issiteevent) {
$key = 'i/siteevent';
$component = 'core';
$alttext = get_string('typesite', 'calendar');
$options = [];
if (!empty($eventtypes['user'])) {
- $options['user'] = get_string('user');
+ $options['user'] = get_string('user', 'calendar');
}
if (!empty($eventtypes['group'])) {
- $options['group'] = get_string('group');
+ $options['group'] = get_string('group', 'calendar');
}
if (!empty($eventtypes['course'])) {
- $options['course'] = get_string('course');
+ $options['course'] = get_string('course', 'calendar');
}
if (!empty($eventtypes['category'])) {
- $options['category'] = get_string('category');
+ $options['category'] = get_string('category', 'calendar');
}
if (!empty($eventtypes['site'])) {
- $options['site'] = get_string('site');
+ $options['site'] = get_string('site', 'calendar');
}
// If we only have one event type and it's 'user' event then don't bother
// Add the select elements for the available event types.
$this->add_event_type_elements($mform, $eventtypes);
- // Eventtype: 0 = user, 1 = global, anything else = course ID.
+ // Eventtype: 0 = user, 1 = site, anything else = course ID.
$mform->addElement('submit', 'add', get_string('add'));
// Add the javascript required to enhance this mform.
if ($what == 'all') {
$users = $user->id;
$courses[SITEID] = new stdClass;
- $courses[SITEID]->shortname = get_string('globalevents', 'calendar');
+ $courses[SITEID]->shortname = get_string('siteevents', 'calendar');
$paramcourses[SITEID] = $courses[SITEID];
$paramcategory = true;
} else if ($what == 'groups') {
"Set to true to return current user's user events",
VALUE_DEFAULT, true, NULL_ALLOWED),
'siteevents' => new external_value(PARAM_BOOL,
- "Set to true to return global events",
+ "Set to true to return site events",
VALUE_DEFAULT, true, NULL_ALLOWED),
'timestart' => new external_value(PARAM_INT,
"Time from which events should be returned",
define('CALENDAR_TF_12', '%I:%M %p');
/**
- * CALENDAR_EVENT_GLOBAL - Global calendar event types
+ * CALENDAR_EVENT_GLOBAL - Site calendar event types
+ * @deprecated since 3.8
*/
define('CALENDAR_EVENT_GLOBAL', 1);
+/**
+ * CALENDAR_EVENT_SITE - Site calendar event types
+ */
+define('CALENDAR_EVENT_SITE', 1);
+
/**
* CALENDAR_EVENT_COURSE - Course calendar event types
*/
$event->cmid = $module->id;
} else if ($event->courseid == SITEID) { // Site event.
$event->icon = '<img src="' . $OUTPUT->image_url('i/siteevent') . '" alt="' .
- get_string('globalevent', 'calendar') . '" class="icon" />';
- $event->cssclass = 'calendar_event_global';
+ get_string('siteevent', 'calendar') . '" class="icon" />';
+ $event->cssclass = 'calendar_event_site';
} else if ($event->courseid != 0 && $event->courseid != SITEID && $event->groupid == 0) { // Course event.
$event->icon = '<img src="' . $OUTPUT->image_url('i/courseevent') . '" alt="' .
get_string('courseevent', 'calendar') . '" class="icon" />';
* @param int $year the number of the year
* @param array $eventsbyday event on specific day
* @param array $durationbyday duration of the event in days
- * @param array $typesbyday event type (eg: global, course, user, or group)
+ * @param array $typesbyday event type (eg: site, course, user, or group)
* @param array $courses list of courses
* @return void
*/
// Mark the day as having such an event.
if ($event->courseid == SITEID && $event->groupid == 0) {
- $typesbyday[$eventdaystart]['startglobal'] = true;
- // Set event class for global event.
- $events[$event->id]->class = 'calendar_event_global';
+ $typesbyday[$eventdaystart]['startsite'] = true;
+ // Set event class for site event.
+ $events[$event->id]->class = 'calendar_event_site';
} else if ($event->courseid != 0 && $event->courseid != SITEID && $event->groupid == 0) {
$typesbyday[$eventdaystart]['startcourse'] = true;
// Set event class for course event.
for ($i = $lowerbound + 1; $i <= $upperbound; ++$i) {
$durationbyday[$i][] = $event->id;
if ($event->courseid == SITEID && $event->groupid == 0) {
- $typesbyday[$i]['durationglobal'] = true;
+ $typesbyday[$i]['durationsite'] = true;
} else if ($event->courseid != 0 && $event->courseid != SITEID && $event->groupid == 0) {
$typesbyday[$i]['durationcourse'] = true;
} else if ($event->groupid) {
if ($ignorefilters || calendar_show_event_type(CALENDAR_EVENT_COURSE, $user)) {
$courses = array_keys($courseeventsfrom);
}
- if ($ignorefilters || calendar_show_event_type(CALENDAR_EVENT_GLOBAL, $user)) {
+ if ($ignorefilters || calendar_show_event_type(CALENDAR_EVENT_SITE, $user)) {
$courses[] = SITEID;
}
$courses = array_unique($courses);
* @return bool True if the tyep should be displayed false otherwise
*/
function calendar_show_event_type($type, $user = null) {
- $default = CALENDAR_EVENT_GLOBAL + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP + CALENDAR_EVENT_USER;
+ $default = CALENDAR_EVENT_SITE + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP + CALENDAR_EVENT_USER;
if (get_user_preferences('calendar_persistflt', 0, $user) === 0) {
global $SESSION;
*/
function calendar_set_event_type_display($type, $display = null, $user = null) {
$persist = get_user_preferences('calendar_persistflt', 0, $user);
- $default = CALENDAR_EVENT_GLOBAL + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP
+ $default = CALENDAR_EVENT_SITE + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP
+ CALENDAR_EVENT_USER + CALENDAR_EVENT_COURSECAT;
if ($persist === 0) {
global $SESSION;
| student2 | B2i | student |
And the following "events" exist:
| name | eventtype |
- | Site event | global |
+ | Site event | site |
And the following "events" exist:
| name | eventtype | course |
| CA1i event | course | A1i |
protected function create_event($properties = []) {
$record = new \stdClass();
$record->name = 'event name';
- $record->eventtype = 'global';
+ $record->eventtype = 'site';
$record->timestart = time();
$record->timeduration = 0;
$record->timesort = 0;
protected function create_event($properties = []) {
$record = new \stdClass();
$record->name = 'event name';
- $record->eventtype = 'global';
+ $record->eventtype = 'site';
$record->timestart = time();
$record->timeduration = 0;
$record->timesort = 0;
protected function create_event($properties = []) {
$record = new \stdClass();
$record->name = 'event name';
- $record->eventtype = 'global';
+ $record->eventtype = 'site';
$record->timestart = time();
$record->timeduration = 0;
$record->timesort = 0;
function create_event($properties) {
$record = new \stdClass();
$record->name = 'event name';
- $record->eventtype = 'global';
+ $record->eventtype = 'site';
$record->repeat = 0;
$record->repeats = 0;
$record->timestart = time();
protected function create_event($properties = []) {
$record = new \stdClass();
$record->name = 'event name';
- $record->eventtype = 'global';
+ $record->eventtype = 'site';
$record->repeat = 0;
$record->repeats = 0;
$record->timestart = time();
// to check the latest default in question/classes/bank/view.php before setting this.
//
// $CFG->questionbankcolumns = 'checkbox_column,question_type_column,'
-// . 'question_name_idnumber_tags_column,tags_action_column,edit_action_column,'
-// . 'copy_action_column,preview_action_column,delete_action_column,'
-// . 'creator_name_column,modifier_name_column';
+// . 'question_name_idnumber_tags_column,'
+// . 'tags_action_column,edit_action_column,copy_action_column,'
+// . 'preview_action_column,delete_action_column,export_xml_action_column,'
+// . 'creator_name_column,modifier_name_column,edit_menu_column';
+//
+// Forum summary report
+//
+// In order for the forum summary report to calculate word count and character count data, those details are now stored
+// for each post in the database when posts are created or updated. For posts that existed prior to a Moodle 3.8 upgrade,
+// these are calculated by the refresh_forum_post_counts ad-hoc task in chunks of 5000 posts per batch by default.
+// That default can be overridden by setting an integer value for $CFG->forumpostcountchunksize.
+//
+// $CFG->forumpostcountchunksize = 5000;
//
//=========================================================================
// 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!!
return get_string('coursenotyetstarted', 'course');
}
+ if (!$fortraining && !$course->get_course_data()->visible) {
+ return get_string('hiddenfromstudents');
+ }
+
if (!$this->students = $course->get_students()) {
return get_string('nocoursestudents', 'course');
}
protected function enrolment_active_during_analysis_time(int $sampleid, int $starttime, int $endtime) {
$userenrol = $this->retrieve('user_enrolments', $sampleid);
- $enrolstart = $userenrol->timestart ?? $userenrol->timecreated;
- $enrolend = $userenrol->timeend ?? PHP_INT_MAX;
+
+ if (!empty($userenrol->timestart)) {
+ $enrolstart = $userenrol->timestart;
+ } else {
+ // This is always set.
+ $enrolstart = $userenrol->timecreated;
+ }
+
+ if (!empty($userenrol->timeend)) {
+ $enrolend = $userenrol->timeend;
+ } else {
+ // Default to tre end of the world.
+ $enrolend = PHP_INT_MAX;
+ }
if ($endtime && $endtime < $enrolstart) {
/* The enrolment starts/ed after the analysis end time.
* @return bool
*/
public function can_request_course() {
- global $CFG;
- if (empty($CFG->enablecourserequests) || $this->id != $CFG->defaultrequestcategory) {
- return false;
- }
- return !$this->can_create_course() && has_capability('moodle/course:request', $this->get_context());
+ return course_request::can_request($this->get_context());
}
/**
}
if ($category->can_request_course()) {
// Request a new course.
- $url = new moodle_url('/course/request.php', array('return' => 'management'));
+ $url = new moodle_url('/course/request.php', array('category' => $category->id, 'return' => 'management'));
$actions[] = html_writer::link($url, get_string('requestcourse'));
}
if ($category->can_resort_courses()) {
}
/**
- * Return structure for edit_module()
+ * Return structure for get_module()
*
* @since Moodle 3.3
* @return external_description
/**
* Print the buttons relating to course requests.
*
- * @param object $context current page context.
+ * @param context $context current page context.
*/
function print_course_request_buttons($context) {
global $CFG, $DB, $OUTPUT;
if (empty($CFG->enablecourserequests)) {
return;
}
- if (!has_capability('moodle/course:create', $context) && has_capability('moodle/course:request', $context)) {
- /// Print a button to request a new course
- echo $OUTPUT->single_button(new moodle_url('/course/request.php'), get_string('requestcourse'), 'get');
+ if (course_request::can_request($context)) {
+ // Print a button to request a new course.
+ $params = [];
+ if ($context instanceof context_coursecat) {
+ $params['category'] = $context->instanceid;
+ }
+ echo $OUTPUT->single_button(new moodle_url('/course/request.php', $params),
+ get_string('requestcourse'), 'get');
}
/// Print a button to manage pending requests
if (has_capability('moodle/site:approvecourse', $context)) {
$data->requester = $USER->id;
// Setting the default category if none set.
- if (empty($data->category) || empty($CFG->requestcategoryselection)) {
+ if (empty($data->category) || !empty($CFG->lockrequestcategory)) {
$data->category = $CFG->defaultrequestcategory;
}
return $this->properties->collision;
}
+ /**
+ * Checks user capability to approve a requested course
+ *
+ * If course was requested without category for some reason (might happen if $CFG->defaultrequestcategory is
+ * misconfigured), we check capabilities 'moodle/site:approvecourse' and 'moodle/course:changecategory'.
+ *
+ * @return bool
+ */
+ public function can_approve() {
+ global $CFG;
+ $category = null;
+ if ($this->properties->category) {
+ $category = core_course_category::get($this->properties->category, IGNORE_MISSING);
+ } else if ($CFG->defaultrequestcategory) {
+ $category = core_course_category::get($CFG->defaultrequestcategory, IGNORE_MISSING);
+ }
+ if ($category) {
+ return has_capability('moodle/site:approvecourse', $category->get_context());
+ }
+
+ // We can not determine the context where the course should be created. The approver should have
+ // both capabilities to approve courses and change course category in the system context.
+ return has_all_capabilities(['moodle/site:approvecourse', 'moodle/course:changecategory'], context_system::instance());
+ }
+
/**
* Returns the category where this course request should be created
*
*/
public function get_category() {
global $CFG;
- // If the category is not set, if the current user does not have the rights to change the category, or if the
- // category does not exist, we set the default category to the course to be approved.
- // The system level is used because the capability moodle/site:approvecourse is based on a system level.
- if (empty($this->properties->category) || !has_capability('moodle/course:changecategory', context_system::instance()) ||
- (!$category = core_course_category::get($this->properties->category, IGNORE_MISSING, true))) {
- $category = core_course_category::get($CFG->defaultrequestcategory, IGNORE_MISSING, true);
- }
- if (!$category) {
- $category = core_course_category::get_default();
+ if ($this->properties->category && ($category = core_course_category::get($this->properties->category, IGNORE_MISSING))) {
+ return $category;
+ } else if ($CFG->defaultrequestcategory &&
+ ($category = core_course_category::get($CFG->defaultrequestcategory, IGNORE_MISSING))) {
+ return $category;
+ } else {
+ return core_course_category::get_default();
}
- return $category;
}
/**
$eventdata->notification = 1;
message_send($eventdata);
}
+
+ /**
+ * Checks if current user can request a course in this context
+ *
+ * @param context $context
+ * @return bool
+ */
+ public static function can_request(context $context) {
+ global $CFG;
+ if (empty($CFG->enablecourserequests)) {
+ return false;
+ }
+ if (has_capability('moodle/course:create', $context)) {
+ return false;
+ }
+
+ if ($context instanceof context_system) {
+ $defaultcontext = context_coursecat::instance($CFG->defaultrequestcategory, IGNORE_MISSING);
+ return $defaultcontext &&
+ has_capability('moodle/course:request', $defaultcontext);
+ } else if ($context instanceof context_coursecat) {
+ if (!$CFG->lockrequestcategory || $CFG->defaultrequestcategory == $context->instanceid) {
+ return has_capability('moodle/course:request', $context);
+ }
+ }
+ return false;
+ }
}
/**
$reject = optional_param('reject', 0, PARAM_INT);
$baseurl = $CFG->wwwroot . '/course/pending.php';
-admin_externalpage_setup('coursespending');
+$context = context_system::instance();
+if (has_capability('moodle/site:approvecourse', $context)) {
+ // Similar to course management capabilities, if user has approve capability in system context
+ // we add the link to the admin menu. Otherwise we check if user has capability anywhere.
+ admin_externalpage_setup('coursespending');
+} else {
+ require_login(null, false);
+ $categories = core_course_category::make_categories_list('moodle/site:approvecourse');
+ if (!$categories) {
+ require_capability('moodle/site:approvecourse', $context);
+ }
+ $PAGE->set_context($context);
+ $PAGE->set_url(new moodle_url('/course/pending.php'));
+}
/// Process approval of a course.
if (!empty($approve) and confirm_sesskey()) {
$courseid = $course->approve();
if ($courseid !== false) {
- redirect(new moodle_url('/course/edit.php', ['id' => $courseid, 'returnto' => 'pending']));
+ if (has_capability('moodle/course:update', context_course::instance($courseid))) {
+ redirect(new moodle_url('/course/edit.php', ['id' => $courseid, 'returnto' => 'pending']));
+ } else {
+ redirect(new moodle_url('/course/view.php', ['id' => $courseid]));
+ }
} else {
print_error('courseapprovedfailed');
}
// Check here for shortname collisions and warn about them.
$course->check_shortname_collision();
+ if (!$course->can_approve()) {
+ continue;
+ }
$category = $course->get_category();
$row = array();
// Where we came from. Used in a number of redirects.
$url = new moodle_url('/course/request.php');
$return = optional_param('return', null, PARAM_ALPHANUMEXT);
+$categoryid = optional_param('category', null, PARAM_INT);
if ($return === 'management') {
$url->param('return', $return);
$returnurl = new moodle_url('/course/management.php', array('categoryid' => $CFG->defaultrequestcategory));
if (empty($CFG->enablecourserequests)) {
print_error('courserequestdisabled', '', $returnurl);
}
-$context = context_system::instance();
+
+if ($CFG->lockrequestcategory) {
+ // Course request category is locked, user will always request in the default request category.
+ $categoryid = null;
+} else if (!$categoryid) {
+ // Category selection is enabled but category is not specified.
+ // Find a category where user has capability to request courses (preferably the default category).
+ $list = core_course_category::make_categories_list('moodle/course:request');
+ $categoryid = array_key_exists($CFG->defaultrequestcategory, $list) ? $CFG->defaultrequestcategory : key($list);
+}
+
+$context = context_coursecat::instance($categoryid ?: $CFG->defaultrequestcategory);
$PAGE->set_context($context);
require_capability('moodle/course:request', $context);
// Set up the form.
-$data = course_request::prepare();
+$data = $categoryid ? (object)['category' => $categoryid] : null;
+$data = course_request::prepare($data);
$requestform = new course_request_form($url);
$requestform->set_data($data);
$mform->addRule('shortname', get_string('missingshortname'), 'required', null, 'client');
$mform->setType('shortname', PARAM_TEXT);
- if (!empty($CFG->requestcategoryselection)) {
- $displaylist = core_course_category::make_categories_list();
+ if (empty($CFG->lockrequestcategory)) {
+ $displaylist = core_course_category::make_categories_list('moodle/course:request');
$mform->addElement('select', 'category', get_string('coursecategory'), $displaylist);
$mform->setDefault('category', $CFG->defaultrequestcategory);
$mform->addHelpButton('category', 'coursecategory');
--- /dev/null
+@core @core_course
+Feature: Users can request and approve courses
+ As a moodle admin
+ In order to improve course creation process
+ I need to be able to enable course approval
+
+ Background:
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | user1 | User | 1 | user1@example.com |
+ | user2 | User | 2 | user2@example.com |
+ | user3 | User | 3 | user3@example.com |
+
+ Scenario: Simple course request workflow
+ Given the following "system role assigns" exist:
+ | user | course | role |
+ | user2 | Acceptance test site | manager |
+ Given I log in as "admin"
+ And I set the following administration settings values:
+ | lockrequestcategory | 1 |
+ And I set the following system permissions of "Authenticated user" role:
+ | capability | permission |
+ | moodle/course:request | Allow |
+ And I log out
+ When I log in as "user1"
+ And I am on course index
+ And I press "Request a course"
+ And I set the following fields to these values:
+ | Course full name | My new course |
+ | Course short name | Mynewcourse |
+ | Supporting information | pretty please |
+ And I press "Request a course"
+ And I should see "Your course request has been saved successfully."
+ And I press "Continue"
+ And I am on course index
+ And I should not see "My new course"
+ And I log out
+ And I log in as "user2"
+ And I am on course index
+ And I press "Courses pending approval"
+ And I should see "Miscellaneous" in the "My new course" "table_row"
+ And I click on "Approve" "button" in the "My new course" "table_row"
+ And I press "Save and return"
+ And I should see "There are no courses pending approval"
+ And I press "Back to course listing"
+ And I should see "My new course"
+ And I log out
+ And I log in as "user1"
+ And I am on course index
+ And I follow "My new course"
+ And I navigate to course participants
+ And I should see "Teacher" in the "User 1" "table_row"
+ And I log out
+
+ Scenario: Course request with category selection
+ Given the following "categories" exist:
+ | name | category | idnumber |
+ | Science category | 0 | SCI |
+ | English category | 0 | ENG |
+ | Other category | 0 | MISC |
+ Given the following "roles" exist:
+ | name | shortname | description | archetype |
+ | Course requestor | courserequestor | My custom role 1 | |
+ And the following "role assigns" exist:
+ | user | role | contextlevel | reference |
+ | user1 | courserequestor | Category | SCI |
+ | user1 | courserequestor | Category | ENG |
+ | user2 | manager | Category | SCI |
+ | user3 | manager | Category | ENG |
+ Given I log in as "admin"
+ And I set the following system permissions of "Course requestor" role:
+ | capability | permission |
+ | moodle/course:request | Allow |
+ And I log out
+ And I log in as "user1"
+ And I am on course index
+ And I follow "English category"
+ And I press "Request a course"
+ And the field "Course category" matches value "English category"
+ And I set the following fields to these values:
+ | Course full name | My new course |
+ | Course short name | Mynewcourse |
+ | Supporting information | pretty please |
+ And I press "Request a course"
+ And I log out
+ And I log in as "user2"
+ And I am on course index
+ And I follow "English category"
+ And "Courses pending approval" "button" should not exist
+ And I am on course index
+ And I follow "Science category"
+ And I press "Courses pending approval"
+ And I should not see "Mynewcourse"
+ And I press "Back to course listing"
+ And I log out
+ And I log in as "user3"
+ And I am on course index
+ And I follow "English category"
+ And I press "Courses pending approval"
+ And I should see "English category" in the "Mynewcourse" "table_row"
+ And I click on "Approve" "button" in the "Mynewcourse" "table_row"
+ And I press "Save and return"
+ And I am on course index
+ And I follow "English category"
+ And I should see "My new course"
+ And I log out
course_delete_module($moduleinstances[$indextodelete]->cmid, true); // Try to delete the instance asynchronously.
$this->assertEquals($expected, course_modules_pending_deletion($course->id, $gradable));
}
+
+ /**
+ * Tests for the course_request::can_request
+ */
+ public function test_can_request_course() {
+ global $CFG, $DB;
+ $this->resetAfterTest();
+
+ $user = $this->getDataGenerator()->create_user();
+ $cat1 = $CFG->defaultrequestcategory;
+ $cat2 = $this->getDataGenerator()->create_category()->id;
+ $cat3 = $this->getDataGenerator()->create_category()->id;
+ $context1 = context_coursecat::instance($cat1);
+ $context2 = context_coursecat::instance($cat2);
+ $context3 = context_coursecat::instance($cat3);
+ $this->setUser($user);
+
+ // By default users don't have capability to request courses.
+ $this->assertFalse(course_request::can_request(context_system::instance()));
+ $this->assertFalse(course_request::can_request($context1));
+ $this->assertFalse(course_request::can_request($context2));
+ $this->assertFalse(course_request::can_request($context3));
+
+ // Allow for the 'user' role the capability to request courses.
+ $userroleid = $DB->get_field('role', 'id', ['shortname' => 'user']);
+ assign_capability('moodle/course:request', CAP_ALLOW, $userroleid,
+ context_system::instance()->id);
+ accesslib_clear_all_caches_for_unit_testing();
+
+ // Lock category selection.
+ $CFG->lockrequestcategory = 1;
+
+ // Now user can only request course in the default category or in system context.
+ $this->assertTrue(course_request::can_request(context_system::instance()));
+ $this->assertTrue(course_request::can_request($context1));
+ $this->assertFalse(course_request::can_request($context2));
+ $this->assertFalse(course_request::can_request($context3));
+
+ // Enable category selection. User can request course anywhere.
+ $CFG->lockrequestcategory = 0;
+ $this->assertTrue(course_request::can_request(context_system::instance()));
+ $this->assertTrue(course_request::can_request($context1));
+ $this->assertTrue(course_request::can_request($context2));
+ $this->assertTrue(course_request::can_request($context3));
+
+ // Remove cap from cat2.
+ $roleid = create_role('Test role', 'testrole', 'Test role description');
+ assign_capability('moodle/course:request', CAP_PROHIBIT, $roleid,
+ $context2->id, true);
+ role_assign($roleid, $user->id, $context2->id);
+ accesslib_clear_all_caches_for_unit_testing();
+
+ $this->assertTrue(course_request::can_request(context_system::instance()));
+ $this->assertTrue(course_request::can_request($context1));
+ $this->assertFalse(course_request::can_request($context2));
+ $this->assertTrue(course_request::can_request($context3));
+
+ // Disable course request functionality.
+ $CFG->enablecourserequests = false;
+ $this->assertFalse(course_request::can_request(context_system::instance()));
+ $this->assertFalse(course_request::can_request($context1));
+ $this->assertFalse(course_request::can_request($context2));
+ $this->assertFalse(course_request::can_request($context3));
+ }
+
+ /**
+ * Tests for the course_request::can_approve
+ */
+ public function test_can_approve_course_request() {
+ global $CFG;
+ $this->resetAfterTest();
+
+ $requestor = $this->getDataGenerator()->create_user();
+ $user = $this->getDataGenerator()->create_user();
+ $cat1 = $CFG->defaultrequestcategory;
+ $cat2 = $this->getDataGenerator()->create_category()->id;
+ $cat3 = $this->getDataGenerator()->create_category()->id;
+
+ // Enable course requests. Default 'user' role has capability to request courses.
+ $CFG->enablecourserequests = true;
+ $CFG->lockrequestcategory = 0;
+ $this->setUser($requestor);
+ $requestdata = ['summary_editor' => ['text' => '', 'format' => 0], 'name' => 'Req', 'reason' => 'test'];
+ $request1 = course_request::create((object)($requestdata));
+ $request2 = course_request::create((object)($requestdata + ['category' => $cat2]));
+ $request3 = course_request::create((object)($requestdata + ['category' => $cat3]));
+
+ $this->setUser($user);
+ // Add capability to approve courses.
+ $roleid = create_role('Test role', 'testrole', 'Test role description');
+ assign_capability('moodle/site:approvecourse', CAP_ALLOW, $roleid,
+ context_system::instance()->id, true);
+ role_assign($roleid, $user->id, context_coursecat::instance($cat2)->id);
+ accesslib_clear_all_caches_for_unit_testing();
+
+ $this->assertFalse($request1->can_approve());
+ $this->assertTrue($request2->can_approve());
+ $this->assertFalse($request3->can_approve());
+
+ // Delete category where course was requested. Now only site-wide manager can approve it.
+ core_course_category::get($cat2, MUST_EXIST, true)->delete_full(false);
+ $this->assertFalse($request2->can_approve());
+
+ $this->setAdminUser();
+ $this->assertTrue($request2->can_approve());
+ }
}
$defaultcategory = $DB->get_field_select('course_categories', "MIN(id)", "parent=0");
set_config('enablecourserequests', 1);
- set_config('requestcategoryselection', 0);
+ set_config('lockrequestcategory', 1);
set_config('defaultrequestcategory', $defaultcategory);
// Create some categories.
// Request with category different than default and category selection allowed.
set_config('defaultrequestcategory', $cat3->id);
- set_config('requestcategoryselection', 1);
+ set_config('lockrequestcategory', 0);
$data->category = $cat1->id;
$cr = course_request::create($data);
$this->assertEquals($cat1->id, $cr->category);
$defaultcategory = $DB->get_field_select('course_categories', "MIN(id)", "parent=0");
set_config('enablecourserequests', 1);
- set_config('requestcategoryselection', 0);
+ set_config('lockrequestcategory', 1);
set_config('defaultrequestcategory', $defaultcategory);
// Create some categories.
$cat1 = $this->getDataGenerator()->create_category();
$cat2 = $this->getDataGenerator()->create_category();
+ // Create a user and allow course requests for him.
$requester = $this->getDataGenerator()->create_user();
+ $roleid = create_role('Course requestor role', 'courserequestor', '');
+ assign_capability('moodle/course:request', CAP_ALLOW, $roleid,
+ context_system::instance()->id);
+ role_assign($roleid, $requester->id, context_system::instance()->id);
+ accesslib_clear_all_caches_for_unit_testing();
$data = new stdClass();
$data->fullname = 'Həllo World!';
$this->assertEquals($defaultcategory, $course->category);
// Test with category.
- set_config('requestcategoryselection', 1);
+ set_config('lockrequestcategory', 0);
set_config('defaultrequestcategory', $cat2->id);
$data->shortname .= ' 2nd';
$data->category = $cat1->id;
$this->setAdminUser();
set_config('enablecourserequests', 1);
- set_config('requestcategoryselection', 0);
+ set_config('lockrequestcategory', 1);
set_config('defaultrequestcategory', $DB->get_field_select('course_categories', "MIN(id)", "parent=0"));
+ // Create a user and allow course requests for him.
$requester = $this->getDataGenerator()->create_user();
+ $roleid = create_role('Course requestor role', 'courserequestor', '');
+ assign_capability('moodle/course:request', CAP_ALLOW, $roleid,
+ context_system::instance()->id);
+ role_assign($roleid, $requester->id, context_system::instance()->id);
+ accesslib_clear_all_caches_for_unit_testing();
$data = new stdClass();
$data->fullname = 'Həllo World!';
],
'isvalid' => get_string('completionnotenabledforcourse', 'completion')
],
+ 'coursehiddentraining' => [
+ 'params' => [
+ 'enablecompletion' => 1,
+ 'startdate' => mktime(0, 0, 0, $month - 1, 24, $year - 1),
+ 'enddate' => mktime(0, 0, 0, $month - 1, 23, $year),
+ 'students' => true,
+ 'visible' => '0',
+ ],
+ 'isvalid' => true,
+ ],
+ 'coursehiddenprediction' => [
+ 'params' => [
+ 'enablecompletion' => 1,
+ 'startdate' => mktime(0, 0, 0, $month - 1, 24, $year),
+ 'enddate' => mktime(0, 0, 0, $month - 1, 23, $year + 1),
+ 'students' => true,
+ 'visible' => '0',
+ ],
+ 'isvalid' => get_string('hiddenfromstudents'),
+ 'fortraining' => false
+ ],
];
}
],
'enrol-partly-active-ends-afterwards' => [
'starttime' => $now,
- 'endtime' => $now + (WEEKSECS * 9),
- 'timestart' => $now + (WEEKSECS * 10),
+ 'endtime' => $now + (WEEKSECS * 10),
+ 'timestart' => $now + (WEEKSECS * 9),
'timeend' => $now + (WEEKSECS * 11),
'nullcalculation' => true,
],
'timeend' => false,
'nullcalculation' => true,
],
- 'enrol-mostly-active-no-enrolment-end' => [
+ 'enrol-mostly-active-no-enrolment-end-false' => [
'starttime' => $now,
'endtime' => $now + (WEEKSECS * 10),
'timestart' => $now + WEEKSECS,
'timeend' => false,
- 'nullcalculation' => true,
+ 'nullcalculation' => false,
+ ],
+ 'enrol-mostly-active-no-enrolment-end-zero' => [
+ 'starttime' => $now,
+ 'endtime' => $now + (WEEKSECS * 10),
+ 'timestart' => $now + WEEKSECS,
+ 'timeend' => 0,
+ 'nullcalculation' => false,
+ ],
+ 'enrol-no-enrolment-start-false' => [
+ 'starttime' => $now,
+ 'endtime' => $now + (WEEKSECS * 10),
+ 'timestart' => false,
+ 'timeend' => $now + (WEEKSECS * 9),
+ 'nullcalculation' => false,
+ ],
+ 'enrol-no-enrolment-start-zero' => [
+ 'starttime' => $now,
+ 'endtime' => $now + (WEEKSECS * 10),
+ 'timestart' => 0,
+ 'timeend' => $now + (WEEKSECS * 9),
+ 'nullcalculation' => false,
],
'no-start' => [
'starttime' => 0,
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
- * Privacy Subsystem implementation for filter_h5p.
+ * Privacy Subsystem implementation for filter_displayh5p.
*
- * @package filter_h5p
+ * @package filter_displayh5p
* @copyright 2019 Victor Deniz <victor@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-namespace filter_h5p\privacy;
+namespace filter_displayh5p\privacy;
defined('MOODLE_INTERNAL') || die;
/**
- * Privacy Subsystem for filter_h5p implementing null_provider.
+ * Privacy Subsystem for filter_displayh5p implementing null_provider.
*
* @copyright 2019 Victor Deniz <victor@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
- * H5P filter
+ * Display H5P filter
*
- * @package filter_h5p
+ * @package filter_displayh5p
* @copyright 2019 Victor Deniz <victor@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die;
/**
- * H5P filter
+ * Display H5P filter
*
* This filter will replace any occurrence of H5P URLs with the corresponding H5P content embed code
*
- * @package filter_h5p
+ * @package filter_displayh5p
* @copyright 2019 Victor Deniz <victor@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class filter_h5p extends moodle_text_filter {
+class filter_displayh5p extends moodle_text_filter {
/**
* @var boolean $loadresizerjs This is whether to request the resize.js script.
return $text;
}
- $allowedsources = get_config('filter_h5p', 'allowedsources');
+ $allowedsources = get_config('filter_displayh5p', 'allowedsources');
$allowedsources = array_filter(array_map('trim', explode("\n", $allowedsources)));
if (empty($allowedsources)) {
return $text;
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
- * Strings for filter_h5p
+ * Strings for filter_displayh5p
*
- * @package filter_h5p
+ * @package filter_displayh5p
* @copyright 2019 Victor Deniz <victor@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
<b>[id]</b> is a placeholder for the H5P content id in the external source.
<b>*</b> wildcard is supported. For example, *.example.com will embed H5P content from any subdomain of example.com, but not from the example.com domain.';
-$string['filtername'] = 'H5P';
+$string['filtername'] = 'Display H5P';
$string['privacy:metadata'] = 'This H5P filter does not store any personal data.';
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
- * H5P filter settings
+ * Display H5P filter settings
*
- * @package filter_h5p
+ * @package filter_displayh5p
* @copyright 2019 Victor Deniz <victor@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die;
if ($ADMIN->fulltree) {
- $settings->add(new admin_setting_configtextarea('filter_h5p/allowedsources', get_string('allowedsourceslist', 'filter_h5p'),
- get_string('allowedsourceslistdesc', 'filter_h5p'),
+ $settings->add(new admin_setting_configtextarea(
+ 'filter_displayh5p/allowedsources',
+ get_string('allowedsourceslist',
+ 'filter_displayh5p'),
+ get_string('allowedsourceslistdesc', 'filter_displayh5p'),
"https://h5p.org/h5p/embed/[id]\nhttps://*.h5p.com/content/[id]/embed\nhttps://*.h5p.com/content/[id]"));
}
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
- * Unit tests for the filter_h5p
+ * Unit tests for the filter_displayh5p
*
- * @package filter_h5p
+ * @package filter_displayh5p
* @category test
* @copyright 2019 Victor Deniz <victor@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
defined('MOODLE_INTERNAL') || die();
global $CFG;
-require_once($CFG->dirroot.'/filter/h5p/filter.php');
+require_once($CFG->dirroot.'/filter/displayh5p/filter.php');
/**
- * Unit tests for the H5P filter.
+ * Unit tests for the display H5P filter.
*
* @copyright 2019 Victor Deniz <victor@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class filter_h5p_testcase extends advanced_testcase {
+class filter_displayh5p_testcase extends advanced_testcase {
public function setUp() {
parent::setUp();
$this->resetAfterTest(true);
- set_config('allowedsources', "https://h5p.org/h5p/embed/[id]\nhttps://*.h5p.com/content/[id]/embed\nhttps://*.h5p.com/content/[id]
- \nhttps://generic.wordpress.soton.ac.uk/altc/wp-admin/admin-ajax.php?action=h5p_embed&id=[id]", 'filter_h5p');
- // Enable h5p filter at top level.
- filter_set_global_state('h5p', TEXTFILTER_ON);
+ set_config('allowedsources',
+ "https://h5p.org/h5p/embed/[id]\nhttps://*.h5p.com/content/[id]/embed\nhttps://*.h5p.com/content/[id]
+ \nhttps://generic.wordpress.soton.ac.uk/altc/wp-admin/admin-ajax.php?action=h5p_embed&id=[id]",
+ 'filter_displayh5p');
+ // Enable display h5p filter at top level.
+ filter_set_global_state('displayh5p', TEXTFILTER_ON);
}
/**
* Check that h5p tags with urls from allowed domains are filtered.
*
* @param string $text Original text
- * @param string $filteredtextpattern Text pattern after H5P filter
+ * @param string $filteredtextpattern Text pattern after display H5P filter
*
* @dataProvider texts_provider
*/
public function test_filter_urls($text, $filteredtextpattern) {
- $filterplugin = new filter_h5p(null, array());
+ $filterplugin = new filter_displayh5p(null, array());
$filteredtext = $filterplugin->filter($text);
$this->assertRegExp($filteredtextpattern, $filteredtext);
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
- * Version of filter_h5p.
+ * Version of filter_displayh5p.
*
- * @package filter_h5p
+ * @package filter_displayh5p
* @copyright 2019 Victor Deniz <victor@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die;
-$plugin->version = 2019092000;
+$plugin->version = 2019101800;
$plugin->requires = 2019092000;
-$plugin->component = 'filter_h5p';
+$plugin->component = 'filter_displayh5p';
'numusers' => new external_value(PARAM_INT, 'Num users in course', VALUE_OPTIONAL),
'averageformatted' => new external_value(PARAM_NOTAGS, 'Grade average', VALUE_OPTIONAL),
'feedback' => new external_value(PARAM_RAW, 'Grade feedback', VALUE_OPTIONAL),
- 'feedbackformat' => new external_format_value('feedback'),
+ 'feedbackformat' => new external_format_value('feedback', VALUE_OPTIONAL),
), 'Grade items'
)
)
$string['componentisuptodate'] = 'Το στοιχείο λογισμικού είναι ενημερωμένο.';
$string['dmlexceptiononinstall'] = '<p>Παρουσιάστηκε κάποιο σφάλμα βάσης δεδομένων [{$a->errorcode}].<br />{$a->debuginfo}</p>';
$string['downloadedfilecheckfailed'] = 'Αποτυχία ελέγχου αρχείου που έγινε λήψη';
-$string['invalidmd5'] = 'Î\9cη ÎγκÏ\85Ï\81ο md5';
+$string['invalidmd5'] = 'Î\97 μεÏ\84αβληÏ\84ή ελÎγÏ\87οÏ\85 ήÏ\84αν λανθαÏ\83μÎνη - δοκιμάÏ\83Ï\84ε ξανά';
$string['missingrequiredfield'] = 'Κάποιο απαιτούμενο πεδίο λείπει';
$string['remotedownloaderror'] = '<p>Απέτυχε η λήψη του τμήματος στον εξυπηρετητή σας. Παρακαλούμε επιβεβαιώστε τις ρυθμίσεις του διακομιστή μεσολάβησης (proxy)· η επέκταση PHP cURL συνιστάται θερμά.</p> <p>Πρέπει να κατεβάσετε το <a href="{$a->url}">{$a->url}</a> χειροκίνητα, να το αντιγράψετε στο «{$a->dest}» στον εξυπηρετητή σας και να το αποσυμπιέσετε εκεί.</p>';
$string['wrongdestpath'] = 'Λανθασμένη διαδρομή προορισμού (πλήρες όνομα).';
$string['datarootpermission'] = 'Droits d\'accès au dossier de données';
$string['dbprefix'] = 'Préfixe des tables';
$string['dirroot'] = 'Dossier Moodle';
-$string['environmenthead'] = 'Vérification de l\'environnement...';
+$string['environmenthead'] = 'Vérification de l\'environnement…';
$string['environmentsub2'] = 'Chaque version de Moodle nécessite une version minimale de certains composants PHP et des extensions de PHP obligatoires. Une vérification complète de l\'environnement est effectuée avec chaque installation et chaque mise à jour. Veuillez contacter l\'administrateur du serveur si vous ne savez pas comment installer une nouvelle version ou activer des extensions de PHP.';
$string['errorsinenvironment'] = 'Échec de la vérification de l\'environnement !';
$string['installation'] = 'Installation';
$string['allowediplist'] = 'Allowed IP list';
$string['allowedemaildomains'] = 'Allowed email domains';
$string['allowemailaddresses'] = 'Allowed email domains';
+$string['allowemojipicker'] = 'Enable emoji picker';
$string['allowindexing'] = 'Allow indexing by search engines';
$string['allowindexing_desc'] = 'This determines whether to allow search engines to index your site. "Everywhere" will allow the search engines to search everywhere including login and signup pages, which means sites with Force Login turned on are still indexed. To avoid the risk of spam involved with the signup page being searchable, use "Everywhere except login and signup pages". "Nowhere" will tell search engines not to index any page. Note this is only a tag in the header of the site. It is up to the search engine to respect the tag.';
$string['allowindexingeverywhere'] = 'Everywhere';
$string['configallowcoursethemes'] = 'If you enable this, then courses will be allowed to set their own themes. Course themes override all other theme choices (site, user, or session themes)';
$string['configallowedemaildomains'] = 'List email domains that are allowed to be disclosed in the "From" section of outgoing email. The default of "Empty" will use the No-reply address for all outgoing email. The use of wildcards is allowed e.g. *.example.com will allow emails sent from any subdomain of example.com, but not example.com itself. This will require separate entry.';
$string['configallowemailaddresses'] = 'To restrict new email addresses to particular domains, list them here separated by spaces. All other domains will be rejected. To allow subdomains, add the domain with a preceding \'.\'. To allow a root domain together with its subdomains, add the domain twice - once with a preceding \'.\' and once without e.g. .ourcollege.edu.au ourcollege.edu.au.';
+$string['configallowemojipicker'] = 'If enabled, the emoji picker will be available within the site';
+$string['configallowemojipickerincompatible'] = 'Your current database configuration does not properly support emojis. In order to enable the emoji picker you will need to <a href="https://docs.moodle.org/37/en/MySQL_full_unicode_support">upgrade your database for full unicode support</a>.';
$string['configallowguestmymoodle'] = 'If enabled guests can access Dashboard, otherwise guests are redirected to the site front page.';
$string['configallowobjectembed'] = 'As a default security measure, normal users are not allowed to embed multimedia (like Flash) within texts using explicit EMBED and OBJECT tags in their HTML (although it can still be done safely using the mediaplugins filter). If you wish to allow these tags then enable this option.';
$string['configallowoverride'] = 'You can allow people with the roles on the left side to override some of the column roles';
$string['configdebugpageinfo'] = 'Enable if you want page information printed in page footer.';
$string['configdebugvalidators'] = 'Enable if you want to have links to external validator servers in page footer. You may need to create new user with username <em>w3cvalidator</em>, and enable guest access. These changes may allow unauthorized access to server, do not enable on production sites!';
$string['configdefaulthomepage'] = 'This determines the first link in the navigation for logged-in users.';
-$string['configdefaultrequestcategory'] = 'Courses requested by users will be automatically placed in this category.';
+$string['configdefaultrequestcategory'] = 'Courses requested by users will be placed in this category if the category is not specified.';
$string['configdefaultrequestedcategory'] = 'Default category to put courses that were requested into, if they\'re approved.';
$string['configdefaultuserroleid'] = 'All logged in users will be given the capabilities of the role you specify here, at the site level, in ADDITION to any other roles they may have been given. The default is the Authenticated user role. Note that this will not conflict with other roles they have unless you prohibit capabilities, it just ensures that all users have capabilities that are not assignable at the course level (eg post blog entries, manage own calendar, etc).';
$string['configdeleteincompleteusers'] = 'After this period, any account without the first name, last name or email field filled in is deleted.';
$string['configemailsubjectprefix'] = 'Text to be prefixed to the subject line of all outgoing mail.';
$string['configenablecalendarexport'] = 'Enable exporting or subscribing to calendars.';
$string['configenablecomments'] = 'Enable comments';
-$string['configenablecourserequests'] = 'This will allow any user to request a course be created.';
+$string['configenablecourserequests'] = 'Enable course request functionality. Users with capability to request courses but without capability to create courses will be able to request courses.';
$string['configenablemobilewebservice'] = 'Enable mobile service for the official Moodle app or other app requesting it. For more information, read the {$a}';
$string['configenablerssfeeds'] = 'If enabled, RSS feeds are generated by various features across the site, such as blogs, forums, database activities and glossaries. Note that RSS feeds also need to be enabled for the particular activity modules.';
$string['configenablerssfeedsdisabled'] = 'It is not available because RSS feeds are disabled in all the Site. To enable them, go to the Variables settings under Admin Configuration.';
$string['configlangmenu'] = 'Choose whether or not you want to display the general-purpose language menu on the home page, login page etc. This does not affect the user\'s ability to set the preferred language in their own profile.';
$string['configlatinexcelexport'] = 'Choose the encoding for Excel exports.';
$string['configlocale'] = 'Choose a sitewide locale - this will override the format and language of dates for all language packs (though names of days in calendar are not affected). You need to have this locale data installed on your operating system (eg for linux en_US.UTF-8 or es_ES.UTF-8). In most cases this field should be left blank.';
+$string['configlockrequestcategory'] = 'Only allow course requests in the default course request category. This is a legacy setting, it is better not to use it but instead assign capability to request courses in the appropriate course category context';
$string['configloglifetime'] = 'This specifies the length of time you want to keep logs about user activity. Logs that are older than this age are automatically deleted. It is best to keep logs as long as possible, in case you need them, but if you have a very busy server and are experiencing performance problems, then you may want to lower the log lifetime. Values lower than 30 are not recommended because statistics may not work properly.';
$string['configlookahead'] = 'Days to look ahead';
$string['configmailnewline'] = 'Newline characters used in mail messages. CRLF is required according to RFC 822bis, some mail servers do automatic conversion from LF to CRLF, other mail servers do incorrect conversion from CRLF to CRCRLF, yet others reject mails with bare LF (qmail for example). Try changing this setting if you are having problems with undelivered emails or double newlines.';
$string['configproxyuser'] = 'Username needed to access internet through proxy if required, empty if none (PHP cURL extension required).';
$string['configrecaptchaprivatekey'] = 'String of characters (secret key) used to communicate between your Moodle server and the recaptcha server. ReCAPTCHA keys can be obtained from <a target="_blank" href="https://www.google.com/recaptcha">Google reCAPTCHA</a>.';
$string['configrecaptchapublickey'] = 'String of characters (site key) used to display the reCAPTCHA element in the signup form. ReCAPTCHA keys can be obtained from <a target="_blank" href="https://www.google.com/recaptcha">Google reCAPTCHA</a>.';
-$string['configrequestcategoryselection'] = 'Allow the selection of a category when requesting a course.';
$string['configrequestedstudentname'] = 'Word for student used in requested courses';
$string['configrequestedstudentsname'] = 'Word for students used in requested courses';
$string['configrequestedteachername'] = 'Word for teacher used in requested courses';
$string['lockoutthreshold_desc'] = 'Select number of failed login attempts that result in account lockout. This feature may be abused in denial of service attacks.';
$string['lockoutwindow'] = 'Account lockout observation window';
$string['lockoutwindow_desc'] = 'Observation time for lockout threshold, if there are no failed attempts the threshold counter is reset after this time.';
+$string['lockrequestcategory'] = 'Lock category for the course requests';
$string['log'] = 'Logs';
$string['logguests'] = 'Log guest access';
$string['logguests_help'] = 'This setting enables logging of actions by guest account and not logged in users. High profile sites may want to disable this logging for performance reasons. It is recommended to keep this setting enabled on production sites.';
$string['profilingenabled'] = 'Enable profiling';
$string['profilingenabled_help'] = 'If you enable this setting, then profiling will be available in this site and you will be able to define its behavior by configuring the next options.';
$string['profilingexcluded'] = 'Exclude profiling';
-$string['profilingexcluded_help'] = 'List of (comma separated, absolute skipping wwwroot, callable) URLs that will be excluded from being profiled from the ones defined by \'Profile these\' setting.';
+$string['profilingexcluded_help'] = 'List of (comma or newline separated, absolute skipping wwwroot, callable) URLs that will be excluded from being profiled from the ones defined by \'Profile these\' setting.';
$string['profilingimportprefix'] = 'Profiling import prefix';
$string['profilingimportprefix_desc'] = 'For easier detection, all the imported profiling runs will be prefixed with the value specified here.';
$string['profilingincluded'] = 'Profile these';
-$string['profilingincluded_help'] = 'List of (comma separated, absolute skipping wwwroot, callable) URLs that will be automatically profiled. Examples: /index.php, /course/view.php. Also accepts the * wildchar at any position. Examples: /mod/forum/*, /mod/*/view.php.';
+$string['profilingincluded_help'] = 'List of (comma or newline separated, absolute skipping wwwroot, callable) URLs that will be automatically profiled. Examples: /index.php, /course/view.php. Also accepts the * wildchar at any position. Examples: /mod/forum/*, /mod/*/view.php.';
$string['profilinglifetime'] = 'Keep profiling runs';
$string['profilinglifetime_help'] = 'Specify the time you want to keep information about old profiling runs. Older ones will be pruned periodically. Note that this excludes any profiling run marked as \'reference run\'.';
$string['profilingslow'] = 'Profile slow pages';
$string['purgeselectedcachesfinished'] = 'The selected caches were purged.';
$string['purgetemplates'] = 'Templates';
$string['purgethemecache'] = 'Themes';
-$string['requestcategoryselection'] = 'Enable category selection';
$string['restorecourse'] = 'Restore course';
$string['restorernewroleid'] = 'Restorers\' role in courses';
$string['restorernewroleid_help'] = 'If the user does not already have the permission to manage the newly restored course, the user is automatically assigned this role and enrolled if necessary. Select "None" if you do not want restorers to be able to manage every restored course.';
$string['registermoodleorgli1'] = 'You are added to a low-volume mailing list for important notifications such as security alerts and new releases of Moodle.';
$string['registermoodleorgli2'] = 'Statistics about your site will be added to the {$a} of the worldwide Moodle community.';
$string['registerwithmoodleorg'] = 'Register your site';
+$string['configrequestcategoryselection'] = 'Allow the selection of a category when requesting a course.';
+$string['requestcategoryselection'] = 'Enable category selection';
$string['errorimportversionmismatches'] = 'The version of the following components differs from the version installed on this site: {$a}. You can use the option \'Ignore version mismatches\' to ignore these differences.';
$string['errorimportmissingclasses'] = 'The following analytics components are not available on this site: {$a->missingclasses}.';
$string['errorinvalidindicator'] = 'Invalid {$a} indicator';
+$string['errorinvalidcontexts'] = 'Some of the selected contexts can not be used in this target.';
$string['errorinvalidtarget'] = 'Invalid {$a} target';
$string['errorinvalidtimesplitting'] = 'Invalid analysis interval; please ensure you add the fully qualified class name.';
+$string['errornocontextrestrictions'] = 'The selected target does not support context restrictions';
$string['errornoexportconfig'] = 'There was a problem exporting the model configuration.';
$string['errornoexportconfigrequirements'] = 'Only non-static models with an analysis interval can be exported.';
$string['errornoindicators'] = 'This model does not have any indicators.';
$string['calendarpreferences'] = 'Calendar preferences';
$string['calendartypes'] = 'Calendar types';
$string['calendarurl'] = 'Calendar URL: {$a}';
+$string['category'] = 'Category';
$string['categoryevent'] = 'Category event';
$string['clickhide'] = 'click to hide';
$string['clickshow'] = 'click to show';
$string['fri'] = 'Fri';
$string['friday'] = 'Friday';
$string['generateurlbutton'] = 'Get calendar URL';
-$string['global'] = 'Global';
-$string['globalevent'] = 'Global event';
-$string['globalevents'] = 'Global events';
$string['gotoactivity'] = 'Go to activity';
$string['gotocalendar'] = 'Go to calendar';
$string['group'] = 'Group';
$string['groupevent'] = 'Group event';
$string['groupevents'] = 'Group events';
-$string['eventtypeglobal'] = 'global';
-$string['eventtypesite'] = 'global';
+$string['eventtypesite'] = 'site';
$string['eventtypecategory'] = 'category';
$string['eventtypecourse'] = 'course';
$string['eventtypemodule'] = 'module';
$string['sat'] = 'Sat';
$string['saturday'] = 'Saturday';
$string['shown'] = 'shown';
+$string['site'] = 'Site';
+$string['siteevent'] = 'Site event';
$string['siteevents'] = 'Site events';
$string['spanningevents'] = 'Events underway';
$string['subscriptions'] = 'Subscriptions';
$string['whendate'] = 'When: {$a}';
$string['yesterday'] = 'Yesterday';
$string['youcandeleteallrepeats'] = 'This event is part of a repeating event series. You can delete this event only, or all {$a} events in the series at once.';
+
+// Deprecated since Moodle 3.8.
+$string['global'] = 'Global';
+$string['globalevent'] = 'Global event';
+$string['globalevents'] = 'Global events';
+$string['eventtypeglobal'] = 'global';
registersite,core_hub
updatesite,core_hub
unregisterexplained,core_hub
+configrequestcategoryselection,core_admin
+requestcategoryselection,core_admin
+global,core_calendar
+globalevent,core_calendar
+globalevents,core_calendar
+eventtypeglobal,core_calendar
$string['emailtoprivatefiles'] = 'You can also e-mail files as attachments straight to your private files space. Simply attach your files to an e-mail and send it to {$a}';
$string['emailtoprivatefilesdenied'] = 'Your administrator has disabled the option to upload your own private files.';
$string['emailvia'] = '{$a->name} (via {$a->siteshortname})';
+$string['emojicategoryactivities'] = 'Activities';
+$string['emojicategoryanimalsnature'] = 'Animals & nature';
+$string['emojicategoryflags'] = 'Flags';
+$string['emojicategoryfooddrink'] = 'Food & drink';
+$string['emojicategoryobjects'] = 'Objects';
+$string['emojicategoryrecent'] = 'Recent';
+$string['emojicategorysmileyspeople'] = 'Smileys & people';
+$string['emojicategorysymbols'] = 'Symbols';
+$string['emojicategorytravelplaces'] = 'Travel & places';
$string['emptydragdropregion'] = 'empty region';
$string['enable'] = 'Enable';
$string['encryptedcode'] = 'Encrypted code';
$string['eventquestionsimported'] = 'Questions imported';
$string['eventquestionupdated'] = 'Question updated';
$string['export'] = 'Export';
+$string['exportasxml'] = 'Export as Moodle XML';
$string['exportcategory'] = 'Export category';
$string['exportcategory_help'] = 'This setting determines the category from which the exported questions will be taken.
--- /dev/null
+// 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/>.
+
+/**
+ * Emoji auto complete.
+ *
+ * @copyright 2019 Ryan Wyllie <ryan@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+import * as EmojiData from 'core/emoji/data';
+import {render as renderTemplate} from 'core/templates';
+import {debounce} from 'core/utils';
+import LocalStorage from 'core/localstorage';
+import KeyCodes from 'core/key_codes';
+
+const INPUT_DEBOUNCE_TIMER = 200;
+const SUGGESTION_LIMIT = 50;
+const MAX_RECENT_COUNT = 27;
+const RECENT_EMOJIS_STORAGE_KEY = 'moodle-recent-emojis';
+
+const SELECTORS = {
+ EMOJI_BUTTON: '[data-region="emoji-button"]',
+ ACTIVE_EMOJI_BUTTON: '[data-region="emoji-button"].active',
+};
+
+/**
+ * Get the list of recent emojis data from local storage.
+ *
+ * @return {Array}
+ */
+const getRecentEmojis = () => {
+ const storedData = LocalStorage.get(RECENT_EMOJIS_STORAGE_KEY);
+ return storedData ? JSON.parse(storedData) : [];
+};
+
+/**
+ * Add an emoji data to the set of recent emojis. The new set of recent emojis are
+ * saved in local storage.
+ *
+ * @param {String} unified The char chodes for the emoji
+ * @param {String} shortName The emoji short name
+ */
+const addRecentEmoji = (unified, shortName) => {
+ const newEmoji = {
+ unified,
+ shortnames: [shortName]
+ };
+ const recentEmojis = getRecentEmojis();
+ // Add the new emoji to the start of the list of recent emojis.
+ let newRecentEmojis = [newEmoji, ...recentEmojis.filter(emoji => emoji.unified != newEmoji.unified)];
+ // Limit the number of recent emojis.
+ newRecentEmojis = newRecentEmojis.slice(0, MAX_RECENT_COUNT);
+
+ LocalStorage.set(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(newRecentEmojis));
+};
+
+/**
+ * Get the actual emoji string from the short name.
+ *
+ * @param {String} shortName Emoji short name
+ * @return {String|null}
+ */
+const getEmojiTextFromShortName = (shortName) => {
+ const unified = EmojiData.byShortName[shortName];
+
+ if (unified) {
+ const charCodes = unified.split('-').map(code => `0x${code}`);
+ return String.fromCodePoint.apply(null, charCodes);
+ } else {
+ return null;
+ }
+};
+
+/**
+ * Render the auto complete list for the given short names.
+ *
+ * @param {Element} root The root container for the emoji auto complete
+ * @param {Array} shortNames The list of short names for emoji suggestions to show
+ */
+const render = async (root, shortNames) => {
+ const renderContext = {
+ emojis: shortNames.map((shortName, index) => {
+ return {
+ active: index === 0,
+ emojitext: getEmojiTextFromShortName(shortName),
+ displayshortname: `:${shortName}:`,
+ shortname: shortName,
+ unified: EmojiData.byShortName[shortName]
+ };
+ })
+ };
+ const html = await renderTemplate('core/emoji/auto_complete', renderContext);
+ root.innerHTML = html;
+};
+
+/**
+ * Get the list of emoji short names that include the given search term. If
+ * the search term is an empty string then the list of recently used emojis
+ * will be returned.
+ *
+ * @param {String} searchTerm Text to match on
+ * @param {Number} limit Maximum number of results to return
+ * @return {Array}
+ */
+const searchEmojis = (searchTerm, limit) => {
+ if (searchTerm === '') {
+ return getRecentEmojis().map(data => data.shortnames[0]).slice(0, limit);
+ } else {
+ searchTerm = searchTerm.toLowerCase();
+ return Object.keys(EmojiData.byShortName)
+ .filter(shortName => shortName.includes(searchTerm))
+ .slice(0, limit);
+ }
+};
+
+/**
+ * Get the current word at the given position (index) within the text.
+ *
+ * @param {String} text The text to process
+ * @param {Number} position The position (index) within the text to match the word
+ * @return {String}
+ */
+const getWordFromPosition = (text, position) => {
+ const startMatches = text.slice(0, position).match(/(\S*)$/);
+ const endMatches = text.slice(position).match(/^(\S*)/);
+ let startText = '';
+ let endText = '';
+
+ if (startMatches) {
+ startText = startMatches[startMatches.length - 1];
+ }
+
+ if (endMatches) {
+ endText = endMatches[endMatches.length - 1];
+ }
+
+ return `${startText}${endText}`;
+};
+
+/**
+ * Check if the given text is a full short name, i.e. has leading and trialing colon
+ * characters.
+ *
+ * @param {String} text The text to process
+ * @return {Bool}
+ */
+const isCompleteShortName = text => /^:[^:\s]+:$/.test(text);
+
+/**
+ * Check if the given text is a partial short name, i.e. has a leading colon but no
+ * trailing colon.
+ *
+ * @param {String} text The text to process
+ * @return {Bool}
+ */
+const isPartialShortName = text => /^:[^:\s]*$/.test(text);
+
+/**
+ * Remove the colon characters from the given text.
+ *
+ * @param {String} text The text to process
+ * @return {String}
+ */
+const getShortNameFromText = text => text.replace(/:/g, '');
+
+/**
+ * Get the currently active emoji button element in the list of suggestions.
+ *
+ * @param {Element} root The emoji auto complete container element
+ * @return {Element|null}
+ */
+const getActiveEmojiSuggestion = (root) => {
+ return root.querySelector(SELECTORS.ACTIVE_EMOJI_BUTTON);
+};
+
+/**
+ * Make the previous sibling of the current active emoji active.
+ *
+ * @param {Element} root The emoji auto complete container element
+ */
+const selectPreviousEmojiSuggestion = (root) => {
+ const activeEmojiSuggestion = getActiveEmojiSuggestion(root);
+ const previousSuggestion = activeEmojiSuggestion.previousElementSibling;
+
+ if (previousSuggestion) {
+ activeEmojiSuggestion.classList.remove('active');
+ previousSuggestion.classList.add('active');
+ previousSuggestion.scrollIntoView({behaviour: 'smooth', inline: 'center'});
+ }
+};
+
+/**
+ * Make the next sibling to the current active emoji active.
+ *
+ * @param {Element} root The emoji auto complete container element
+ */
+const selectNextEmojiSuggestion = (root) => {
+ const activeEmojiSuggestion = getActiveEmojiSuggestion(root);
+ const nextSuggestion = activeEmojiSuggestion.nextElementSibling;
+
+ if (nextSuggestion) {
+ activeEmojiSuggestion.classList.remove('active');
+ nextSuggestion.classList.add('active');
+ nextSuggestion.scrollIntoView({behaviour: 'smooth', inline: 'center'});
+ }
+};
+
+/**
+ * Trigger the select callback for the given emoji button element.
+ *
+ * @param {Element} element The emoji button element
+ * @param {Function} selectCallback The callback for when the user selects an emoji
+ */
+const selectEmojiElement = (element, selectCallback) => {
+ const shortName = element.getAttribute('data-short-name');
+ const unified = element.getAttribute('data-unified');
+ addRecentEmoji(unified, shortName);
+ selectCallback(element.innerHTML.trim());
+};
+
+/**
+ * Initialise the emoji auto complete.
+ *
+ * @param {Element} root The root container element for the auto complete
+ * @param {Element} textArea The text area element to monitor for auto complete
+ * @param {Function} hasSuggestionCallback Callback for when there are auto-complete suggestions
+ * @param {Function} selectCallback Callback for when the user selects an emoji
+ */
+export default (root, textArea, hasSuggestionCallback, selectCallback) => {
+ let hasSuggestions = false;
+ let previousSearchText = '';
+
+ // Debounce the listener so that each keypress delays the execution of the handler. The
+ // handler should only run 200 milliseconds after the last keypress.
+ textArea.addEventListener('keyup', debounce(() => {
+ // This is a "keyup" listener so that it only executes after the text area value
+ // has been updated.
+ const text = textArea.value;
+ const cursorPos = textArea.selectionStart;
+ const searchText = getWordFromPosition(text, cursorPos);
+
+ if (searchText === previousSearchText) {
+ // Nothing has changed so no need to take any action.
+ return;
+ } else {
+ previousSearchText = searchText;
+ }
+
+ if (isCompleteShortName(searchText)) {
+ // If the user has entered a full short name (with leading and trialing colons)
+ // then see if we can find a match for it and auto complete it.
+ const shortName = getShortNameFromText(searchText);
+ const emojiText = getEmojiTextFromShortName(shortName);
+ hasSuggestions = false;
+ if (emojiText) {
+ addRecentEmoji(EmojiData.byShortName[shortName], shortName);
+ selectCallback(emojiText);
+ }
+ } else if (isPartialShortName(searchText)) {
+ // If the user has entered a partial short name (leading colon but no trailing) then
+ // search on the text to see if we can find some suggestions for them.
+ const suggestions = searchEmojis(getShortNameFromText(searchText), SUGGESTION_LIMIT);
+
+ if (suggestions.length) {
+ render(root, suggestions);
+ hasSuggestions = true;
+ } else {
+ hasSuggestions = false;
+ }
+ } else {
+ hasSuggestions = false;
+ }
+
+ hasSuggestionCallback(hasSuggestions);
+ }, INPUT_DEBOUNCE_TIMER));
+
+ textArea.addEventListener('keydown', (e) => {
+ if (hasSuggestions) {
+ const isModifierPressed = (e.shiftKey || e.metaKey || e.altKey || e.ctrlKey);
+ if (!isModifierPressed) {
+ switch (e.which) {
+ case KeyCodes.escape:
+ // Escape key closes the auto complete.
+ hasSuggestions = false;
+ hasSuggestionCallback(false);
+ break;
+ case KeyCodes.arrowLeft:
+ // Arrow keys navigate through the list of suggetions.
+ selectPreviousEmojiSuggestion(root);
+ e.preventDefault();
+ break;
+ case KeyCodes.arrowRight:
+ // Arrow keys navigate through the list of suggetions.
+ selectNextEmojiSuggestion(root);
+ e.preventDefault();
+ break;
+ case KeyCodes.enter:
+ // Enter key selects the current suggestion.
+ selectEmojiElement(getActiveEmojiSuggestion(root), selectCallback);
+ e.preventDefault();
+ e.stopPropagation();
+ break;
+ }
+ }
+ }
+ });
+
+ root.addEventListener('click', (e) => {
+ const target = e.target;
+ if (target.matches(SELECTORS.EMOJI_BUTTON)) {
+ selectEmojiElement(target, selectCallback);
+ }
+ });
+};
\ No newline at end of file
--- /dev/null
+// 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/>.
+
+/**
+ * Emoji data based on the data available from https://github.com/iamcal/emoji-data.
+ *
+ * @copyright 2019 Ryan Wyllie <ryan@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+export const byCategory = [
+ {
+ "name": "Smileys & People",
+ "emojis": [
+ {
+ "unified": "1F600",
+ "shortnames": [
+ "grinning"
+ ]
+ },
+ {
+ "unified": "1F601",
+ "shortnames": [
+ "grin"
+ ]
+ },
+ {
+ "unified": "1F602",
+ "shortnames": [
+ "joy"
+ ]
+ },
+ {
+ "unified": "1F923",
+ "shortnames": [
+ "rolling_on_the_floor_laughing"
+ ]
+ },
+ {
+ "unified": "1F603",
+ "shortnames": [
+ "smiley"
+ ]
+ },
+ {
+ "unified": "1F604",
+ "shortnames": [
+ "smile"
+ ]
+ },
+ {
+ "unified": "1F605",
+ "shortnames": [
+ "sweat_smile"
+ ]
+ },
+ {
+ "unified": "1F606",
+ "shortnames": [
+ "laughing"
+ ]
+ },
+ {
+ "unified": "1F609",
+ "shortnames": [
+ "wink"
+ ]
+ },
+ {
+ "unified": "1F60A",
+ "shortnames": [
+ "blush"
+ ]
+ },
+ {
+ "unified": "1F60B",
+ "shortnames": [
+ "yum"
+ ]
+ },
+ {
+ "unified": "1F60E",
+ "shortnames": [
+ "sunglasses"
+ ]
+ },
+ {
+ "unified": "1F60D",
+ "shortnames": [
+ "heart_eyes"
+ ]
+ },
+ {
+ "unified": "1F618",
+ "shortnames": [
+ "kissing_heart"
+ ]
+ },
+ {
+ "unified": "1F617",
+ "shortnames": [
+ "kissing"
+ ]
+ },
+ {
+ "unified": "1F619",
+ "shortnames": [
+ "kissing_smiling_eyes"
+ ]
+ },
+ {
+ "unified": "1F61A",
+ "shortnames": [
+ "kissing_closed_eyes"
+ ]
+ },
+ {
+ "unified": "263A-FE0F",
+ "shortnames": [
+ "relaxed"
+ ]
+ },
+ {
+ "unified": "1F642",
+ "shortnames": [
+ "slightly_smiling_face"
+ ]
+ },
+ {
+ "unified": "1F917",
+ "shortnames": [
+ "hugging_face"
+ ]
+ },
+ {
+ "unified": "1F929",
+ "shortnames": [
+ "star-struck"
+ ]
+ },
+ {
+ "unified": "1F914",
+ "shortnames": [
+ "thinking_face"
+ ]
+ },
+ {
+ "unified": "1F928",
+ "shortnames": [
+ "face_with_raised_eyebrow"
+ ]
+ },
+ {
+ "unified": "1F610",
+ "shortnames": [
+ "neutral_face"
+ ]
+ },
+ {
+ "unified": "1F611",
+ "shortnames": [
+ "expressionless"
+ ]
+ },
+ {
+ "unified": "1F636",
+ "shortnames": [
+ "no_mouth"
+ ]
+ },
+ {
+ "unified": "1F644",
+ "shortnames": [
+ "face_with_rolling_eyes"
+ ]
+ },
+ {
+ "unified": "1F60F",
+ "shortnames": [
+ "smirk"
+ ]
+ },
+ {
+ "unified": "1F623",
+ "shortnames": [
+ "persevere"
+ ]
+ },
+ {
+ "unified": "1F625",
+ "shortnames": [
+ "disappointed_relieved"
+ ]
+ },
+ {
+ "unified": "1F62E",
+ "shortnames": [
+ "open_mouth"
+ ]
+ },
+ {
+ "unified": "1F910",
+ "shortnames": [
+ "zipper_mouth_face"
+ ]
+ },
+ {
+ "unified": "1F62F",
+ "shortnames": [
+ "hushed"
+ ]
+ },
+ {
+ "unified": "1F62A",
+ "shortnames": [
+ "sleepy"
+ ]
+ },
+ {
+ "unified": "1F62B",
+ "shortnames": [
+ "tired_face"
+ ]
+ },
+ {
+ "unified": "1F634",
+ "shortnames": [
+ "sleeping"
+ ]
+ },
+ {
+ "unified": "1F60C",
+ "shortnames": [
+ "relieved"
+ ]
+ },
+ {
+ "unified": "1F61B",
+ "shortnames": [
+ "stuck_out_tongue"
+ ]
+ },
+ {
+ "unified": "1F61C",
+ "shortnames": [
+ "stuck_out_tongue_winking_eye"
+ ]
+ },
+ {
+ "unified": "1F61D",
+ "shortnames": [
+ "stuck_out_tongue_closed_eyes"
+ ]
+ },
+ {
+ "unified": "1F924",
+ "shortnames": [
+ "drooling_face"
+ ]
+ },
+ {
+ "unified": "1F612",
+ "shortnames": [
+ "unamused"
+ ]
+ },
+ {
+ "unified": "1F613",
+ "shortnames": [
+ "sweat"
+ ]
+ },
+ {
+ "unified": "1F614",
+ "shortnames": [
+ "pensive"
+ ]
+ },
+ {
+ "unified": "1F615",
+ "shortnames": [
+ "confused"
+ ]
+ },
+ {
+ "unified": "1F643",
+ "shortnames": [
+ "upside_down_face"
+ ]
+ },
+ {
+ "unified": "1F911",
+ "shortnames": [
+ "money_mouth_face"
+ ]
+ },
+ {
+ "unified": "1F632",
+ "shortnames": [
+ "astonished"
+ ]
+ },
+ {
+ "unified": "2639-FE0F",
+ "shortnames": [
+ "white_frowning_face"
+ ]
+ },
+ {
+ "unified": "1F641",
+ "shortnames": [
+ "slightly_frowning_face"
+ ]
+ },
+ {
+ "unified": "1F616",
+ "shortnames": [
+ "confounded"
+ ]
+ },
+ {
+ "unified": "1F61E",
+ "shortnames": [
+ "disappointed"
+ ]
+ },
+ {
+ "unified": "1F61F",
+ "shortnames": [
+ "worried"
+ ]
+ },
+ {
+ "unified": "1F624",
+ "shortnames": [
+ "triumph"
+ ]
+ },
+ {
+ "unified": "1F622",
+ "shortnames": [
+ "cry"
+ ]
+ },
+ {
+ "unified": "1F62D",
+ "shortnames": [
+ "sob"
+ ]
+ },
+ {
+ "unified": "1F626",
+ "shortnames": [
+ "frowning"
+ ]
+ },
+ {
+ "unified": "1F627",
+ "shortnames": [
+ "anguished"
+ ]
+ },
+ {
+ "unified": "1F628",
+ "shortnames": [
+ "fearful"
+ ]
+ },
+ {
+ "unified": "1F629",
+ "shortnames": [
+ "weary"
+ ]
+ },
+ {
+ "unified": "1F92F",
+ "shortnames": [
+ "exploding_head"
+ ]
+ },
+ {
+ "unified": "1F62C",
+ "shortnames": [
+ "grimacing"
+ ]
+ },
+ {
+ "unified": "1F630",
+ "shortnames": [
+ "cold_sweat"
+ ]
+ },
+ {
+ "unified": "1F631",
+ "shortnames": [
+ "scream"
+ ]
+ },
+ {
+ "unified": "1F633",
+ "shortnames": [
+ "flushed"
+ ]
+ },
+ {
+ "unified": "1F92A",
+ "shortnames": [
+ "zany_face"
+ ]
+ },
+ {
+ "unified": "1F635",
+ "shortnames": [
+ "dizzy_face"
+ ]
+ },
+ {
+ "unified": "1F621",
+ "shortnames": [
+ "rage"
+ ]
+ },
+ {
+ "unified": "1F620",
+ "shortnames": [
+ "angry"
+ ]
+ },
+ {
+ "unified": "1F92C",
+ "shortnames": [
+ "face_with_symbols_on_mouth"
+ ]
+ },
+ {
+ "unified": "1F637",
+ "shortnames": [
+ "mask"
+ ]
+ },
+ {
+ "unified": "1F912",
+ "shortnames": [
+ "face_with_thermometer"
+ ]
+ },
+ {
+ "unified": "1F915",
+ "shortnames": [
+ "face_with_head_bandage"
+ ]
+ },
+ {
+ "unified": "1F922",
+ "shortnames": [
+ "nauseated_face"
+ ]
+ },
+ {
+ "unified": "1F92E",
+ "shortnames": [
+ "face_vomiting"
+ ]
+ },
+ {
+ "unified": "1F927",
+ "shortnames": [
+ "sneezing_face"
+ ]
+ },
+ {
+ "unified": "1F607",
+ "shortnames": [
+ "innocent"
+ ]
+ },
+ {
+ "unified": "1F920",
+ "shortnames": [
+ "face_with_cowboy_hat"
+ ]
+ },
+ {
+ "unified": "1F921",
+ "shortnames": [
+ "clown_face"
+ ]
+ },
+ {
+ "unified": "1F925",
+ "shortnames": [
+ "lying_face"
+ ]
+ },
+ {
+ "unified": "1F92B",
+ "shortnames": [
+ "shushing_face"
+ ]
+ },
+ {
+ "unified": "1F92D",
+ "shortnames": [
+ "face_with_hand_over_mouth"
+ ]
+ },
+ {
+ "unified": "1F9D0",
+ "shortnames": [
+ "face_with_monocle"
+ ]
+ },
+ {
+ "unified": "1F913",
+ "shortnames": [
+ "nerd_face"
+ ]
+ },
+ {
+ "unified": "1F608",
+ "shortnames": [
+ "smiling_imp"
+ ]
+ },
+ {
+ "unified": "1F47F",
+ "shortnames": [
+ "imp"
+ ]
+ },
+ {
+ "unified": "1F479",
+ "shortnames": [
+ "japanese_ogre"
+ ]
+ },
+ {
+ "unified": "1F47A",
+ "shortnames": [
+ "japanese_goblin"
+ ]
+ },
+ {
+ "unified": "1F480",
+ "shortnames": [
+ "skull"
+ ]
+ },
+ {
+ "unified": "2620-FE0F",
+ "shortnames": [
+ "skull_and_crossbones"
+ ]
+ },
+ {
+ "unified": "1F47B",
+ "shortnames": [
+ "ghost"
+ ]
+ },
+ {
+ "unified": "1F47D",
+ "shortnames": [
+ "alien"
+ ]
+ },
+ {
+ "unified": "1F47E",
+ "shortnames": [
+ "space_invader"
+ ]
+ },
+ {
+ "unified": "1F916",
+ "shortnames": [
+ "robot_face"
+ ]
+ },
+ {
+ "unified": "1F4A9",
+ "shortnames": [
+ "hankey"
+ ]
+ },
+ {
+ "unified": "1F63A",
+ "shortnames": [
+ "smiley_cat"
+ ]
+ },
+ {
+ "unified": "1F638",
+ "shortnames": [
+ "smile_cat"
+ ]
+ },
+ {
+ "unified": "1F639",
+ "shortnames": [
+ "joy_cat"
+ ]
+ },
+ {
+ "unified": "1F63B",
+ "shortnames": [
+ "heart_eyes_cat"
+ ]
+ },
+ {
+ "unified": "1F63C",
+ "shortnames": [
+ "smirk_cat"
+ ]
+ },
+ {
+ "unified": "1F63D",
+ "shortnames": [
+ "kissing_cat"
+ ]
+ },
+ {
+ "unified": "1F640",
+ "shortnames": [
+ "scream_cat"
+ ]
+ },
+ {
+ "unified": "1F63F",
+ "shortnames": [
+ "crying_cat_face"
+ ]
+ },
+ {
+ "unified": "1F63E",
+ "shortnames": [
+ "pouting_cat"
+ ]
+ },
+ {
+ "unified": "1F648",
+ "shortnames": [
+ "see_no_evil"
+ ]
+ },
+ {
+ "unified": "1F649",
+ "shortnames": [
+ "hear_no_evil"
+ ]
+ },
+ {
+ "unified": "1F64A",
+ "shortnames": [
+ "speak_no_evil"
+ ]
+ },
+ {
+ "unified": "1F476",
+ "shortnames": [
+ "baby"
+ ]
+ },
+ {
+ "unified": "1F9D2",
+ "shortnames": [
+ "child"
+ ]
+ },
+ {
+ "unified": "1F466",
+ "shortnames": [
+ "boy"
+ ]
+ },
+ {
+ "unified": "1F467",
+ "shortnames": [
+ "girl"
+ ]
+ },
+ {
+ "unified": "1F9D1",
+ "shortnames": [
+ "adult"
+ ]
+ },
+ {
+ "unified": "1F468",
+ "shortnames": [
+ "man"
+ ]
+ },
+ {
+ "unified": "1F469",
+ "shortnames": [
+ "woman"
+ ]
+ },
+ {
+ "unified": "1F9D3",
+ "shortnames": [
+ "older_adult"
+ ]
+ },
+ {
+ "unified": "1F474",
+ "shortnames": [
+ "older_man"
+ ]
+ },
+ {
+ "unified": "1F475",
+ "shortnames": [
+ "older_woman"
+ ]
+ },
+ {
+ "unified": "1F468-200D-2695-FE0F",
+ "shortnames": [
+ "male-doctor"
+ ]
+ },
+ {
+ "unified": "1F469-200D-2695-FE0F",
+ "shortnames": [
+ "female-doctor"
+ ]
+ },
+ {
+ "unified": "1F468-200D-1F393",
+ "shortnames": [
+ "male-student"
+ ]
+ },
+ {
+ "unified": "1F469-200D-1F393",
+ "shortnames": [
+ "female-student"
+ ]
+ },
+ {
+ "unified": "1F468-200D-1F3EB",
+ "shortnames": [
+ "male-teacher"
+ ]
+ },
+ {
+ "unified": "1F469-200D-1F3EB",
+ "shortnames": [
+ &nbs