Merge branch 'install_master' of https://git.in.moodle.com/amosbot/moodle-install
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Thu, 24 Jan 2019 14:12:22 +0000 (15:12 +0100)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Thu, 24 Jan 2019 14:12:22 +0000 (15:12 +0100)
216 files changed:
admin/cli/uninstall_plugins.php [new file with mode: 0644]
admin/customfields.php [new file with mode: 0644]
admin/searchareas.php
admin/settings/courses.php
admin/settings/plugins.php
admin/tool/dataprivacy/classes/external.php
admin/tool/dataprivacy/templates/form-user-selector-suggestion.mustache
admin/tool/dataprivacy/tests/behat/dataexport.feature
admin/tool/dataprivacy/tests/external_test.php
admin/tool/dataprivacy/version.php
backup/moodle2/backup_root_task.class.php
backup/moodle2/backup_settingslib.php
backup/moodle2/backup_stepslib.php
backup/moodle2/restore_root_task.class.php
backup/moodle2/restore_settingslib.php
backup/moodle2/restore_stepslib.php
badges/renderer.php
blocks/private_files/module.js
comment/locallib.php
course/classes/category.php
course/classes/customfield/course_handler.php [new file with mode: 0644]
course/classes/list_element.php
course/classes/search/customfield.php [new file with mode: 0644]
course/classes/search/mycourse.php
course/classes/search/section.php
course/customfield.php [new file with mode: 0644]
course/edit_form.php
course/externallib.php
course/format/lib.php
course/lib.php
course/renderer.php
course/tests/behat/customfields_locked.feature [new file with mode: 0644]
course/tests/behat/customfields_visibility.feature [new file with mode: 0644]
course/tests/customfield_test.php [new file with mode: 0644]
course/tests/externallib_test.php
course/tests/search_test.php
customfield/amd/build/form.min.js [new file with mode: 0644]
customfield/amd/src/form.js [new file with mode: 0644]
customfield/classes/api.php [new file with mode: 0644]
customfield/classes/category.php [new file with mode: 0644]
customfield/classes/category_controller.php [new file with mode: 0644]
customfield/classes/data.php [new file with mode: 0644]
customfield/classes/data_controller.php [new file with mode: 0644]
customfield/classes/event/category_created.php [new file with mode: 0644]
customfield/classes/event/category_deleted.php [new file with mode: 0644]
customfield/classes/event/category_updated.php [new file with mode: 0644]
customfield/classes/event/field_created.php [new file with mode: 0644]
customfield/classes/event/field_deleted.php [new file with mode: 0644]
customfield/classes/event/field_updated.php [new file with mode: 0644]
customfield/classes/field.php [new file with mode: 0644]
customfield/classes/field_config_form.php [new file with mode: 0644]
customfield/classes/field_controller.php [new file with mode: 0644]
customfield/classes/handler.php [new file with mode: 0644]
customfield/classes/output/field_data.php [new file with mode: 0644]
customfield/classes/output/management.php [new file with mode: 0644]
customfield/classes/output/renderer.php [new file with mode: 0644]
customfield/classes/privacy/customfield_provider.php [new file with mode: 0644]
customfield/classes/privacy/provider.php [new file with mode: 0644]
customfield/edit.php [new file with mode: 0644]
customfield/externallib.php [new file with mode: 0644]
customfield/field/checkbox/classes/data_controller.php [new file with mode: 0644]
customfield/field/checkbox/classes/field_controller.php [new file with mode: 0644]
customfield/field/checkbox/classes/privacy/provider.php [new file with mode: 0644]
customfield/field/checkbox/lang/en/customfield_checkbox.php [new file with mode: 0644]
customfield/field/checkbox/tests/behat/field.feature [new file with mode: 0644]
customfield/field/checkbox/tests/plugin_test.php [new file with mode: 0644]
customfield/field/checkbox/version.php [new file with mode: 0644]
customfield/field/date/classes/data_controller.php [new file with mode: 0644]
customfield/field/date/classes/field_controller.php [new file with mode: 0644]
customfield/field/date/classes/privacy/provider.php [new file with mode: 0644]
customfield/field/date/lang/en/customfield_date.php [new file with mode: 0644]
customfield/field/date/lib.php [new file with mode: 0644]
customfield/field/date/tests/behat/field.feature [new file with mode: 0644]
customfield/field/date/tests/plugin_test.php [new file with mode: 0644]
customfield/field/date/version.php [new file with mode: 0644]
customfield/field/select/classes/data_controller.php [new file with mode: 0644]
customfield/field/select/classes/field_controller.php [new file with mode: 0644]
customfield/field/select/classes/privacy/provider.php [new file with mode: 0644]
customfield/field/select/lang/en/customfield_select.php [new file with mode: 0644]
customfield/field/select/tests/behat/field.feature [new file with mode: 0644]
customfield/field/select/tests/plugin_test.php [new file with mode: 0644]
customfield/field/select/version.php [new file with mode: 0644]
customfield/field/text/classes/data_controller.php [new file with mode: 0644]
customfield/field/text/classes/field_controller.php [new file with mode: 0644]
customfield/field/text/classes/privacy/provider.php [new file with mode: 0644]
customfield/field/text/lang/en/customfield_text.php [new file with mode: 0644]
customfield/field/text/tests/behat/field.feature [new file with mode: 0644]
customfield/field/text/tests/plugin_test.php [new file with mode: 0644]
customfield/field/text/version.php [new file with mode: 0644]
customfield/field/textarea/classes/data_controller.php [new file with mode: 0644]
customfield/field/textarea/classes/field_controller.php [new file with mode: 0644]
customfield/field/textarea/classes/privacy/provider.php [new file with mode: 0644]
customfield/field/textarea/lang/en/customfield_textarea.php [new file with mode: 0644]
customfield/field/textarea/lib.php [new file with mode: 0644]
customfield/field/textarea/tests/behat/default_value.feature [new file with mode: 0644]
customfield/field/textarea/tests/behat/field.feature [new file with mode: 0644]
customfield/field/textarea/tests/plugin_test.php [new file with mode: 0644]
customfield/field/textarea/version.php [new file with mode: 0644]
customfield/lib.php [new file with mode: 0644]
customfield/templates/field_data.mustache [new file with mode: 0644]
customfield/templates/list.mustache [new file with mode: 0644]
customfield/tests/api_test.php [new file with mode: 0644]
customfield/tests/behat/edit_categories.feature [new file with mode: 0644]
customfield/tests/behat/edit_fields_settings.feature [new file with mode: 0644]
customfield/tests/behat/required_field.feature [new file with mode: 0644]
customfield/tests/behat/unique_field.feature [new file with mode: 0644]
customfield/tests/category_controller_test.php [new file with mode: 0644]
customfield/tests/data_controller_test.php [new file with mode: 0644]
customfield/tests/field_controller_test.php [new file with mode: 0644]
customfield/tests/fixtures/test_instance_form.php [new file with mode: 0644]
customfield/tests/generator/lib.php [new file with mode: 0644]
customfield/tests/generator_test.php [new file with mode: 0644]
customfield/tests/privacy_test.php [new file with mode: 0644]
enrol/database/classes/task/sync_enrolments.php [new file with mode: 0644]
enrol/database/cli/sync.php
enrol/database/db/tasks.php [new file with mode: 0644]
enrol/database/lang/en/enrol_database.php
enrol/database/upgrade.txt [new file with mode: 0644]
enrol/database/version.php
group/overview.php
lang/en/admin.php
lang/en/backup.php
lang/en/course.php
lang/en/customfield.php [new file with mode: 0644]
lang/en/moodle.php
lang/en/plugin.php
lang/en/role.php
lang/en/search.php
lib/adminlib.php
lib/classes/component.php
lib/classes/output/icon_system_fontawesome.php
lib/classes/plugin_manager.php
lib/classes/plugininfo/customfield.php [new file with mode: 0644]
lib/db/access.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/formslib.php
lib/gradelib.php
lib/moodlelib.php
lib/outputlib.php
lib/setuplib.php
lib/templates/pix_icon.mustache
lib/testing/generator/data_generator.php
lib/tests/behat/behat_data_generators.php
lib/tests/behat/behat_general.php
lib/tests/component_test.php
lib/tests/gradelib_test.php
lib/upgrade.txt
message/classes/api.php
message/classes/helper.php
message/classes/search/base_message.php
message/tests/helper_test.php [new file with mode: 0644]
message/tests/search_received_test.php
message/tests/search_sent_test.php
mod/assign/classes/output/grading_app.php
mod/assign/lang/en/assign.php
mod/assign/module.js
mod/assign/templates/grading_actions.mustache
mod/folder/module.js
mod/forum/lib.php
mod/forum/post.php
mod/glossary/editcategories.php
mod/glossary/lang/en/glossary.php
mod/glossary/lib.php
mod/glossary/tests/behat/behat_mod_glossary.php
mod/glossary/tests/behat/categories.feature
mod/quiz/locallib.php
mod/quiz/styles.css
mod/resource/locallib.php
mod/scorm/db/install.xml
mod/scorm/db/upgrade.php
mod/scorm/version.php
mod/wiki/module.js
phpunit.xml.dist
pix/i/customfield.png [new file with mode: 0644]
pix/i/customfield.svg [new file with mode: 0644]
pix/i/flagged.svg [new file with mode: 0644]
pix/i/unflagged.svg [new file with mode: 0644]
question/behaviour/behaviourbase.php
question/behaviour/manualgraded/tests/walkthrough_test.php
question/engine/tests/helpers.php
question/type/calculated/edit_calculated_form.php
question/type/calculatedmulti/edit_calculatedmulti_form.php
question/type/ddimageortext/amd/build/form.min.js
question/type/ddimageortext/amd/src/form.js
question/type/ddmarker/amd/build/form.min.js
question/type/ddmarker/amd/src/form.js
question/type/match/renderer.php
report/completion/index.php
report/progress/index.php
search/classes/area_category.php [new file with mode: 0644]
search/classes/base.php
search/classes/base_block.php
search/classes/base_mod.php
search/classes/manager.php
search/classes/output/form/search.php
search/classes/output/renderer.php
search/index.php
search/tests/area_category_test.php [new file with mode: 0644]
search/tests/base_test.php
search/tests/manager_test.php
theme/boost/config.php
theme/boost/scss/editor.scss [new file with mode: 0644]
theme/boost/scss/moodle/course.scss
theme/boost/scss/moodle/question.scss
theme/boost/scss/preset/default.scss
theme/boost/style/moodle.css
theme/boost/templates/mod_assign/grading_actions.mustache
theme/boost/templates/navbar.mustache
theme/bootstrapbase/less/moodle/question.less
theme/bootstrapbase/style/moodle.css
user/classes/search/user.php
user/index.php
user/tests/search_test.php
version.php

diff --git a/admin/cli/uninstall_plugins.php b/admin/cli/uninstall_plugins.php
new file mode 100644 (file)
index 0000000..e093d89
--- /dev/null
@@ -0,0 +1,163 @@
+<?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/>.
+
+/**
+ * CLI script to uninstall plugins.
+ *
+ * @package    core
+ * @subpackage cli
+ * @copyright  2018 Dmitrii Metelkin <dmitriim@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define('CLI_SCRIPT', true);
+
+require(__DIR__ . '/../../config.php');
+require_once($CFG->libdir . '/clilib.php');
+require_once($CFG->libdir . '/adminlib.php');
+
+$help = "Command line tool to uninstall plugins.
+
+Options:
+    -h --help                   Print this help.
+    --show-all                  Displays a list of all installed plugins.
+    --show-missing              Displays a list of plugins missing from disk.
+    --purge-missing             Uninstall all missing from disk plugins.
+    --plugins=<plugin name>     A comma separated list of plugins to be uninstalled. E.g. mod_assign,mod_forum
+    --run                       Execute uninstall. If this option is not set, then the script will be run in a dry mode.
+
+Examples:
+
+    # php uninstall_plugins.php  --show-all
+        Prints tab-separated list of all installed plugins.
+
+    # php uninstall_plugins.php  --show-missing
+        Prints tab-separated list of all missing from disk plugins.
+
+    # php uninstall_plugins.php  --purge-missing
+        A dry run of uninstalling all missing plugins.
+
+    # php uninstall_plugins.php  --purge-missing --run
+        Run uninstall of all missing plugins.
+
+    # php uninstall_plugins.php  --plugins=mod_assign,mod_forum
+        A dry run of uninstalling mod_assign and mod_forum plugins.
+
+    # php uninstall_plugins.php  --plugins=mod_assign,mod_forum --run
+        Run uninstall for mod_assign and mod_forum plugins.
+";
+
+list($options, $unrecognised) = cli_get_params([
+    'help' => false,
+    'show-all' => false,
+    'show-missing' => false,
+    'purge-missing' => false,
+    'plugins' => false,
+    'run' => false,
+], [
+    'h' => 'help'
+]);
+
+if ($unrecognised) {
+    $unrecognised = implode(PHP_EOL.'  ', $unrecognised);
+    cli_error(get_string('cliunknowoption', 'core_admin', $unrecognised));
+}
+
+if ($options['help']) {
+    cli_writeln($help);
+    exit(0);
+}
+
+$pluginman = core_plugin_manager::instance();
+$plugininfo = $pluginman->get_plugins();
+
+if ($options['show-all'] || $options['show-missing']) {
+    foreach ($plugininfo as $type => $plugins) {
+        foreach ($plugins as $name => $plugin) {
+            $pluginstring = $plugin->component . "\t" . $plugin->displayname;
+
+            if ($options['show-all']) {
+                cli_writeln($pluginstring);
+            } else {
+                if ($plugin->get_status() === core_plugin_manager::PLUGIN_STATUS_MISSING) {
+                    cli_writeln($pluginstring);
+                }
+            }
+        }
+    }
+
+    exit(0);
+}
+
+if ($options['purge-missing']) {
+    foreach ($plugininfo as $type => $plugins) {
+        foreach ($plugins as $name => $plugin) {
+            if ($plugin->get_status() === core_plugin_manager::PLUGIN_STATUS_MISSING) {
+
+                $pluginstring = $plugin->component . "\t" . $plugin->displayname;
+
+                if ($pluginman->can_uninstall_plugin($plugin->component)) {
+                    if ($options['run']) {
+                        cli_writeln('Uninstalling: ' . $pluginstring);
+
+                        $progress = new progress_trace_buffer(new text_progress_trace(), true);
+                        $pluginman->uninstall_plugin($plugin->component, $progress);
+                        $progress->finished();
+                        cli_write($progress->get_buffer());
+                    } else {
+                        cli_writeln('Will be uninstalled: ' . $pluginstring);
+                    }
+                } else {
+                    cli_writeln('Can not be uninstalled: ' . $pluginstring);
+                }
+            }
+        }
+    }
+
+    exit(0);
+}
+
+if ($options['plugins']) {
+    $components = explode(',', $options['plugins']);
+    foreach ($components as $component) {
+        $plugin = $pluginman->get_plugin_info($component);
+
+        if (is_null($plugin)) {
+            cli_writeln('Unknown plugin: ' . $component);
+        } else {
+            $pluginstring = $plugin->component . "\t" . $plugin->displayname;
+
+            if ($pluginman->can_uninstall_plugin($plugin->component)) {
+                if ($options['run']) {
+                    cli_writeln('Uninstalling: ' . $pluginstring);
+                    $progress = new progress_trace_buffer(new text_progress_trace(), true);
+                    $pluginman->uninstall_plugin($plugin->component, $progress);
+                    $progress->finished();
+                    cli_write($progress->get_buffer());
+                } else {
+                    cli_writeln('Will be uninstalled: ' . $pluginstring);
+                }
+            } else {
+                cli_writeln('Can not be uninstalled: ' . $pluginstring);
+            }
+        }
+    }
+
+    exit(0);
+}
+
+cli_writeln($help);
+exit(0);
diff --git a/admin/customfields.php b/admin/customfields.php
new file mode 100644 (file)
index 0000000..2f7681e
--- /dev/null
@@ -0,0 +1,62 @@
+<?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/>.
+
+/**
+ * Allows the admin to enable, disable and uninstall custom fields
+ *
+ * @package    core_admin
+ * @copyright  2018 Daniel Neis Araujo
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once('../config.php');
+require_once($CFG->libdir.'/adminlib.php');
+
+$action  = required_param('action', PARAM_ALPHANUMEXT);
+$customfieldname = required_param('field', PARAM_PLUGIN);
+
+$syscontext = context_system::instance();
+$PAGE->set_url('/admin/customfields.php');
+$PAGE->set_context($syscontext);
+
+require_login();
+require_capability('moodle/site:config', $syscontext);
+require_sesskey();
+
+$return = new moodle_url('/admin/settings.php', array('section' => 'managecustomfields'));
+
+$customfieldplugins = core_plugin_manager::instance()->get_plugins_of_type('customfield');
+$sortorder = array_flip(array_keys($customfieldplugins));
+
+if (!isset($customfieldplugins[$customfieldname])) {
+    print_error('customfieldnotfound', 'error', $return, $customfieldname);
+}
+
+switch ($action) {
+    case 'disable':
+        if ($customfieldplugins[$customfieldname]->is_enabled()) {
+            set_config('disabled', 1, 'customfield_'. $customfieldname);
+            core_plugin_manager::reset_caches();
+        }
+        break;
+    case 'enable':
+        if (!$customfieldplugins[$customfieldname]->is_enabled()) {
+            unset_config('disabled', 'customfield_'. $customfieldname);
+            core_plugin_manager::reset_caches();
+        }
+        break;
+}
+redirect($return);
index 911a86b..fb5b0a3 100644 (file)
@@ -153,6 +153,7 @@ $table = new html_table();
 $table->id = 'core-search-areas';
 $table->head = [
     get_string('searcharea', 'search'),
+    get_string('searchareacategories', 'search'),
     get_string('enable'),
     get_string('newestdocindexed', 'admin'),
     get_string('searchlastrun', 'admin'),
@@ -165,6 +166,14 @@ foreach ($searchareas as $area) {
     $areaid = $area->get_area_id();
     $columns = array(new html_table_cell($area->get_visible_name()));
 
+    $areacategories = [];
+    foreach (\core_search\manager::get_search_area_categories() as $category) {
+        if (key_exists($areaid, $category->get_areas())) {
+            $areacategories[] = $category->get_visiblename();
+        }
+    }
+    $columns[] = new html_table_cell(implode(', ', $areacategories));
+
     if ($area->is_enabled()) {
         $columns[] = $OUTPUT->action_icon(admin_searcharea_action_url('disable', $areaid),
             new pix_icon('t/hide', get_string('disable'), 'moodle', array('title' => '', 'class' => 'iconsmall')),
index f23bf5f..739c96e 100644 (file)
@@ -37,6 +37,12 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
             array('moodle/category:manage', 'moodle/course:create')
         )
     );
+    $ADMIN->add('courses',
+        new admin_externalpage('course_customfield', new lang_string('course_customfield', 'admin'),
+            $CFG->wwwroot . '/course/customfield.php',
+            array('moodle/course:configurecustomfields')
+        )
+    );
     $ADMIN->add('courses',
         new admin_externalpage('addcategory', new lang_string('addcategory', 'admin'),
             new moodle_url('/course/editcategory.php', array('parent' => 0)),
index 9a2202f..616d10e 100644 (file)
@@ -59,6 +59,18 @@ if ($hassiteconfig) {
         $plugin->load_settings($ADMIN, 'formatsettings', $hassiteconfig);
     }
 
+    // Custom fields.
+    $ADMIN->add('modules', new admin_category('customfieldsettings', new lang_string('customfields', 'core_customfield')));
+    $temp = new admin_settingpage('managecustomfields', new lang_string('managecustomfields', 'core_admin'));
+    $temp->add(new admin_setting_managecustomfields());
+    $ADMIN->add('customfieldsettings', $temp);
+    $plugins = core_plugin_manager::instance()->get_plugins_of_type('customfield');
+    core_collator::asort_objects_by_property($plugins, 'displayname');
+    foreach ($plugins as $plugin) {
+        /** @var \core\plugininfo\customfield $plugin */
+        $plugin->load_settings($ADMIN, 'customfieldsettings', $hassiteconfig);
+    }
+
     // blocks
     $ADMIN->add('modules', new admin_category('blocksettings', new lang_string('blocks')));
     $ADMIN->add('blocksettings', new admin_page_manageblocks());
@@ -573,6 +585,25 @@ if ($hassiteconfig) {
             new lang_string('searchallavailablecourses_desc', 'admin'),
             0, $options));
 
+    // Search display options.
+    $temp->add(new admin_setting_heading('searchdisplay', new lang_string('searchdisplay', 'admin'), ''));
+    $temp->add(new admin_setting_configcheckbox('searchenablecategories',
+        new lang_string('searchenablecategories', 'admin'),
+        new lang_string('searchenablecategories_desc', 'admin'),
+        0));
+    $options = [];
+    foreach (\core_search\manager::get_search_area_categories() as $category) {
+        $options[$category->get_name()] = $category->get_visiblename();
+    }
+    $temp->add(new admin_setting_configselect('searchdefaultcategory',
+        new lang_string('searchdefaultcategory', 'admin'),
+        new lang_string('searchdefaultcategory_desc', 'admin'),
+        \core_search\manager::SEARCH_AREA_CATEGORY_ALL, $options));
+    $temp->add(new admin_setting_configcheckbox('searchhideallcategory',
+        new lang_string('searchhideallcategory', 'admin'),
+        new lang_string('searchhideallcategory_desc', 'admin'),
+        0));
+
     $ADMIN->add('searchplugins', $temp);
     $ADMIN->add('searchplugins', new admin_externalpage('searchareas', new lang_string('searchareas', 'admin'),
         new moodle_url('/admin/searchareas.php')));
index 3dd839e..20c28f9 100644 (file)
@@ -689,6 +689,7 @@ class external extends external_api {
      * @throws restricted_context_exception
      */
     public static function get_users($query) {
+        global $DB;
         $params = external_api::validate_parameters(self::get_users_parameters(), [
             'query' => $query
         ]);
@@ -703,15 +704,30 @@ class external extends external_api {
         // Exclude admins and guest user.
         $excludedusers = array_keys(get_admins()) + [guest_user()->id];
         $sort = 'lastname ASC, firstname ASC';
-        $fields = 'id, email, ' . $allusernames;
-        $users = get_users(true, $query, true, $excludedusers, $sort, '', '', 0, 30, $fields);
+        $fields = 'id,' . $allusernames;
+
+        $extrafields = get_extra_user_fields($context);
+        if (!empty($extrafields)) {
+            $fields .= ',' . implode(',', $extrafields);
+        }
+
+        list($sql, $params) = users_search_sql($query, '', false, $extrafields, $excludedusers);
+        $users = $DB->get_records_select('user', $sql, $params, $sort, $fields, 0, 30);
+
         $useroptions = [];
         foreach ($users as $user) {
-            $useroptions[$user->id] = (object)[
+            $useroption = (object)[
                 'id' => $user->id,
-                'fullname' => fullname($user),
-                'email' => $user->email
+                'fullname' => fullname($user)
             ];
+            $useroption->extrafields = [];
+            foreach ($extrafields as $extrafield) {
+                $useroption->extrafields[] = (object)[
+                    'name' => $extrafield,
+                    'value' => $user->$extrafield
+                ];
+            }
+            $useroptions[$user->id] = $useroption;
         }
 
         return $useroptions;
@@ -729,7 +745,13 @@ class external extends external_api {
             [
                 'id' => new external_value(core_user::get_property_type('id'), 'ID of the user'),
                 'fullname' => new external_value(core_user::get_property_type('firstname'), 'The fullname of the user'),
-                'email' => new external_value(core_user::get_property_type('email'), 'The user\'s email address', VALUE_OPTIONAL),
+                'extrafields' => new external_multiple_structure(
+                    new external_single_structure([
+                            'name' => new external_value(PARAM_TEXT, 'Name of the extrafield.'),
+                            'value' => new external_value(PARAM_TEXT, 'Value of the extrafield.')
+                        ]
+                    ), 'List of extra fields', VALUE_OPTIONAL
+                )
             ]
         ));
     }
index 759650c..0f8443d 100644 (file)
     Example context (json):
     {
         "fullname": "Admin User",
-        "email": "admin@example.com"
+        "extrafields": [
+            {
+                "name": "email",
+                "value": "admin@example.com"
+            },
+            {
+                "name": "phone1",
+                "value": "0123456789"
+            }
+        ]
     }
 }}
 <span>
     <span>{{fullname}}</span>
-    <span><small>{{email}}</small></span>
+    {{#extrafields}}
+        <span><small>{{value}}</small></span>
+    {{/extrafields}}
 </span>
index 1947f62..78751cc 100644 (file)
@@ -6,21 +6,29 @@ Feature: Data export from the privacy API
 
   Background:
     Given the following "users" exist:
-      | username | firstname      | lastname |
-      | victim   | Victim User    | 1        |
-      | parent   | Long-suffering | Parent   |
+      | username  | firstname      | lastname  | institution |
+      | victim    | Victim User    | 1         | University1 |
+      | victim2   | Victim User    | 2         | University2 |
+      | requester | The            | Requester | University3 |
+      | parent    | Long-suffering | Parent    |             |
     And the following "roles" exist:
       | shortname | name  | archetype |
       | tired     | Tired |           |
     And the following "permission overrides" exist:
-      | capability                                   | permission | role  | contextlevel | reference |
-      | tool/dataprivacy:makedatarequestsforchildren | Allow      | tired | System       |           |
+      | capability                                   | permission | role    | contextlevel | reference |
+      | tool/dataprivacy:makedatarequestsforchildren | Allow      | tired   | System       |           |
+      | tool/dataprivacy:managedatarequests          | Allow      | manager | System       |           |
+      | moodle/site:viewuseridentity                 | Prevent    | manager | System       |           |
     And the following "role assigns" exist:
       | user   | role  | contextlevel | reference |
       | parent | tired | User         | victim    |
+    And the following "system role assigns" exist:
+      | user      | role    | contextlevel |
+      | requester | manager | User         |
     And the following config values are set as admin:
       | contactdataprotectionofficer | 1  | tool_dataprivacy |
       | privacyrequestexpiry         | 55 | tool_dataprivacy |
+      | dporoles                     | 1  | tool_dataprivacy |
     And the following data privacy "categories" exist:
       | name          |
       | Site category |
@@ -127,3 +135,19 @@ Feature: Data export from the privacy API
 
     And I should see "Expired" in the "Victim User 1" "table_row"
     And I should not see "Actions"
+
+  @javascript
+  Scenario: Test search for user using extra field.
+    Given the following "permission overrides" exist:
+      | capability                   | permission | role    | contextlevel | reference |
+      | moodle/site:viewuseridentity | Allow      | manager | System       |           |
+    And the following config values are set as admin:
+      | showuseridentity | institution |
+    And I log in as "requester"
+    And I navigate to "Users > Privacy and policies > Data requests" in site administration
+    And I follow "New request"
+    And I set the field "Search" to "University1"
+    Then I should see "Victim User 1"
+    When I reload the page
+    And I set the field "Search" to "University2"
+    Then I should see "Victim User 2"
index b17811a..7cb7df7 100644 (file)
@@ -970,4 +970,143 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
         $this->expectException(required_capability_exception::class);
         $result = external::bulk_deny_data_requests([$requestid1]);
     }
+
+    /**
+     * Test for external::get_users(), case search using non-identity field without
+     * facing any permission problem.
+     *
+     * @throws coding_exception
+     * @throws dml_exception
+     * @throws invalid_parameter_exception
+     * @throws required_capability_exception
+     * @throws restricted_context_exception
+     */
+    public function test_get_users_using_using_non_identity() {
+        $this->resetAfterTest();
+        $context = context_system::instance();
+        $requester = $this->getDataGenerator()->create_user();
+        $role = $this->getDataGenerator()->create_role();
+        role_assign($role, $requester->id, $context);
+        assign_capability('tool/dataprivacy:managedatarequests', CAP_ALLOW, $role, $context);
+        $this->setUser($requester);
+
+        $this->getDataGenerator()->create_user([
+            'firstname' => 'First Student'
+        ]);
+        $student2 = $this->getDataGenerator()->create_user([
+            'firstname' => 'Second Student'
+        ]);
+
+        $results = external::get_users('Second');
+        $this->assertCount(1, $results);
+        $this->assertEquals((object)[
+            'id' => $student2->id,
+            'fullname' => fullname($student2),
+            'extrafields' => []
+        ], $results[$student2->id]);
+    }
+
+    /**
+     * Test for external::get_users(), case search using identity field but
+     * don't have "moodle/site:viewuseridentity" permission.
+     *
+     * @throws coding_exception
+     * @throws dml_exception
+     * @throws invalid_parameter_exception
+     * @throws required_capability_exception
+     * @throws restricted_context_exception
+     */
+    public function test_get_users_using_identity_without_permission() {
+        global $CFG;
+
+        $this->resetAfterTest();
+        $CFG->showuseridentity = 'institution';
+
+        // Create requester user and assign correct capability.
+        $context = context_system::instance();
+        $requester = $this->getDataGenerator()->create_user();
+        $role = $this->getDataGenerator()->create_role();
+        role_assign($role, $requester->id, $context);
+        assign_capability('tool/dataprivacy:managedatarequests', CAP_ALLOW, $role, $context);
+        $this->setUser($requester);
+
+        $this->getDataGenerator()->create_user([
+            'institution' => 'University1'
+        ]);
+
+        $results = external::get_users('University1');
+        $this->assertEmpty($results);
+    }
+
+    /**
+     * Test for external::get_users(), case search using disabled identity field
+     * even they have "moodle/site:viewuseridentity" permission.
+     *
+     * @throws coding_exception
+     * @throws dml_exception
+     * @throws invalid_parameter_exception
+     * @throws required_capability_exception
+     * @throws restricted_context_exception
+     */
+    public function test_get_users_using_field_not_in_identity() {
+        $this->resetAfterTest();
+
+        $context = context_system::instance();
+        $requester = $this->getDataGenerator()->create_user();
+        $role = $this->getDataGenerator()->create_role();
+        role_assign($role, $requester->id, $context);
+        assign_capability('tool/dataprivacy:managedatarequests', CAP_ALLOW, $role, $context);
+        assign_capability('moodle/site:viewuseridentity', CAP_ALLOW, $role, $context);
+        $this->setUser($requester);
+
+        $this->getDataGenerator()->create_user([
+            'institution' => 'University1'
+        ]);
+
+        $results = external::get_users('University1');
+        $this->assertEmpty($results);
+    }
+
+    /**
+     * Test for external::get_users(), case search using enabled identity field
+     * with "moodle/site:viewuseridentity" permission.
+     *
+     * @throws coding_exception
+     * @throws dml_exception
+     * @throws invalid_parameter_exception
+     * @throws required_capability_exception
+     * @throws restricted_context_exception
+     */
+    public function test_get_users() {
+        global $CFG;
+        $this->resetAfterTest();
+        $CFG->showuseridentity = 'institution';
+        $context = context_system::instance();
+        $requester = $this->getDataGenerator()->create_user();
+        $role = $this->getDataGenerator()->create_role();
+        role_assign($role, $requester->id, $context);
+        assign_capability('tool/dataprivacy:managedatarequests', CAP_ALLOW, $role, $context);
+        assign_capability('moodle/site:viewuseridentity', CAP_ALLOW, $role, $context);
+        $this->setUser($requester);
+
+        $student1 = $this->getDataGenerator()->create_user([
+            'institution' => 'University1'
+        ]);
+        $this->getDataGenerator()->create_user([
+            'institution' => 'University2'
+        ]);
+
+        $results = external::get_users('University1');
+        $this->assertCount(1, $results);
+        $this->assertEquals((object)[
+            'id' => $student1->id,
+            'fullname' => fullname($student1),
+            'extrafields' => [
+                0 => (object)[
+                    'name' => 'institution',
+                    'value' => 'University1'
+                ]
+            ]
+        ], $results[$student1->id]);
+    }
 }
index a56cc8a..411f0ac 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die;
 
-$plugin->version   = 2018120300;
+$plugin->version   = 2019011500;
 $plugin->requires  = 2018112800;        // Moodle 3.5dev (Build 2018031600) and upwards.
 $plugin->component = 'tool_dataprivacy';
index 6366c8f..03e989d 100644 (file)
@@ -168,5 +168,10 @@ class backup_root_task extends backup_task {
         $competencies = new backup_competencies_setting();
         $competencies->set_ui(new backup_setting_ui_checkbox($competencies, get_string('rootsettingcompetencies', 'backup')));
         $this->add_setting($competencies);
+
+        // Define custom fields inclusion setting if custom fields are used.
+        $customfields = new backup_customfield_setting('customfield', base_setting::IS_BOOLEAN, true);
+        $customfields->set_ui(new backup_setting_ui_checkbox($customfields, get_string('rootsettingcustomfield', 'backup')));
+        $this->add_setting($customfields);
     }
 }
index d2e1e5f..9125673 100644 (file)
@@ -74,6 +74,15 @@ class backup_users_setting extends backup_generic_setting {}
 class backup_groups_setting extends backup_generic_setting {
 }
 
+/**
+ * root setting to control if backup will include custom field information
+ *
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @copyright 2018 Daniel Neis Araujo
+ */
+class backup_customfield_setting extends backup_generic_setting {
+}
+
 /**
  * root setting to control if backup will include activities or no.
  * A lot of other settings (_included at activity levels)
index ef99fa6..19e314f 100644 (file)
@@ -391,6 +391,11 @@ class backup_course_structure_step extends backup_structure_step {
         $tag = new backup_nested_element('tag', array('id'), array(
             'name', 'rawname'));
 
+        $customfields = new backup_nested_element('customfields');
+        $customfield = new backup_nested_element('customfield', array('id'), array(
+          'shortname', 'type', 'value', 'valueformat'
+        ));
+
         // attach format plugin structure to $course element, only one allowed
         $this->add_plugin_structure('format', $course, false);
 
@@ -425,6 +430,9 @@ class backup_course_structure_step extends backup_structure_step {
         $course->add_child($tags);
         $tags->add_child($tag);
 
+        $course->add_child($customfields);
+        $customfields->add_child($customfield);
+
         // Set the sources
 
         $courserec = $DB->get_record('course', array('id' => $this->task->get_courseid()));
@@ -457,6 +465,10 @@ class backup_course_structure_step extends backup_structure_step {
                                      backup_helper::is_sqlparam('course'),
                                      backup::VAR_PARENTID));
 
+        $handler = core_course\customfield\course_handler::create();
+        $fieldsforbackup = $handler->get_instance_data_for_backup($this->task->get_courseid());
+        $customfield->set_source_array($fieldsforbackup);
+
         // Some annotations
 
         $course->annotate_ids('grouping', 'defaultgroupingid');
index eb03318..3069a0b 100644 (file)
@@ -286,5 +286,9 @@ class restore_root_task extends restore_task {
         $competencies = new restore_competencies_setting($hascompetencies);
         $competencies->set_ui(new backup_setting_ui_checkbox($competencies, get_string('rootsettingcompetencies', 'backup')));
         $this->add_setting($competencies);
+
+        $customfields = new restore_customfield_setting('customfields', base_setting::IS_BOOLEAN, $defaultvalue);
+        $customfields->set_ui(new backup_setting_ui_checkbox($customfields, get_string('rootsettingcustomfield', 'backup')));
+        $this->add_setting($customfields);
     }
 }
index 4d559e0..daa3fec 100644 (file)
@@ -52,6 +52,15 @@ class restore_users_setting extends restore_generic_setting {}
 class restore_groups_setting extends restore_generic_setting {
 }
 
+/**
+ * root setting to control if restore will include custom field information
+ *
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @copyright 2018 Daniel Neis Araujo
+ */
+class restore_customfield_setting extends restore_generic_setting {
+}
+
 /**
  * root setting to control if restore will create role assignments
  * or no (any level), depends of @restore_users_setting
index 859198e..b3d5322 100644 (file)
@@ -1785,6 +1785,7 @@ class restore_course_structure_step extends restore_structure_step {
         $course = new restore_path_element('course', '/course');
         $category = new restore_path_element('category', '/course/category');
         $tag = new restore_path_element('tag', '/course/tags/tag');
+        $customfield = new restore_path_element('customfield', '/course/customfields/customfield');
         $allowed_module = new restore_path_element('allowed_module', '/course/allowed_modules/module');
 
         // Apply for 'format' plugins optional paths at course level
@@ -1808,7 +1809,7 @@ class restore_course_structure_step extends restore_structure_step {
         // Apply for admin tool plugins optional paths at course level.
         $this->add_plugin_structure('tool', $course);
 
-        return array($course, $category, $tag, $allowed_module);
+        return array($course, $category, $tag, $customfield, $allowed_module);
     }
 
     /**
@@ -1932,6 +1933,16 @@ class restore_course_structure_step extends restore_structure_step {
                 context_course::instance($this->get_courseid()), $data->rawname);
     }
 
+    /**
+     * Process custom fields
+     *
+     * @param array $data
+     */
+    public function process_customfield($data) {
+        $handler = core_course\customfield\course_handler::create();
+        $handler->restore_instance_data_from_backup($this->task, $data);
+    }
+
     public function process_allowed_module($data) {
         $data = (object)$data;
 
index 998e091..98d8466 100644 (file)
@@ -351,7 +351,9 @@ class core_badges_renderer extends plugin_renderer_base {
         if (!empty($badge->version)) {
             $dl[get_string('version', 'badges')] = $badge->version;
         }
-        $dl[get_string('language')] = $languages[$badge->language];
+        if (!empty($badge->language)) {
+            $dl[get_string('language')] = $languages[$badge->language];
+        }
         $dl[get_string('description', 'badges')] = $badge->description;
         if (!empty($badge->imageauthorname)) {
             $dl[get_string('imageauthorname', 'badges')] = $badge->imageauthorname;
@@ -417,15 +419,18 @@ class core_badges_renderer extends plugin_renderer_base {
         if (!empty($endorsement)) {
             $output .= self::print_badge_endorsement($badge);
         }
-        $relatedbadges = $badge->get_related_badges();
-        if (!empty($relatedbadges)) {
+
+        $relatedbadges = $badge->get_related_badges(true);
+        $items = array();
+        foreach ($relatedbadges as $related) {
+            $relatedurl = new moodle_url('/badges/overview.php', array('id' => $related->id));
+            $items[] = html_writer::link($relatedurl->out(), $related->name, array('target' => '_blank'));
+        }
+        if (!empty($items)) {
             $output .= $this->heading(get_string('relatedbages', 'badges'), 3);
-            $items = array();
-            foreach ($relatedbadges as $related) {
-                $items[] = $related->name;
-            }
             $output .= html_writer::alist($items, array(), 'ul');
         }
+
         $competencies = $badge->get_alignment();
         if (!empty($competencies)) {
             $output .= $this->heading(get_string('alignment', 'badges'), 3);
index 42a4026..203eb03 100644 (file)
@@ -24,7 +24,7 @@
 M.block_private_files = {};
 
 M.block_private_files.init_tree = function(Y, expand_all, htmlid) {
-    Y.use('yui2-treeview', function(Y) {
+    Y.use('yui2-treeview', 'node-event-simulate', function(Y) {
         var tree = new Y.YUI2.widget.TreeView(htmlid);
 
         tree.subscribe("clickEvent", function(node, event) {
@@ -32,6 +32,12 @@ M.block_private_files.init_tree = function(Y, expand_all, htmlid) {
             return false;
         });
 
+        tree.subscribe("enterKeyPressed", function(node) {
+            // We want keyboard activation to trigger a click on the first link.
+            Y.one(node.getContentEl()).one('a').simulate('click');
+            return false;
+        });
+
         if (expand_all) {
             tree.expandAll();
         }
index ec0633e..dc30c34 100644 (file)
@@ -170,6 +170,7 @@ class comment_manager {
 
         $link = new moodle_url('/comment/index.php', array('action' => 'delete', 'sesskey' => sesskey()));
         foreach ($comments as $c) {
+            $userdata = html_writer::link(new moodle_url('/user/profile.php', ['id' => $c->userid]), $c->fullname);
             $this->setup_plugin($c);
             if (!empty($this->plugintype)) {
                 $context_url = plugin_callback($this->plugintype, $this->pluginname, 'comment', 'url', array($c));
@@ -180,7 +181,7 @@ class comment_manager {
                 $action .= html_writer::empty_tag('br');
                 $action .= html_writer::link($context_url, get_string('commentincontext'), array('target'=>'_blank'));
             }
-            $table->data[] = array($checkbox, $c->fullname, $c->content, $action);
+            $table->data[] = array($checkbox, $userdata, $c->content, $action);
         }
         echo html_writer::table($table);
         echo $OUTPUT->paging_bar($count, $page, $this->perpage, $CFG->wwwroot.'/comment/index.php');
index 2880675..c365857 100644 (file)
@@ -903,6 +903,18 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         $cache->set_many($values);
     }
 
+    /**
+     * Preloads the custom fields values in bulk
+     *
+     * @param array $records
+     */
+    public static function preload_custom_fields(array &$records) {
+        $customfields = \core_course\customfield\course_handler::create()->get_instances_data(array_keys($records));
+        foreach ($customfields as $courseid => $data) {
+            $records[$courseid]->customfields = $data;
+        }
+    }
+
     /**
      * Verify user enrollments for multiple course-user combinations
      *
@@ -1009,6 +1021,10 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         if (!empty($options['coursecontacts'])) {
             self::preload_course_contacts($list);
         }
+        // Preload custom fields if necessary - saves DB queries later to do it for each course separately.
+        if (!empty($options['customfields'])) {
+            self::preload_custom_fields($list);
+        }
         return $list;
     }
 
@@ -1404,6 +1420,10 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
                 if (!empty($options['coursecontacts'])) {
                     self::preload_course_contacts($records);
                 }
+                // Preload custom fields if necessary - saves DB queries later to do it for each course separately.
+                if (!empty($options['customfields'])) {
+                    self::preload_custom_fields($records);
+                }
                 // If option 'idonly' is specified no further action is needed, just return list of ids.
                 if (!empty($options['idonly'])) {
                     return array_keys($records);
@@ -1493,6 +1513,10 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         if (!empty($preloadcoursecontacts)) {
             self::preload_course_contacts($records);
         }
+        // Preload custom fields if necessary - saves DB queries later to do it for each course separately.
+        if (!empty($options['customfields'])) {
+            self::preload_custom_fields($records);
+        }
         // If option 'idonly' is specified no further action is needed, just return list of ids.
         if (!empty($options['idonly'])) {
             return array_keys($records);
@@ -1606,6 +1630,10 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
                 if (!empty($options['idonly'])) {
                     return array_keys($records);
                 }
+                // Preload custom fields if necessary - saves DB queries later to do it for each course separately.
+                if (!empty($options['customfields'])) {
+                    self::preload_custom_fields($records);
+                }
                 // Prepare the list of core_course_list_element objects.
                 foreach ($ids as $id) {
                     $courses[$id] = new core_course_list_element($records[$id]);
@@ -1645,6 +1673,10 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
             if (!empty($options['coursecontacts'])) {
                 self::preload_course_contacts($list);
             }
+            // Preload custom fields if necessary - saves DB queries later to do it for each course separately.
+            if (!empty($options['customfields'])) {
+                self::preload_custom_fields($list);
+            }
             // If option 'idonly' is specified no further action is needed, just return list of ids.
             if (!empty($options['idonly'])) {
                 return array_keys($list);
diff --git a/course/classes/customfield/course_handler.php b/course/classes/customfield/course_handler.php
new file mode 100644 (file)
index 0000000..d7badc8
--- /dev/null
@@ -0,0 +1,241 @@
+<?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/>.
+
+/**
+ * Course handler for custom fields
+ *
+ * @package   core_course
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_course\customfield;
+
+defined('MOODLE_INTERNAL') || die;
+
+use core_customfield\api;
+use core_customfield\field_controller;
+
+/**
+ * Course handler for custom fields
+ *
+ * @package core_course
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class course_handler extends \core_customfield\handler {
+
+    /**
+     * @var course_handler
+     */
+    static protected $singleton;
+
+    /**
+     * @var \context
+     */
+    protected $parentcontext;
+
+    /** @var int Field is displayed in the course listing, visible to everybody */
+    const VISIBLETOALL = 2;
+    /** @var int Field is displayed in the course listing but only for teachers */
+    const VISIBLETOTEACHERS = 1;
+    /** @var int Field is not displayed in the course listing */
+    const NOTVISIBLE = 0;
+
+    /**
+     * Returns a singleton
+     *
+     * @param int $itemid
+     * @return \core_course\customfield\course_handler
+     */
+    public static function create(int $itemid = 0) : \core_customfield\handler {
+        if (static::$singleton === null) {
+            self::$singleton = new static(0);
+        }
+        return self::$singleton;
+    }
+
+    /**
+     * The current user can configure custom fields on this component.
+     *
+     * @return bool true if the current can configure custom fields, false otherwise
+     */
+    public function can_configure() : bool {
+        return has_capability('moodle/course:configurecustomfields', $this->get_configuration_context());
+    }
+
+    /**
+     * The current user can edit custom fields on the given course.
+     *
+     * @param field_controller $field
+     * @param int $instanceid id of the course to test edit permission
+     * @return bool true if the current can edit custom fields, false otherwise
+     */
+    public function can_edit(field_controller $field, int $instanceid = 0) : bool {
+        if ($instanceid) {
+            $context = $this->get_instance_context($instanceid);
+            return (!$field->get_configdata_property('locked') ||
+                    has_capability('moodle/course:changelockedcustomfields', $context));
+        } else {
+            $context = $this->get_parent_context();
+            return (!$field->get_configdata_property('locked') ||
+                guess_if_creator_will_have_course_capability('moodle/course:changelockedcustomfields', $context));
+        }
+    }
+
+    /**
+     * The current user can view custom fields on the given course.
+     *
+     * @param field_controller $field
+     * @param int $instanceid id of the course to test edit permission
+     * @return bool true if the current can edit custom fields, false otherwise
+     */
+    public function can_view(field_controller $field, int $instanceid) : bool {
+        $visibility = $field->get_configdata_property('visibility');
+        if ($visibility == self::NOTVISIBLE) {
+            return false;
+        } else if ($visibility == self::VISIBLETOTEACHERS) {
+            return has_capability('moodle/course:update', $this->get_instance_context($instanceid));
+        } else {
+            return true;
+        }
+    }
+
+    /**
+     * Sets parent context for the course
+     *
+     * This may be needed when course is being created, there is no course context but we need to check capabilities
+     *
+     * @param \context $context
+     */
+    public function set_parent_context(\context $context) {
+        $this->parentcontext = $context;
+    }
+
+    /**
+     * Returns the parent context for the course
+     *
+     * @return \context
+     */
+    protected function get_parent_context() : \context {
+        global $PAGE;
+        if ($this->parentcontext) {
+            return $this->parentcontext;
+        } else if ($PAGE->context && $PAGE->context instanceof \context_coursecat) {
+            return $PAGE->context;
+        }
+        return \context_system::instance();
+    }
+
+    /**
+     * Context that should be used for new categories created by this handler
+     *
+     * @return \context the context for configuration
+     */
+    public function get_configuration_context() : \context {
+        return \context_system::instance();
+    }
+
+    /**
+     * URL for configuration of the fields on this handler.
+     *
+     * @return \moodle_url The URL to configure custom fields for this component
+     */
+    public function get_configuration_url() : \moodle_url {
+        return new \moodle_url('/course/customfield.php');
+    }
+
+    /**
+     * Returns the context for the data associated with the given instanceid.
+     *
+     * @param int $instanceid id of the record to get the context for
+     * @return \context the context for the given record
+     */
+    public function get_instance_context(int $instanceid = 0) : \context {
+        if ($instanceid > 0) {
+            return \context_course::instance($instanceid);
+        } else {
+            return \context_system::instance();
+        }
+    }
+
+    /**
+     * Allows to add custom controls to the field configuration form that will be saved in configdata
+     *
+     * @param \MoodleQuickForm $mform
+     */
+    public function config_form_definition(\MoodleQuickForm $mform) {
+        $mform->addElement('header', 'course_handler_header', get_string('customfieldsettings', 'core_course'));
+        $mform->setExpanded('course_handler_header', true);
+
+        // If field is locked.
+        $mform->addElement('selectyesno', 'configdata[locked]', get_string('customfield_islocked', 'core_course'));
+        $mform->addHelpButton('configdata[locked]', 'customfield_islocked', 'core_course');
+
+        // Field data visibility.
+        $visibilityoptions = [self::VISIBLETOALL => get_string('customfield_visibletoall', 'core_course'),
+            self::VISIBLETOTEACHERS => get_string('customfield_visibletoteachers', 'core_course'),
+            self::NOTVISIBLE => get_string('customfield_notvisible', 'core_course')];
+        $mform->addElement('select', 'configdata[visibility]', get_string('customfield_visibility', 'core_course'),
+            $visibilityoptions);
+        $mform->addHelpButton('configdata[visibility]', 'customfield_visibility', 'core_course');
+    }
+
+    /**
+     * Creates or updates custom field data.
+     *
+     * @param \restore_task $task
+     * @param array $data
+     */
+    public function restore_instance_data_from_backup(\restore_task $task, array $data) {
+        $courseid = $task->get_courseid();
+        $context = $this->get_instance_context($courseid);
+        $editablefields = $this->get_editable_fields($courseid);
+        $records = api::get_instance_fields_data($editablefields, $courseid);
+        $target = $task->get_target();
+        $override = ($target != \backup::TARGET_CURRENT_ADDING && $target != \backup::TARGET_EXISTING_ADDING);
+
+        foreach ($records as $d) {
+            $field = $d->get_field();
+            if ($field->get('shortname') === $data['shortname'] && $field->get('type') === $data['type']) {
+                if (!$d->get('id') || $override) {
+                    $d->set($d->datafield(), $data['value']);
+                    $d->set('value', $data['value']);
+                    $d->set('valueformat', $data['valueformat']);
+                    $d->set('contextid', $context->id);
+                    $d->save();
+                }
+                return;
+            }
+        }
+    }
+
+    /**
+     * Set up page customfield/edit.php
+     *
+     * @param field_controller $field
+     * @return string page heading
+     */
+    public function setup_edit_page(field_controller $field) : string {
+        global $CFG, $PAGE;
+        require_once($CFG->libdir.'/adminlib.php');
+
+        $title = parent::setup_edit_page($field);
+        admin_externalpage_setup('course_customfield');
+        $PAGE->navbar->add($title);
+        return $title;
+    }
+}
index b9d3010..7d5eab4 100644 (file)
@@ -200,6 +200,28 @@ class core_course_list_element implements IteratorAggregate {
         return $this->coursecontacts;
     }
 
+    /**
+     * Returns custom fields data for this course
+     *
+     * @return \core_customfield\data_controller[]
+     */
+    public function get_custom_fields() : array {
+        if (!isset($this->record->customfields)) {
+            $this->record->customfields = \core_course\customfield\course_handler::create()->get_instance_data($this->id);
+        }
+        return $this->record->customfields;
+    }
+
+    /**
+     * Does this course have custom fields
+     *
+     * @return bool
+     */
+    public function has_custom_fields() : bool {
+        $customfields = $this->get_custom_fields();
+        return !empty($customfields);
+    }
+
     /**
      * Checks if course has any associated overview files
      *
diff --git a/course/classes/search/customfield.php b/course/classes/search/customfield.php
new file mode 100644 (file)
index 0000000..8f5db60
--- /dev/null
@@ -0,0 +1,184 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Search area for course custom fields.
+ *
+ * @package core_course
+ * @copyright Toni Barbera <toni@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_course\search;
+
+use core_course\customfield\course_handler;
+use core_customfield\data_controller;
+use core_customfield\field_controller;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Search area for course custom fields.
+ *
+ * @package core_course
+ * @copyright Toni Barbera <toni@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class customfield extends \core_search\base {
+
+    /**
+     * Custom fields are indexed at course context.
+     *
+     * @var array
+     */
+    protected static $levels = [CONTEXT_COURSE];
+
+    /**
+     * Returns recordset containing required data for indexing
+     * course custom fields.
+     *
+     * @param int $modifiedfrom timestamp
+     * @param \context|null $context Restriction context
+     * @return \moodle_recordset|null Recordset or null if no change possible
+     */
+    public function get_document_recordset($modifiedfrom = 0, \context $context = null) {
+        global $DB;
+
+        list ($contextjoin, $contextparams) = $this->get_course_level_context_restriction_sql($context, 'c', SQL_PARAMS_NAMED);
+        if ($contextjoin === null) {
+            return null;
+        }
+
+        $fields = course_handler::create()->get_fields();
+        if (!$fields) {
+            $fields = array();
+        }
+        list($fieldsql, $fieldparam) = $DB->get_in_or_equal(array_keys($fields), SQL_PARAMS_NAMED, 'fld', true, true);
+
+        // Restrict recordset to CONTEXT_COURSE (since we are implementing it to core_course\search).
+        $sql = "SELECT d.*
+                  FROM {customfield_data} d
+                  JOIN {course} c ON c.id = d.instanceid
+                  JOIN {context} cnt ON cnt.instanceid = c.id
+           $contextjoin
+                 WHERE d.timemodified >= :modifiedfrom
+                   AND cnt.contextlevel = :contextlevel
+                   AND d.fieldid $fieldsql
+              ORDER BY d.timemodified ASC";
+        return $DB->get_recordset_sql($sql , array_merge($contextparams,
+            ['modifiedfrom' => $modifiedfrom, 'contextlevel' => CONTEXT_COURSE], $fieldparam));
+    }
+
+    /**
+     * Returns the document associated with this section.
+     *
+     * @param \stdClass $record
+     * @param array $options
+     * @return \core_search\document|bool
+     */
+    public function get_document($record, $options = array()) {
+        global $PAGE;
+
+        try {
+            $context = \context_course::instance($record->instanceid);
+        } catch (\moodle_exception $ex) {
+            // Notify it as we run here as admin, we should see everything.
+            debugging('Error retrieving ' . $this->areaid . ' ' . $record->id . ' document, not all required data is available: ' .
+                $ex->getMessage(), DEBUG_DEVELOPER);
+            return false;
+        }
+
+        $handler = course_handler::create();
+        $field = $handler->get_fields()[$record->fieldid];
+        $data = data_controller::create(0, $record, $field);
+
+        // Prepare associative array with data from DB.
+        $doc = \core_search\document_factory::instance($record->id, $this->componentname, $this->areaname);
+        $doc->set('title', content_to_text($field->get('name'), false));
+        $doc->set('content', content_to_text($data->export_value(), FORMAT_HTML));
+        $doc->set('contextid', $context->id);
+        $doc->set('courseid', $context->instanceid);
+        $doc->set('owneruserid', \core_search\manager::NO_OWNER_ID);
+        $doc->set('modified', $record->timemodified);
+
+        // Check if this document should be considered new.
+        if (isset($options['lastindexedtime']) && ($options['lastindexedtime'] < $record->timecreated)) {
+            // If the document was created after the last index time, it must be new.
+            $doc->set_is_new(true);
+        }
+
+        return $doc;
+    }
+
+    /**
+     * Whether the user can access the document or not.
+     *
+     * @param int $id The course instance id.
+     * @return int
+     */
+    public function check_access($id) {
+        global $DB;
+        $course = $DB->get_record('course', array('id' => $id));
+        if (!$course) {
+            return \core_search\manager::ACCESS_DELETED;
+        }
+        if (can_access_course($course)) {
+            return \core_search\manager::ACCESS_GRANTED;
+        }
+        return \core_search\manager::ACCESS_DENIED;
+    }
+
+    /**
+     * Link to the course.
+     *
+     * @param \core_search\document $doc
+     * @return \moodle_url
+     */
+    public function get_doc_url(\core_search\document $doc) {
+        return $this->get_context_url($doc);
+    }
+
+    /**
+     * Link to the course.
+     *
+     * @param \core_search\document $doc
+     * @return \moodle_url
+     */
+    public function get_context_url(\core_search\document $doc) {
+        return new \moodle_url('/course/view.php', array('id' => $doc->get('courseid')));
+    }
+
+    /**
+     * Returns the moodle component name.
+     *
+     * It might be the plugin name (whole frankenstyle name) or the core subsystem name.
+     *
+     * @return string
+     */
+    public function get_component_name() {
+        return 'course';
+    }
+
+    /**
+     * Returns an icon instance for the document.
+     *
+     * @param \core_search\document $doc
+     * @return \core_search\document_icon
+     */
+    public function get_doc_icon(\core_search\document $doc) : \core_search\document_icon {
+        return new \core_search\document_icon('i/customfield');
+    }
+}
index 9cbdc58..bac0127 100644 (file)
@@ -184,4 +184,13 @@ class mycourse extends \core_search\base {
     public function get_doc_icon(\core_search\document $doc) : \core_search\document_icon {
         return new \core_search\document_icon('i/course');
     }
+
+    /**
+     * Returns a list of category names associated with the area.
+     *
+     * @return array
+     */
+    public function get_category_names() {
+        return [\core_search\manager::SEARCH_AREA_CATEGORY_COURSES];
+    }
 }
index 42badb4..6be829d 100644 (file)
@@ -205,4 +205,13 @@ class section extends \core_search\base {
     public function get_doc_icon(\core_search\document $doc) : \core_search\document_icon {
         return new \core_search\document_icon('i/section');
     }
+
+    /**
+     * Returns a list of category names associated with the area.
+     *
+     * @return array
+     */
+    public function get_category_names() {
+        return [\core_search\manager::SEARCH_AREA_CATEGORY_COURSE_CONTENT];
+    }
 }
diff --git a/course/customfield.php b/course/customfield.php
new file mode 100644 (file)
index 0000000..2e92ec8
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Manage course custom fields
+ *
+ * @package core_course
+ * @copyright 2018 Toni Barbera (toni@moodle.com)
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once('../config.php');
+require_once($CFG->libdir.'/adminlib.php');
+
+admin_externalpage_setup('course_customfield');
+
+$output = $PAGE->get_renderer('core_customfield');
+$handler = core_course\customfield\course_handler::create();
+$outputpage = new \core_customfield\output\management($handler);
+
+echo $output->header(),
+     $output->heading(new lang_string('course_customfield', 'admin')),
+     $output->render($outputpage),
+     $output->footer();
index a91cb5f..1f42e6c 100644 (file)
@@ -320,6 +320,11 @@ class course_edit_form extends moodleform {
                     array('itemtype' => 'course', 'component' => 'core'));
         }
 
+        // Add custom fields to the form.
+        $handler = core_course\customfield\course_handler::create();
+        $handler->set_parent_context($categorycontext); // For course handler only.
+        $handler->instance_form_definition($mform, empty($course->id) ? 0 : $course->id);
+
         // When two elements we need a group.
         $buttonarray = array();
         $classarray = array('class' => 'form-submit');
@@ -334,6 +339,8 @@ class course_edit_form extends moodleform {
         $mform->addElement('hidden', 'id', null);
         $mform->setType('id', PARAM_INT);
 
+        // Prepare custom fields data.
+        $handler->instance_form_before_set_data($course);
         // Finally set the current form data
         $this->set_data($course);
     }
@@ -383,6 +390,10 @@ class course_edit_form extends moodleform {
                 $mform->removeElement('newsitems');
             }
         }
+
+        // Tweak the form with values provided by custom fields in use.
+        $handler  = core_course\customfield\course_handler::create();
+        $handler->instance_form_definition_after_data($mform, empty($courseid) ? 0 : $courseid);
     }
 
     /**
@@ -425,7 +436,10 @@ class course_edit_form extends moodleform {
             $errors = array_merge($errors, $formaterrors);
         }
 
+        // Add the custom fields validation.
+        $handler = core_course\customfield\course_handler::create();
+        $errors  = array_merge($errors, $handler->instance_form_validation($data, $files));
+
         return $errors;
     }
 }
-
index 81e21ab..f30b41a 100644 (file)
@@ -553,6 +553,19 @@ class core_course_external extends external_api {
                 $courseinfo['numsections'] = $courseformatoptions['numsections'];
             }
 
+            $handler = core_course\customfield\course_handler::create();
+            if ($customfields = $handler->export_instance_data($course->id)) {
+                $courseinfo['customfields'] = [];
+                foreach ($customfields as $data) {
+                    $courseinfo['customfields'][] = [
+                        'type' => $data->get_type(),
+                        'value' => $data->get_value(),
+                        'name' => $data->get_name(),
+                        'shortname' => $data->get_shortname()
+                    ];
+                }
+            }
+
             //some field should be returned only if the user has update permission
             $courseadmin = has_capability('moodle/course:update', $context);
             if ($courseadmin) {
@@ -663,9 +676,16 @@ class core_course_external extends external_api {
                                 new external_single_structure(
                                     array('name' => new external_value(PARAM_ALPHANUMEXT, 'course format option name'),
                                         'value' => new external_value(PARAM_RAW, 'course format option value')
-                                )),
-                                    'additional options for particular course format', VALUE_OPTIONAL
+                                )), 'additional options for particular course format', VALUE_OPTIONAL
                              ),
+                            'customfields' => new external_multiple_structure(
+                                new external_single_structure(
+                                    ['name' => new external_value(PARAM_TEXT, 'The name of the custom field'),
+                                     'shortname' => new external_value(PARAM_ALPHANUMEXT, 'The shortname of the custom field'),
+                                     'type'  => new external_value(PARAM_COMPONENT,
+                                         'The type of the custom field - text, checkbox...'),
+                                     'value' => new external_value(PARAM_RAW, 'The value of the custom field')]
+                                ), 'Custom fields and associated values', VALUE_OPTIONAL),
                         ), 'course'
                 )
         );
@@ -739,8 +759,14 @@ class core_course_external extends external_api {
                                         'value' => new external_value(PARAM_RAW, 'course format option value')
                                 )),
                                     'additional options for particular course format', VALUE_OPTIONAL),
-                        )
-                    ), 'courses to create'
+                            'customfields' => new external_multiple_structure(
+                                new external_single_structure(
+                                    array(
+                                        'shortname'  => new external_value(PARAM_ALPHANUMEXT, 'The shortname of the custom field'),
+                                        'value' => new external_value(PARAM_RAW, 'The value of the custom field'),
+                                )), 'custom fields for the course', VALUE_OPTIONAL
+                            )
+                    )), 'courses to create'
                 )
             )
         );
@@ -828,6 +854,13 @@ class core_course_external extends external_api {
                 }
             }
 
+            // Custom fields.
+            if (!empty($course['customfields'])) {
+                foreach ($course['customfields'] as $field) {
+                    $course['customfield_'.$field['shortname']] = $field['value'];
+                }
+            }
+
             //Note: create_course() core function check shortname, idnumber, category
             $course['id'] = create_course((object) $course)->id;
 
@@ -909,8 +942,14 @@ class core_course_external extends external_api {
                                 new external_single_structure(
                                     array('name' => new external_value(PARAM_ALPHANUMEXT, 'course format option name'),
                                         'value' => new external_value(PARAM_RAW, 'course format option value')
-                                )),
-                                    'additional options for particular course format', VALUE_OPTIONAL),
+                                )), 'additional options for particular course format', VALUE_OPTIONAL),
+                            'customfields' => new external_multiple_structure(
+                                new external_single_structure(
+                                    [
+                                        'shortname'  => new external_value(PARAM_ALPHANUMEXT, 'The shortname of the custom field'),
+                                        'value' => new external_value(PARAM_RAW, 'The value of the custom field')
+                                    ]
+                                ), 'Custom fields', VALUE_OPTIONAL),
                         )
                     ), 'courses to update'
                 )
@@ -1024,6 +1063,13 @@ class core_course_external extends external_api {
                     }
                 }
 
+                // Prepare list of custom fields.
+                if (isset($course['customfields'])) {
+                    foreach ($course['customfields'] as $field) {
+                        $course['customfield_' . $field['shortname']] = $field['value'];
+                    }
+                }
+
                 // Update course if user has all required capabilities.
                 update_course((object) $course);
             } catch (Exception $e) {
@@ -2499,6 +2545,17 @@ class core_course_external extends external_api {
                 new external_value(PARAM_PLUGIN, 'enrollment method'),
                 'enrollment methods list'
             ),
+            'customfields' => new external_multiple_structure(
+                new external_single_structure(
+                    array(
+                        'name' => new external_value(PARAM_RAW, 'The name of the custom field'),
+                        'shortname' => new external_value(PARAM_RAW,
+                            'The shortname of the custom field - to be able to build the field class in the code'),
+                        'type'  => new external_value(PARAM_ALPHANUMEXT,
+                            'The type of the custom field - text field, checkbox...'),
+                        'value' => new external_value(PARAM_RAW, 'The value of the custom field'),
+                    )
+                ), 'Custom fields', VALUE_OPTIONAL),
         );
 
         if (!$onlypublicdata) {
index 0ba58c6..40c880d 100644 (file)
@@ -1111,6 +1111,11 @@ abstract class format_base {
         $DB->delete_records('course_sections', array('id' => $section->id));
         rebuild_course_cache($course->id, true);
 
+        // Delete section summary files.
+        $context = \context_course::instance($course->id);
+        $fs = get_file_storage();
+        $fs->delete_area_files($context->id, 'course', 'section', $section->id);
+
         // Descrease 'numsections' if needed.
         if ($decreasenumsections) {
             $this->update_course_format_options(array('numsections' => $course->numsections - 1));
index 5f13036..55cc640 100644 (file)
@@ -2488,6 +2488,15 @@ function create_course($data, $editoroptions = NULL) {
         core_tag_tag::set_item_tags('core', 'course', $course->id, context_course::instance($course->id), $data->tags);
     }
 
+    // Save custom fields if there are any of them in the form.
+    $handler = core_course\customfield\course_handler::create();
+    // Make sure to set the handler's parent context first.
+    $coursecatcontext = context_coursecat::instance($category->id);
+    $handler->set_parent_context($coursecatcontext);
+    // Save the custom field data.
+    $data->id = $course->id;
+    $handler->instance_form_save($data, true);
+
     return $course;
 }
 
@@ -2572,6 +2581,10 @@ function update_course($data, $editoroptions = NULL) {
         }
     }
 
+    // Update custom fields if there are any of them in the form.
+    $handler = core_course\customfield\course_handler::create();
+    $handler->instance_form_save($data);
+
     // Update with the new data
     $DB->update_record('course', $data);
     // make sure the modinfo cache is reset
index 4321d51..77abf8f 100644 (file)
@@ -1116,7 +1116,8 @@ class core_course_renderer extends plugin_renderer_base {
         // If we display course in collapsed form but the course has summary or course contacts, display the link to the info page.
         $content .= html_writer::start_tag('div', array('class' => 'moreinfo'));
         if ($chelper->get_show_courses() < self::COURSECAT_SHOW_COURSES_EXPANDED) {
-            if ($course->has_summary() || $course->has_course_contacts() || $course->has_course_overviewfiles()) {
+            if ($course->has_summary() || $course->has_course_contacts() || $course->has_course_overviewfiles()
+                    || $course->has_custom_fields()) {
                 $url = new moodle_url('/course/info.php', array('id' => $course->id));
                 $image = $this->output->pix_icon('i/info', $this->strings->summary);
                 $content .= html_writer::link($url, $image, array('title' => $this->strings->summary));
@@ -1221,6 +1222,13 @@ class core_course_renderer extends plugin_renderer_base {
             }
         }
 
+        // Display custom fields.
+        if ($course->has_custom_fields()) {
+            $handler = core_course\customfield\course_handler::create();
+            $customfields = $handler->display_custom_fields_data($course->get_custom_fields());
+            $content .= \html_writer::tag('div', $customfields, ['class' => 'customfields-container']);
+        }
+
         return $content;
     }
 
@@ -2497,6 +2505,7 @@ class coursecat_helper {
         // and core_course_category::search_courses().
         $this->coursesdisplayoptions['summary'] = $showcourses >= core_course_renderer::COURSECAT_SHOW_COURSES_AUTO;
         $this->coursesdisplayoptions['coursecontacts'] = $showcourses >= core_course_renderer::COURSECAT_SHOW_COURSES_EXPANDED;
+        $this->coursesdisplayoptions['customfields'] = $showcourses >= core_course_renderer::COURSECAT_SHOW_COURSES_COLLAPSED;
         return $this;
     }
 
@@ -2543,6 +2552,7 @@ class coursecat_helper {
      *      this may be a huge list!
      *    - summary - preloads fields 'summary' and 'summaryformat'
      *    - coursecontacts - preloads course contacts
+     *    - customfields - preloads custom fields data
      *    - isenrolled - preloads indication whether this user is enrolled in the course
      *    - sort - list of fields to sort. Example
      *             array('idnumber' => 1, 'shortname' => 1, 'id' => -1)
diff --git a/course/tests/behat/customfields_locked.feature b/course/tests/behat/customfields_locked.feature
new file mode 100644 (file)
index 0000000..a3c73b6
--- /dev/null
@@ -0,0 +1,30 @@
+@core @core_course @core_customfield
+Feature: Fields locked control where they are displayed
+  In order to display custom fields on course listing
+  As a manager
+  I can change the visibility of the fields
+
+  Background:
+    Given the following "custom field categories" exist:
+      | name              | component   | area   | itemid |
+      | Category for test | core_course | course | 0      |
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student@example.com  |
+    And the following "courses" exist:
+      | fullname | shortname | format |
+      | Course 1 | C1        | topics |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+
+  Scenario: Display course custom fields on homepage
+    When I log in as "admin"
+    And I navigate to "Courses > Course custom fields" in site administration
+    And I click on "Add a new custom field" "link"
+    And I click on "Text field" "link"
+    And I set the following fields to these values:
+      | Name       | Test field |
+      | Short name | testfield  |
+      | Locked     | No         |
diff --git a/course/tests/behat/customfields_visibility.feature b/course/tests/behat/customfields_visibility.feature
new file mode 100644 (file)
index 0000000..bc32f62
--- /dev/null
@@ -0,0 +1,84 @@
+@core @core_course @core_customfield
+Feature: The visibility of fields control where they are displayed
+  In order to display custom fields on course listing
+  As a manager
+  I can change the visibility of the fields
+
+  Background:
+    Given the following "custom field categories" exist:
+      | name              | component   | area   | itemid |
+      | Category for test | core_course | course | 0      |
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student@example.com  |
+    And the following "courses" exist:
+      | fullname | shortname | format |
+      | Course 1 | C1        | topics |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+
+  Scenario: Display course custom fields on homepage
+    When I log in as "admin"
+    And I navigate to "Courses > Course custom fields" in site administration
+    And I click on "Add a new custom field" "link"
+    And I click on "Text field" "link"
+    And I set the following fields to these values:
+      | Name       | Test field |
+      | Short name | testfield  |
+      | Visible to | Everyone   |
+    And I press "Save changes"
+    And I log out
+    Then I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Test field | testcontent |
+    And I press "Save and display"
+    And I am on site homepage
+    Then I should see "Test field: testcontent"
+
+  Scenario: Do not display course custom fields on homepage
+    When I log in as "admin"
+    And I navigate to "Courses > Course custom fields" in site administration
+    And I click on "Add a new custom field" "link"
+    And I click on "Text field" "link"
+    And I set the following fields to these values:
+      | Name       | Test field  |
+      | Short name | testfield   |
+      | Visible to | Not visible |
+    And I press "Save changes"
+    And I log out
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Test field | testcontent |
+    And I press "Save and display"
+    And I am on site homepage
+    And I should not see "Test field: testcontent"
+
+  Scenario: Display course custom fields on homepage only to course editors
+    When I log in as "admin"
+    And I navigate to "Courses > Course custom fields" in site administration
+    And I click on "Add a new custom field" "link"
+    And I click on "Text field" "link"
+    And I set the following fields to these values:
+      | Name       | Test field     |
+      | Short name | testfield      |
+      | Visible to | Course editors |
+    And I press "Save changes"
+    And I log out
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Test field | testcontent |
+    And I press "Save and display"
+    When I am on site homepage
+    And I should see "Test field: testcontent"
+    And I log out
+    When I log in as "student"
+    When I am on site homepage
+    And I should not see "Test field: testcontent"
diff --git a/course/tests/customfield_test.php b/course/tests/customfield_test.php
new file mode 100644 (file)
index 0000000..2673208
--- /dev/null
@@ -0,0 +1,191 @@
+<?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/>.
+
+/**
+ * Tests for customfields in courses
+ *
+ * @package    core_course
+ * @copyright  2018 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+
+/**
+ * Tests for customfields in courses
+ *
+ * @package    core_course
+ * @copyright  2018 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_course_customfield_testcase extends advanced_testcase {
+
+    /**
+     * Set up
+     */
+    protected function setUp() {
+        parent::setUp();
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $dg = self::getDataGenerator();
+        $catid = $dg->create_custom_field_category([])->get('id');
+        $dg->create_custom_field(['categoryid' => $catid, 'type' => 'text', 'shortname' => 'f1']);
+        $dg->create_custom_field(['categoryid' => $catid, 'type' => 'checkbox', 'shortname' => 'f2']);
+        $dg->create_custom_field(['categoryid' => $catid, 'type' => 'date', 'shortname' => 'f3',
+            'configdata' => ['startyear' => 2000, 'endyear' => 3000, 'includetime' => 1]]);
+        $dg->create_custom_field(['categoryid' => $catid, 'type' => 'select', 'shortname' => 'f4',
+            'configdata' => ['options' => "a\nb\nc"]]);
+        $dg->create_custom_field(['categoryid' => $catid, 'type' => 'textarea', 'shortname' => 'f5']);
+    }
+
+    /**
+     * Test creating course with customfields and retrieving them
+     */
+    public function test_create_course() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+
+        $now = time();
+        $c1 = $dg->create_course(['shortname' => 'SN', 'fullname' => 'FN',
+            'summary' => 'DESC', 'summaryformat' => FORMAT_MOODLE,
+            'customfield_f1' => 'some text', 'customfield_f2' => 1,
+            'customfield_f3' => $now, 'customfield_f4' => 2,
+            'customfield_f5_editor' => ['text' => 'test', 'format' => FORMAT_HTML]]);
+
+        $data = \core_course\customfield\course_handler::create()->export_instance_data_object($c1->id);
+
+        $this->assertEquals('some text', $data->f1);
+        $this->assertEquals('Yes', $data->f2);
+        $this->assertEquals(userdate($now, get_string('strftimedaydatetime')), $data->f3);
+        $this->assertEquals('b', $data->f4);
+        $this->assertEquals('test', $data->f5);
+
+        $this->assertEquals(5, count($DB->get_records('customfield_data')));
+
+        delete_course($c1->id, false);
+
+        $this->assertEquals(0, count($DB->get_records('customfield_data')));
+    }
+
+    /**
+     * Backup a course and return its backup ID.
+     *
+     * @param int $courseid The course ID.
+     * @param int $userid The user doing the backup.
+     * @return string
+     */
+    protected function backup_course($courseid, $userid = 2) {
+        $backuptempdir = make_backup_temp_directory('');
+        $packer = get_file_packer('application/vnd.moodle.backup');
+
+        $bc = new backup_controller(backup::TYPE_1COURSE, $courseid, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO,
+            backup::MODE_GENERAL, $userid);
+        $bc->execute_plan();
+
+        $results = $bc->get_results();
+        $results['backup_destination']->extract_to_pathname($packer, "$backuptempdir/core_course_testcase");
+
+        $bc->destroy();
+        unset($bc);
+        return 'core_course_testcase';
+    }
+
+    /**
+     * Restore a course.
+     *
+     * @param int $backupid The backup ID.
+     * @param int $courseid The course ID to restore in, or 0.
+     * @param int $userid The ID of the user performing the restore.
+     * @return stdClass The updated course object.
+     */
+    protected function restore_course($backupid, $courseid, $userid) {
+        global $DB;
+
+        $target = backup::TARGET_CURRENT_ADDING;
+        if (!$courseid) {
+            $target = backup::TARGET_NEW_COURSE;
+            $categoryid = $DB->get_field_sql("SELECT MIN(id) FROM {course_categories}");
+            $courseid = restore_dbops::create_new_course('Tmp', 'tmp', $categoryid);
+        }
+
+        $rc = new restore_controller($backupid, $courseid, backup::INTERACTIVE_NO, backup::MODE_GENERAL, $userid, $target);
+        $target == backup::TARGET_NEW_COURSE ?: $rc->get_plan()->get_setting('overwrite_conf')->set_value(true);
+        $this->assertTrue($rc->execute_precheck());
+        $rc->execute_plan();
+
+        $course = $DB->get_record('course', array('id' => $rc->get_courseid()));
+
+        $rc->destroy();
+        unset($rc);
+        return $course;
+    }
+
+    /**
+     * Test backup and restore of custom fields
+     */
+    public function test_restore_customfields() {
+        global $USER;
+        $dg = $this->getDataGenerator();
+
+        $c1 = $dg->create_course(['shortname' => 'SN', 'fullname' => 'FN',
+            'summary' => 'DESC', 'summaryformat' => FORMAT_MOODLE,
+            'customfield_f1' => 'some text', 'customfield_f2' => 1]);
+        $backupid = $this->backup_course($c1->id);
+
+        // The information is restored but adapted because names are already taken.
+        $c2 = $this->restore_course($backupid, 0, $USER->id);
+
+        $data = \core_course\customfield\course_handler::create()->export_instance_data_object($c1->id);
+        $this->assertEquals('some text', $data->f1);
+        $this->assertEquals('Yes', $data->f2);
+    }
+
+    /**
+     * Delete a category that has fields and the fields have data.
+     */
+    public function test_delete_category() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+
+        $now = time();
+        $c1 = $dg->create_course(['shortname' => 'SN', 'fullname' => 'FN',
+            'summary' => 'DESC', 'summaryformat' => FORMAT_MOODLE,
+            'customfield_f1' => 'some text', 'customfield_f2' => 1,
+            'customfield_f3' => $now, 'customfield_f4' => 2,
+            'customfield_f5_editor' => ['text' => 'test', 'format' => FORMAT_HTML]]);
+
+        // Find the category and delete it.
+        $cats = \core_course\customfield\course_handler::create()->get_categories_with_fields();
+        $cat = reset($cats);
+        $cat->get_handler()->delete_category($cat);
+
+        // Course no longer has the customfield properties.
+        $course = course_get_format($c1->id)->get_course();
+        $keys = array_keys((array)$course);
+        $this->assertEmpty(array_intersect($keys, ['customfield_f1', 'customfield_f2',
+            'customfield_f3', 'customfield_f4', 'customfield_f5']));
+
+        // Nothing in customfield tables either.
+        $this->assertEquals(0, count($DB->get_records('customfield_field')));
+        $this->assertEquals(0, count($DB->get_records('customfield_data')));
+    }
+
+}
index 39c187f..627182e 100644 (file)
@@ -409,6 +409,14 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         // Enable course themes.
         set_config('allowcoursethemes', 1);
 
+        // Custom fields.
+        $fieldcategory = self::getDataGenerator()->create_custom_field_category(['name' => 'Other fields']);
+
+        $customfield = ['shortname' => 'test', 'name' => 'Custom field', 'type' => 'text',
+            'categoryid' => $fieldcategory->get('id'),
+            'configdata' => ['visibility' => \core_course\customfield\course_handler::VISIBLETOALL]];
+        $field = self::getDataGenerator()->create_custom_field($customfield);
+
         // Set the required capabilities by the external function
         $contextid = context_system::instance()->id;
         $roleid = $this->assignUserCapability('moodle/course:create', $contextid);
@@ -456,7 +464,11 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         foreach ($course3options as $key => $value) {
             $course3['courseformatoptions'][] = array('name' => $key, 'value' => $value);
         }
-        $courses = array($course1, $course2, $course3);
+        $course4['fullname'] = 'Test course with custom fields';
+        $course4['shortname'] = 'Testcoursecustomfields';
+        $course4['categoryid'] = $category->id;
+        $course4['customfields'] = [['shortname' => $customfield['shortname'], 'value' => 'Test value']];
+        $courses = array($course4, $course1, $course2, $course3);
 
         $createdcourses = core_course_external::create_courses($courses);
 
@@ -464,7 +476,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $createdcourses = external_api::clean_returnvalue(core_course_external::create_courses_returns(), $createdcourses);
 
         // Check that right number of courses were created.
-        $this->assertEquals(3, count($createdcourses));
+        $this->assertEquals(4, count($createdcourses));
 
         // Check that the courses were correctly created.
         foreach ($createdcourses as $createdcourse) {
@@ -520,6 +532,14 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                 $this->assertEquals(course_get_format($createdcourse['id'])->get_last_section_number(),
                     $course3options['numsections']);
                 $this->assertEquals($courseinfo->coursedisplay, $course3options['coursedisplay']);
+            } else if ($createdcourse['shortname'] == $course4['shortname']) {
+                $this->assertEquals($courseinfo->fullname, $course4['fullname']);
+                $this->assertEquals($courseinfo->shortname, $course4['shortname']);
+                $this->assertEquals($courseinfo->category, $course4['categoryid']);
+
+                $handler = core_course\customfield\course_handler::create();
+                $customfields = $handler->export_instance_data_object($createdcourse['id']);
+                $this->assertEquals((object)['test' => 'Test value'], $customfields);
             } else {
                 throw new moodle_exception('Unexpected shortname');
             }
@@ -608,11 +628,22 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $coursedata['summaryformat'] = FORMAT_MOODLE;
         $course1  = self::getDataGenerator()->create_course($coursedata);
 
+        $fieldcategory = self::getDataGenerator()->create_custom_field_category(
+            ['name' => 'Other fields']);
+
+        $customfield = ['shortname' => 'test', 'name' => 'Custom field', 'type' => 'text',
+            'categoryid' => $fieldcategory->get('id')];
+        $field = self::getDataGenerator()->create_custom_field($customfield);
+
+        $customfieldvalue = ['shortname' => 'test', 'value' => 'Test value'];
+
         $generatedcourses[$course1->id] = $course1;
         $course2  = self::getDataGenerator()->create_course();
         $generatedcourses[$course2->id] = $course2;
         $course3  = self::getDataGenerator()->create_course(array('format' => 'topics'));
         $generatedcourses[$course3->id] = $course3;
+        $course4  = self::getDataGenerator()->create_course(['customfields' => [$customfieldvalue]]);
+        $generatedcourses[$course4->id] = $course4;
 
         // Set the required capabilities by the external function.
         $context = context_system::instance();
@@ -623,15 +654,17 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                 context_course::instance($course2->id)->id, $roleid);
         $this->assignUserCapability('moodle/course:update',
                 context_course::instance($course3->id)->id, $roleid);
+        $this->assignUserCapability('moodle/course:update',
+                context_course::instance($course4->id)->id, $roleid);
 
         $courses = core_course_external::get_courses(array('ids' =>
-            array($course1->id, $course2->id)));
+            array($course1->id, $course2->id, $course4->id)));
 
         // We need to execute the return values cleaning process to simulate the web service server.
         $courses = external_api::clean_returnvalue(core_course_external::get_courses_returns(), $courses);
 
-        // Check we retrieve the good total number of categories.
-        $this->assertEquals(2, count($courses));
+        // Check we retrieve the good total number of courses.
+        $this->assertEquals(3, count($courses));
 
         foreach ($courses as $course) {
             $coursecontext = context_course::instance($course['id']);
@@ -668,6 +701,9 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                     array('name' => 'coursedisplay', 'value' => $dbcourse->coursedisplay),
                 ));
             }
+            if ($dbcourse->id == 4) {
+                $this->assertEquals($course['customfields'], [array_merge($customfield, $customfieldvalue)]);
+            }
         }
 
         // Get all courses in the DB
@@ -728,6 +764,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $page = new moodle_page();
         $page->set_course($course2);
         $page->blocks->add_blocks([BLOCK_POS_LEFT => ['news_items'], BLOCK_POS_RIGHT => []], 'course-view-*');
+
         // Search by name.
         $results = core_course_external::search_courses('search', 'FIRST');
         $results = external_api::clean_returnvalue(core_course_external::search_courses_returns(), $results);
@@ -753,6 +790,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                 array('TAG-LABEL ON SECOND COURSE'));
         $taginstance = $DB->get_record('tag_instance',
                 array('itemtype' => 'course', 'itemid' => $course2->id), '*', MUST_EXIST);
+
         // Search by tagid.
         $results = core_course_external::search_courses('tagid', $taginstance->tagid);
         $results = external_api::clean_returnvalue(core_course_external::search_courses_returns(), $results);
@@ -782,7 +820,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assertEquals(1, $results['total']);
         $this->assertEquals($coursedata1['fullname'], $results['courses'][0]['fullname']);
 
-        // Check that we can see both without the limit to enrolled setting.
+        // Check that we can see all courses without the limit to enrolled setting.
         $results = core_course_external::search_courses('search', 'COURSE', 0, 0, array(), 0);
         $results = external_api::clean_returnvalue(core_course_external::search_courses_returns(), $results);
         $this->assertCount(2, $results['courses']);
@@ -798,7 +836,6 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         // Search by block (use news_items default block). Should fail (only admins allowed).
         $this->expectException('required_capability_exception');
         $results = core_course_external::search_courses('blocklist', $blockid);
-
     }
 
     /**
@@ -1188,6 +1225,27 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assertArrayNotHasKey('completiondata', $result[0]['modules'][0]);
     }
 
+    /**
+     * Test mimetype is returned for resources with showtype set.
+     */
+    public function test_get_course_contents_including_mimetype() {
+        $this->resetAfterTest(true);
+
+        $this->setAdminUser();
+        $course = self::getDataGenerator()->create_course();
+
+        $record = new stdClass();
+        $record->course = $course->id;
+        $record->showtype = 1;
+        $resource = self::getDataGenerator()->create_module('resource', $record);
+
+        $result = core_course_external::get_course_contents($course->id);
+        $result = external_api::clean_returnvalue(core_course_external::get_course_contents_returns(), $result);
+        $this->assertCount(1, $result[0]['modules']);   // One module, first section.
+        $customdata = unserialize(json_decode($result[0]['modules'][0]['customdata']));
+        $this->assertEquals('text/plain', $customdata['filedetails']['mimetype']);
+    }
+
     /**
      * Test duplicate_course
      */
@@ -1254,6 +1312,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $contextid = context_system::instance()->id;
         $roleid = $this->assignUserCapability('moodle/course:update', $contextid);
         $this->assignUserCapability('moodle/course:changecategory', $contextid, $roleid);
+        $this->assignUserCapability('moodle/course:changelockedcustomfields', $contextid, $roleid);
         $this->assignUserCapability('moodle/course:changefullname', $contextid, $roleid);
         $this->assignUserCapability('moodle/course:changeshortname', $contextid, $roleid);
         $this->assignUserCapability('moodle/course:changeidnumber', $contextid, $roleid);
@@ -1262,19 +1321,32 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assignUserCapability('moodle/course:viewhiddencourses', $contextid, $roleid);
         $this->assignUserCapability('moodle/course:setforcedlanguage', $contextid, $roleid);
 
-        // Create category and course.
+        // Create category and courses.
         $category1  = self::getDataGenerator()->create_category();
         $category2  = self::getDataGenerator()->create_category();
+
         $originalcourse1 = self::getDataGenerator()->create_course();
         self::getDataGenerator()->enrol_user($USER->id, $originalcourse1->id, $roleid);
+
         $originalcourse2 = self::getDataGenerator()->create_course();
         self::getDataGenerator()->enrol_user($USER->id, $originalcourse2->id, $roleid);
 
+        // Course with custom fields.
+        $fieldcategory = self::getDataGenerator()->create_custom_field_category(['name' => 'Other fields']);
+        $customfield = ['shortname' => 'test', 'name' => 'Custom field', 'type' => 'text',
+            'categoryid' => $fieldcategory->get('id'),
+            'configdata' => ['visibility' => \core_course\customfield\course_handler::VISIBLETOALL, 'locked' => 1]];
+        $field = self::getDataGenerator()->create_custom_field($customfield);
+
+        $originalcourse3 = self::getDataGenerator()->create_course(['customfield_test' => 'Test value']);
+        self::getDataGenerator()->enrol_user($USER->id, $originalcourse3->id, $roleid);
+
         // Course values to be updated.
         $course1['id'] = $originalcourse1->id;
         $course1['fullname'] = 'Updated test course 1';
         $course1['shortname'] = 'Udestedtestcourse1';
         $course1['categoryid'] = $category1->id;
+
         $course2['id'] = $originalcourse2->id;
         $course2['fullname'] = 'Updated test course 2';
         $course2['shortname'] = 'Updestedtestcourse2';
@@ -1297,11 +1369,15 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $course2['enablecompletion'] = 1;
         $course2['lang'] = 'en';
         $course2['forcetheme'] = 'bootstrapbase';
-        $courses = array($course1, $course2);
+
+        $course3['id'] = $originalcourse3->id;
+        $updatedcustomfieldvalue = ['shortname' => 'test', 'value' => 'Updated test value'];
+        $course3['customfields'] = [$updatedcustomfieldvalue];
+        $courses = array($course1, $course2, $course3);
 
         $updatedcoursewarnings = core_course_external::update_courses($courses);
         $updatedcoursewarnings = external_api::clean_returnvalue(core_course_external::update_courses_returns(),
-                                                                    $updatedcoursewarnings);
+                $updatedcoursewarnings);
         $COURSE = $origcourse; // Restore $COURSE. Instead of using the OLD one set by the previous line.
 
         // Check that right number of courses were created.
@@ -1310,6 +1386,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         // Check that the courses were correctly created.
         foreach ($courses as $course) {
             $courseinfo = course_get_format($course['id'])->get_course();
+            $customfields = \core_course\customfield\course_handler::create()->export_instance_data_object($course['id']);
             if ($course['id'] == $course2['id']) {
                 $this->assertEquals($course2['fullname'], $courseinfo->fullname);
                 $this->assertEquals($course2['shortname'], $courseinfo->shortname);
@@ -1336,6 +1413,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                 }
 
                 $this->assertEquals($course2['enablecompletion'], $courseinfo->enablecompletion);
+                $this->assertEquals(['test' => null], (array)$customfields);
             } else if ($course['id'] == $course1['id']) {
                 $this->assertEquals($course1['fullname'], $courseinfo->fullname);
                 $this->assertEquals($course1['shortname'], $courseinfo->shortname);
@@ -1345,6 +1423,9 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                 $this->assertEquals(5, course_get_format($course['id'])->get_last_section_number());
                 $this->assertEquals(0, $courseinfo->newsitems);
                 $this->assertEquals(FORMAT_MOODLE, $courseinfo->summaryformat);
+                $this->assertEquals(['test' => null], (array)$customfields);
+            } else if ($course['id'] == $course3['id']) {
+                $this->assertEquals(['test' => $updatedcustomfieldvalue['value']], (array)$customfields);
             } else {
                 throw new moodle_exception('Unexpected shortname');
             }
@@ -1475,6 +1556,21 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $updatedcoursewarnings = external_api::clean_returnvalue(core_course_external::update_courses_returns(),
                                                                     $updatedcoursewarnings);
         $this->assertEquals(1, count($updatedcoursewarnings['warnings']));
+
+        // Try update course custom fields without capability.
+        $this->unassignUserCapability('moodle/course:changelockedcustomfields', $contextid, $roleid);
+        $user = self::getDataGenerator()->create_user();
+        $this->setUser($user);
+        self::getDataGenerator()->enrol_user($user->id, $course3['id'], $roleid);
+
+        $newupdatedcustomfieldvalue = ['shortname' => 'test', 'value' => 'New updated value'];
+        $course3['customfields'] = [$newupdatedcustomfieldvalue];
+
+        core_course_external::update_courses([$course3]);
+
+        // Custom field was not updated.
+        $customfields = \core_course\customfield\course_handler::create()->export_instance_data_object($course3['id']);
+        $this->assertEquals(['test' => $updatedcustomfieldvalue['value']], (array)$customfields);
     }
 
     /**
index ac9d44d..c64ce2c 100644 (file)
@@ -48,12 +48,18 @@ class course_search_testcase extends advanced_testcase {
      */
     protected $sectionareaid = null;
 
+    /**
+     * @var string Area id for custom fields.
+     */
+    protected $customfieldareaid = null;
+
     public function setUp() {
         $this->resetAfterTest(true);
         set_config('enableglobalsearch', true);
 
         $this->mycoursesareaid = \core_search\manager::generate_areaid('core_course', 'mycourse');
         $this->sectionareaid = \core_search\manager::generate_areaid('core_course', 'section');
+        $this->customfieldareaid = \core_search\manager::generate_areaid('core_course', 'customfield');
 
         // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this.
         $search = testable_core_search::instance();
@@ -447,6 +453,90 @@ class course_search_testcase extends advanced_testcase {
                 $searcharea->check_access($documents[1]->get('itemid')));
     }
 
+    /**
+     * Indexing custom fields contents.
+     *
+     * @return void
+     */
+    public function test_customfield_indexing() {
+        // Returns the instance as long as the area is supported.
+        $searcharea = \core_search\manager::get_search_area($this->customfieldareaid);
+        $this->assertInstanceOf('\core_course\search\customfield', $searcharea);
+
+        // We need to be admin for custom fields creation.
+        $this->setAdminUser();
+
+        // Custom fields.
+        $fieldcategory = self::getDataGenerator()->create_custom_field_category(['name' => 'Other fields']);
+        $customfield = ['shortname' => 'test', 'name' => 'Customfield', 'type' => 'text',
+            'categoryid' => $fieldcategory->get('id')];
+        $field = self::getDataGenerator()->create_custom_field($customfield);
+
+        $course1data = ['customfields' => [['shortname' => $customfield['shortname'], 'value' => 'Customvalue1']]];
+        $course1  = self::getDataGenerator()->create_course($course1data);
+
+        $course2data = ['customfields' => [['shortname' => $customfield['shortname'], 'value' => 'Customvalue2']]];
+        $course2 = self::getDataGenerator()->create_course($course2data);
+
+        // All records.
+        $recordset = $searcharea->get_recordset_by_timestamp(0);
+        $this->assertTrue($recordset->valid());
+        $nrecords = 0;
+        foreach ($recordset as $record) {
+            $this->assertInstanceOf('stdClass', $record);
+            $doc = $searcharea->get_document($record);
+            $this->assertInstanceOf('\core_search\document', $doc);
+            $nrecords++;
+        }
+        // If there would be an error/failure in the foreach above the recordset would be closed on shutdown.
+        $recordset->close();
+        $this->assertEquals(2, $nrecords);
+
+        // The +2 is to prevent race conditions.
+        $recordset = $searcharea->get_recordset_by_timestamp(time() + 2);
+
+        // No new records.
+        $this->assertFalse($recordset->valid());
+        $recordset->close();
+    }
+
+    /**
+     * Document contents for custom fields.
+     *
+     * @return void
+     */
+    public function test_customfield_document() {
+        global $DB;
+        // Returns the instance as long as the area is supported.
+        $searcharea = \core_search\manager::get_search_area($this->customfieldareaid);
+
+        // We need to be admin for custom fields creation.
+        $this->setAdminUser();
+
+        // Custom fields.
+        $fieldcategory = self::getDataGenerator()->create_custom_field_category(['name' => 'Other fields']);
+        $customfield = ['shortname' => 'test', 'name' => 'Customfield', 'type' => 'text',
+            'categoryid' => $fieldcategory->get('id')];
+        $field = self::getDataGenerator()->create_custom_field($customfield);
+
+        $coursedata = ['customfields' => [['shortname' => $customfield['shortname'], 'value' => 'Customvalue1']]];
+        $course  = self::getDataGenerator()->create_course($coursedata);
+
+        // Retrieve data we need to compare with document instance.
+        $record = $DB->get_record('customfield_data', ['instanceid' => $course->id]);
+        $field = \core_customfield\field_controller::create($record->fieldid);
+        $data = \core_customfield\data_controller::create(0, $record, $field);
+
+        $doc = $searcharea->get_document($record);
+        $this->assertInstanceOf('\core_search\document', $doc);
+        $this->assertEquals('Customfield', $doc->get('title'));
+        $this->assertEquals('Customvalue1', $doc->get('content'));
+        $this->assertEquals($course->id, $doc->get('courseid'));
+        $this->assertEquals(\core_search\manager::NO_OWNER_ID, $doc->get('owneruserid'));
+        $this->assertEquals($course->id, $doc->get('courseid'));
+        $this->assertFalse($doc->is_set('userid'));
+    }
+
     /**
      * Test document icon for mycourse area.
      */
@@ -478,4 +568,15 @@ class course_search_testcase extends advanced_testcase {
         $this->assertEquals('i/section', $result->get_name());
         $this->assertEquals('moodle', $result->get_component());
     }
+
+    /**
+     * Test assigned search categories.
+     */
+    public function test_get_category_names() {
+        $coursessearcharea = \core_search\manager::get_search_area($this->mycoursesareaid);
+        $sectionsearcharea = \core_search\manager::get_search_area($this->sectionareaid);
+
+        $this->assertEquals(['core-courses'], $coursessearcharea->get_category_names());
+        $this->assertEquals(['core-course-content'], $sectionsearcharea->get_category_names());
+    }
 }
diff --git a/customfield/amd/build/form.min.js b/customfield/amd/build/form.min.js
new file mode 100644 (file)
index 0000000..5fc10f1
Binary files /dev/null and b/customfield/amd/build/form.min.js differ
diff --git a/customfield/amd/src/form.js b/customfield/amd/src/form.js
new file mode 100644 (file)
index 0000000..fa5b257
--- /dev/null
@@ -0,0 +1,202 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This module depends on the real jquery - and returns the non-global version of it.
+ *
+ * @module     core_customfield/form
+ * @package    core_customfield
+ * @copyright  2018 Toni Barbera
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/str', 'core/notification', 'core/ajax', 'core/templates', 'core/sortable_list'], function(
+        $, Str, Notification, Ajax, Templates, SortableList) {
+
+    /**
+     * Display confirmation dialogue
+     *
+     * @param {Number} id
+     * @param {String} type
+     * @param {String} component
+     * @param {String} area
+     * @param {Number} itemid
+     */
+    var confirmDelete = function(id, type, component, area, itemid) {
+        Str.get_strings([
+            {'key': 'confirm'},
+            {'key': 'confirmdelete' + type, component: 'core_customfield'},
+            {'key': 'yes'},
+            {'key': 'no'},
+        ]).done(function(s) {
+            Notification.confirm(s[0], s[1], s[2], s[3], function() {
+                var func = (type === 'field') ? 'core_customfield_delete_field' : 'core_customfield_delete_category';
+                Ajax.call([
+                    {methodname: func, args: {id: id}},
+                    {methodname: 'core_customfield_reload_template', args: {component: component, area: area, itemid: itemid}}
+                ])[1].then(function(response) {
+                    return Templates.render('core_customfield/list', response);
+                }).then(function(html, js) {
+                    Templates.replaceNodeContents($('[data-region="list-page"]'), html, js);
+                    return null;
+                }).fail(Notification.exception);
+            });
+        }).fail(Notification.exception);
+    };
+
+    /**
+     * Creates a new custom fields category with default name and updates the list
+     *
+     * @param {String} component
+     * @param {String} area
+     * @param {Number} itemid
+     */
+    var createNewCategory = function(component, area, itemid) {
+        var promises = Ajax.call([
+                {methodname: 'core_customfield_create_category', args: {component: component, area: area, itemid: itemid}},
+                {methodname: 'core_customfield_reload_template', args: {component: component, area: area, itemid: itemid}}
+            ]),
+            categoryid;
+
+        promises[0].then(function(response) {
+            categoryid = response;
+            return null;
+        }).fail(Notification.exception);
+
+        promises[1].then(function(response) {
+            return Templates.render('core_customfield/list', response);
+        }).then(function(html, js) {
+            Templates.replaceNodeContents($('[data-region="list-page"]'), html, js);
+            window.location.href = '#category-' + categoryid;
+            return null;
+        }).fail(Notification.exception);
+    };
+
+    return {
+        /**
+         * Initialise the custom fields manager
+         */
+        init: function() {
+            var mainlist = $('#customfield_catlist'),
+                component = mainlist.attr('data-component'),
+                area = mainlist.attr('data-area'),
+                itemid = mainlist.attr('data-itemid');
+            $("[data-role=deletefield]").on('click', function(e) {
+                confirmDelete($(this).attr('data-id'), 'field', component, area, itemid);
+                e.preventDefault();
+            });
+            $("[data-role=deletecategory]").on('click', function(e) {
+                confirmDelete($(this).attr('data-id'), 'category', component, area, itemid);
+                e.preventDefault();
+            });
+            $('[data-role=addnewcategory]').on('click', function() {
+                createNewCategory(component, area, itemid);
+            });
+
+            var categoryName = function(element) {
+                return element
+                    .closest('[data-category-id]')
+                    .find('[data-inplaceeditable][data-itemtype=category][data-component=core_customfield]')
+                    .attr('data-value');
+            };
+
+            // Sort category.
+            var sortCat = new SortableList(
+                '#customfield_catlist .categorieslist',
+                {moveHandlerSelector: '.movecategory [data-drag-type=move]'}
+            );
+
+            sortCat.getElementName = function(el) {
+                return $.Deferred().resolve(categoryName(el));
+            };
+
+            $('[data-category-id]').on('sortablelist-drop', function(evt, info) {
+                if (info.positionChanged) {
+                    var promises = Ajax.call([
+                        {
+                            methodname: 'core_customfield_move_category',
+                            args: {
+                                id: info.element.data('category-id'),
+                                beforeid: info.targetNextElement.data('category-id')
+                            }
+
+                        },
+                    ]);
+                    promises[0].fail(Notification.exception);
+                }
+                evt.stopPropagation(); // Important for nested lists to prevent multiple targets.
+            });
+
+            // Sort fields.
+            var sort = new SortableList(
+                '#customfield_catlist .fieldslist tbody',
+                {moveHandlerSelector: '.movefield [data-drag-type=move]'}
+            );
+
+            sort.getDestinationName = function(parentElement, afterElement) {
+                if (!afterElement.length) {
+                    return Str.get_string('totopofcategory', 'customfield', categoryName(parentElement));
+                } else if (afterElement.attr('data-field-name')) {
+                    return Str.get_string('afterfield', 'customfield', afterElement.attr('data-field-name'));
+                } else {
+                    return $.Deferred().resolve('');
+                }
+            };
+
+            $('[data-field-name]').on('sortablelist-drop', function(evt, info) {
+                evt.stopPropagation(); // Important for nested lists to prevent multiple targets.
+                if (info.positionChanged) {
+                    var promises = Ajax.call([
+                        {
+                            methodname: 'core_customfield_move_field',
+                            args: {
+                                id: info.element.data('field-id'),
+                                beforeid: info.targetNextElement.data('field-id'),
+                                categoryid: Number(info.targetList.closest('[data-category-id]').attr('data-category-id'))
+                            },
+                        },
+                    ]);
+                    promises[0].fail(Notification.exception);
+                }
+            }).on('sortablelist-drag', function(evt) {
+                evt.stopPropagation(); // Important for nested lists to prevent multiple targets.
+                // Refreshing fields tables.
+                Str.get_string('therearenofields', 'core_customfield').then(function(s) {
+                    $('#customfield_catlist .categorieslist').children().each(function() {
+                        var fields = $(this).find($('.field')),
+                            nofields = $(this).find($('.nofields'));
+                        if (!fields.length && !nofields.length) {
+                            $(this).find('tbody').append(
+                                '<tr class="nofields"><td colspan="5">' + s + '</td></tr>'
+                            );
+                        }
+                        if (fields.length && nofields.length) {
+                            nofields.remove();
+                        }
+                    });
+                    return null;
+                }).fail(Notification.exception);
+            });
+
+            $('[data-category-id], [data-field-name]').on('sortablelist-dragstart',
+                function(evt, info) {
+                    setTimeout(function() {
+                        $('.sortable-list-is-dragged').width(info.element.width());
+                    }, 501);
+                }
+            );
+
+        }
+    };
+});
diff --git a/customfield/classes/api.php b/customfield/classes/api.php
new file mode 100644 (file)
index 0000000..440ba61
--- /dev/null
@@ -0,0 +1,415 @@
+<?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/>.
+
+/**
+ * Api customfield package
+ *
+ * @package   core_customfield
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield;
+
+use core\output\inplace_editable;
+use core_customfield\event\category_created;
+use core_customfield\event\category_deleted;
+use core_customfield\event\category_updated;
+use core_customfield\event\field_created;
+use core_customfield\event\field_deleted;
+use core_customfield\event\field_updated;
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * Class api
+ *
+ * @package core_customfield
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class api {
+
+    /**
+     * For the given instance and list of fields fields retrieves data associated with them
+     *
+     * @param field_controller[] $fields list of fields indexed by field id
+     * @param int $instanceid
+     * @param bool $adddefaults
+     * @return data_controller[] array of data_controller objects indexed by fieldid. All fields are present,
+     *    some data_controller objects may have 'id', some not
+     *     If ($adddefaults): All fieldids are present, some data_controller objects may have 'id', some not.
+     *     If (!$adddefaults): Only fieldids with data are present, all data_controller objects have 'id'.
+     */
+    public static function get_instance_fields_data(array $fields, int $instanceid, bool $adddefaults = true) : array {
+        return self::get_instances_fields_data($fields, [$instanceid], $adddefaults)[$instanceid];
+    }
+
+    /**
+     * For given list of instances and fields retrieves data associated with them
+     *
+     * @param field_controller[] $fields list of fields indexed by field id
+     * @param int[] $instanceids
+     * @param bool $adddefaults
+     * @return data_controller[][] 2-dimension array, first index is instanceid, second index is fieldid.
+     *     If ($adddefaults): All instanceids and all fieldids are present, some data_controller objects may have 'id', some not.
+     *     If (!$adddefaults): All instanceids are present but only fieldids with data are present, all
+     *         data_controller objects have 'id'.
+     */
+    public static function get_instances_fields_data(array $fields, array $instanceids, bool $adddefaults = true) : array {
+        global $DB;
+
+        // Create the results array where instances and fields order is the same as in the input arrays.
+        $result = array_fill_keys($instanceids, array_fill_keys(array_keys($fields), null));
+
+        if (empty($instanceids) || empty($fields)) {
+            return $result;
+        }
+
+        // Retrieve all existing data.
+        list($sqlfields, $params) = $DB->get_in_or_equal(array_keys($fields), SQL_PARAMS_NAMED, 'fld');
+        list($sqlinstances, $iparams) = $DB->get_in_or_equal($instanceids, SQL_PARAMS_NAMED, 'ins');
+        $sql = "SELECT d.*
+                  FROM {customfield_field} f
+                  JOIN {customfield_data} d ON (f.id = d.fieldid AND d.instanceid {$sqlinstances})
+                 WHERE f.id {$sqlfields}";
+        $fieldsdata = $DB->get_recordset_sql($sql, $params + $iparams);
+        foreach ($fieldsdata as $data) {
+            $result[$data->instanceid][$data->fieldid] = data_controller::create(0, $data, $fields[$data->fieldid]);
+        }
+        $fieldsdata->close();
+
+        if ($adddefaults) {
+            // Add default data where it was not retrieved.
+            foreach ($instanceids as $instanceid) {
+                foreach ($fields as $fieldid => $field) {
+                    if ($result[$instanceid][$fieldid] === null) {
+                        $result[$instanceid][$fieldid] =
+                            data_controller::create(0, (object)['instanceid' => $instanceid], $field);
+                    }
+                }
+            }
+        } else {
+            // Remove null-placeholders for data that was not retrieved.
+            foreach ($instanceids as $instanceid) {
+                $result[$instanceid] = array_filter($result[$instanceid]);
+            }
+        }
+
+        return $result;
+    }
+
+    /**
+     * Retrieve a list of all available custom field types
+     *
+     * @return   array   a list of the fieldtypes suitable to use in a select statement
+     */
+    public static function get_available_field_types() {
+        $fieldtypes = array();
+
+        $plugins = \core\plugininfo\customfield::get_enabled_plugins();
+        foreach ($plugins as $type => $unused) {
+            $fieldtypes[$type] = get_string('pluginname', 'customfield_' . $type);
+        }
+        asort($fieldtypes);
+
+        return $fieldtypes;
+    }
+
+    /**
+     * Updates or creates a field with data that came from a form
+     *
+     * @param field_controller $field
+     * @param \stdClass $formdata
+     */
+    public static function save_field_configuration(field_controller $field, \stdClass $formdata) {
+        foreach ($formdata as $key => $value) {
+            if ($key === 'configdata' && is_array($formdata->configdata)) {
+                $field->set($key, json_encode($value));
+            } else if ($key === 'id' || ($key === 'type' && $field->get('id'))) {
+                continue;
+            } else if (field::has_property($key)) {
+                $field->set($key, $value);
+            }
+        }
+
+        $isnewfield = empty($field->get('id'));
+
+        // Process files in description.
+        if (isset($formdata->description_editor)) {
+            if (!$field->get('id')) {
+                // We need 'id' field to store files used in description.
+                $field->save();
+            }
+
+            $data = (object) ['description_editor' => $formdata->description_editor];
+            $textoptions = $field->get_handler()->get_description_text_options();
+            $data = file_postupdate_standard_editor($data, 'description', $textoptions, $textoptions['context'],
+                'core_customfield', 'description', $field->get('id'));
+            $field->set('description', $data->description);
+            $field->set('descriptionformat', $data->descriptionformat);
+        }
+
+        // Save the field.
+        $field->save();
+
+        if ($isnewfield) {
+            // Move to the end of the category.
+            self::move_field($field, $field->get('categoryid'));
+        }
+
+        if ($isnewfield) {
+            field_created::create_from_object($field)->trigger();
+        } else {
+            field_updated::create_from_object($field)->trigger();
+        }
+    }
+
+    /**
+     * Change fields sort order, move field to another category
+     *
+     * @param field_controller $field field that needs to be moved
+     * @param int $categoryid category that needs to be moved
+     * @param int $beforeid id of the category this category needs to be moved before, 0 to move to the end
+     */
+    public static function move_field(field_controller $field, int $categoryid, int $beforeid = 0) {
+        global $DB;
+
+        if ($field->get('categoryid') != $categoryid) {
+            // Move field to another category. Validate that this category exists and belongs to the same component/area/itemid.
+            $category = $field->get_category();
+            $DB->get_record(category::TABLE, [
+                'component' => $category->get('component'),
+                'area' => $category->get('area'),
+                'itemid' => $category->get('itemid'),
+                'id' => $categoryid], 'id', MUST_EXIST);
+            $field->set('categoryid', $categoryid);
+            $field->save();
+            field_updated::create_from_object($field)->trigger();
+        }
+
+        // Reorder fields in the target category.
+        $records = $DB->get_records(field::TABLE, ['categoryid' => $categoryid], 'sortorder, id', '*');
+
+        $id = $field->get('id');
+        $fieldsids = array_values(array_diff(array_keys($records), [$id]));
+        $idx = $beforeid ? array_search($beforeid, $fieldsids) : false;
+        if ($idx === false) {
+            // Set as the last field.
+            $fieldsids = array_merge($fieldsids, [$id]);
+        } else {
+            // Set before field with id $beforeid.
+            $fieldsids = array_merge(array_slice($fieldsids, 0, $idx), [$id], array_slice($fieldsids, $idx));
+        }
+
+        foreach (array_values($fieldsids) as $idx => $fieldid) {
+            // Use persistent class to update the sortorder for each field that needs updating.
+            if ($records[$fieldid]->sortorder != $idx) {
+                $f = ($fieldid == $id) ? $field : new field(0, $records[$fieldid]);
+                $f->set('sortorder', $idx);
+                $f->save();
+            }
+        }
+    }
+
+    /**
+     * Delete a field
+     *
+     * @param field_controller $field
+     */
+    public static function delete_field_configuration(field_controller $field) : bool {
+        $event = field_deleted::create_from_object($field);
+        get_file_storage()->delete_area_files($field->get_handler()->get_configuration_context()->id, 'core_customfield',
+            'description', $field->get('id'));
+        $result = $field->delete();
+        $event->trigger();
+        return $result;
+    }
+
+    /**
+     * Returns an object for inplace editable
+     *
+     * @param category_controller $category category that needs to be moved
+     * @param bool $editable
+     * @return inplace_editable
+     */
+    public static function get_category_inplace_editable(category_controller $category, bool $editable = true) : inplace_editable {
+        return new inplace_editable('core_customfield',
+                                    'category',
+                                    $category->get('id'),
+                                    $editable,
+                                    $category->get_formatted_name(),
+                                    $category->get('name'),
+                                    get_string('editcategoryname', 'core_customfield'),
+                                    get_string('newvaluefor', 'core_form', format_string($category->get('name')))
+        );
+    }
+
+    /**
+     * Reorder categories, move given category before another category
+     *
+     * @param category_controller $category category that needs to be moved
+     * @param int $beforeid id of the category this category needs to be moved before, 0 to move to the end
+     */
+    public static function move_category(category_controller $category, int $beforeid = 0) {
+        global $DB;
+        $records = $DB->get_records(category::TABLE, [
+            'component' => $category->get('component'),
+            'area' => $category->get('area'),
+            'itemid' => $category->get('itemid')
+        ], 'sortorder, id', '*');
+
+        $id = $category->get('id');
+        $categoriesids = array_values(array_diff(array_keys($records), [$id]));
+        $idx = $beforeid ? array_search($beforeid, $categoriesids) : false;
+        if ($idx === false) {
+            // Set as the last category.
+            $categoriesids = array_merge($categoriesids, [$id]);
+        } else {
+            // Set before category with id $beforeid.
+            $categoriesids = array_merge(array_slice($categoriesids, 0, $idx), [$id], array_slice($categoriesids, $idx));
+        }
+
+        foreach (array_values($categoriesids) as $idx => $categoryid) {
+            // Use persistent class to update the sortorder for each category that needs updating.
+            if ($records[$categoryid]->sortorder != $idx) {
+                $c = ($categoryid == $id) ? $category : category_controller::create(0, $records[$categoryid]);
+                $c->set('sortorder', $idx);
+                $c->save();
+            }
+        }
+    }
+
+    /**
+     * Insert or update custom field category
+     *
+     * @param category_controller $category
+     */
+    public static function save_category(category_controller $category) {
+        $isnewcategory = empty($category->get('id'));
+
+        $category->save();
+
+        if ($isnewcategory) {
+            // Move to the end.
+            self::move_category($category);
+            category_created::create_from_object($category)->trigger();
+        } else {
+            category_updated::create_from_object($category)->trigger();
+        }
+    }
+
+    /**
+     * Delete a custom field category
+     *
+     * @param category_controller $category
+     * @return bool
+     */
+    public static function delete_category(category_controller $category) : bool {
+        $event = category_deleted::create_from_object($category);
+
+        // Delete all fields.
+        foreach ($category->get_fields() as $field) {
+            self::delete_field_configuration($field);
+        }
+
+        $result = $category->delete();
+        $event->trigger();
+        return $result;
+    }
+
+    /**
+     * Returns a list of categories with their related fields.
+     *
+     * @param string $component
+     * @param string $area
+     * @param int $itemid
+     * @return category_controller[]
+     */
+    public static function get_categories_with_fields(string $component, string $area, int $itemid) : array {
+        global $DB;
+
+        $categories = [];
+
+        $options = [
+                'component' => $component,
+                'area'      => $area,
+                'itemid'    => $itemid
+        ];
+
+        $plugins = \core\plugininfo\customfield::get_enabled_plugins();
+        list($sqlfields, $params) = $DB->get_in_or_equal(array_keys($plugins), SQL_PARAMS_NAMED, 'param', true, null);
+
+        $fields = 'f.*, ' . join(', ', array_map(function($field) {
+                return "c.$field AS category_$field";
+        }, array_diff(array_keys(category::properties_definition()), ['usermodified', 'timemodified'])));
+        $sql = "SELECT $fields
+                  FROM {customfield_category} c
+             LEFT JOIN {customfield_field} f ON c.id = f.categoryid AND f.type $sqlfields
+                 WHERE c.component = :component AND c.area = :area AND c.itemid = :itemid
+              ORDER BY c.sortorder, f.sortorder";
+        $fieldsdata = $DB->get_recordset_sql($sql, $options + $params);
+
+        foreach ($fieldsdata as $data) {
+            if (!array_key_exists($data->category_id, $categories)) {
+                $categoryobj = new \stdClass();
+                foreach ($data as $key => $value) {
+                    if (preg_match('/^category_(.*)$/', $key, $matches)) {
+                        $categoryobj->{$matches[1]} = $value;
+                    }
+                }
+                $category = category_controller::create(0, $categoryobj);
+                $categories[$categoryobj->id] = $category;
+            } else {
+                $category = $categories[$data->categoryid];
+            }
+            if ($data->id) {
+                $fieldobj = new \stdClass();
+                foreach ($data as $key => $value) {
+                    if (!preg_match('/^category_/', $key)) {
+                        $fieldobj->{$key} = $value;
+                    }
+                }
+                $field = field_controller::create(0, $fieldobj, $category);
+            }
+        }
+        $fieldsdata->close();
+
+        return $categories;
+    }
+
+    /**
+     * Prepares the object to pass to field configuration form set_data() method
+     *
+     * @param field_controller $field
+     * @return \stdClass
+     */
+    public static function prepare_field_for_config_form(field_controller $field) : \stdClass {
+        if ($field->get('id')) {
+            $formdata = $field->to_record();
+            $formdata->configdata = $field->get('configdata');
+            // Preprocess the description.
+            $textoptions = $field->get_handler()->get_description_text_options();
+            file_prepare_standard_editor($formdata, 'description', $textoptions, $textoptions['context'], 'core_customfield',
+                'description', $formdata->id);
+        } else {
+            $formdata = (object)['categoryid' => $field->get('categoryid'), 'type' => $field->get('type'), 'configdata' => []];
+        }
+        // Allow field to do more preprocessing (usually for editor or filemanager elements).
+        $field->prepare_for_config_form($formdata);
+        return $formdata;
+    }
+}
diff --git a/customfield/classes/category.php b/customfield/classes/category.php
new file mode 100644 (file)
index 0000000..dd89f55
--- /dev/null
@@ -0,0 +1,87 @@
+<?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/>.
+
+/**
+ * Customfield category persistent class
+ *
+ * @package   core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield;
+
+use core\persistent;
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * Class category
+ *
+ * @package core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class category extends persistent {
+    /**
+     * Database table.
+     */
+    const TABLE = 'customfield_category';
+
+    /**
+     * Return the definition of the properties of this model.
+     *
+     * @return array
+     */
+    protected static function define_properties() : array {
+        return array(
+                'name' => [
+                        'type' => PARAM_TEXT,
+                ],
+                'description' => [
+                        'type' => PARAM_RAW,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                ],
+                'descriptionformat' => [
+                        'type' => PARAM_INT,
+                        'default' => FORMAT_MOODLE,
+                        'optional' => true
+                ],
+                'component' => [
+                        'type' => PARAM_COMPONENT
+                ],
+                'area' => [
+                        'type' => PARAM_COMPONENT
+                ],
+                'itemid' => [
+                        'type' => PARAM_INT,
+                        'optional' => true,
+                        'default' => 0
+                ],
+                'contextid' => [
+                        'type' => PARAM_INT,
+                        'optional' => false
+                ],
+                'sortorder' => [
+                        'type' => PARAM_INT,
+                        'optional' => true,
+                        'default' => -1
+                ],
+        );
+    }
+}
diff --git a/customfield/classes/category_controller.php b/customfield/classes/category_controller.php
new file mode 100644 (file)
index 0000000..601b106
--- /dev/null
@@ -0,0 +1,231 @@
+<?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/>.
+
+/**
+ * Customfield catecory controller class
+ *
+ * @package   core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield;
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * Class category
+ *
+ * @package core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class category_controller {
+
+    /**
+     * Category persistent
+     *
+     * @var category
+     */
+    protected $category;
+
+    /**
+     * @var field_controller[]
+     */
+    protected $fields = [];
+
+    /** @var handler */
+    protected $handler;
+
+    /**
+     * category constructor.
+     *
+     * This class is not abstract, however the constructor was made protected to be consistent with
+     * field_controller and data_controller
+     *
+     * @param int $id
+     * @param \stdClass|null $record
+     */
+    protected function __construct(int $id = 0, \stdClass $record = null) {
+        $this->category = new category($id, $record);
+    }
+
+    /**
+     * Creates an instance of category_controller
+     *
+     * Either $id or $record or $handler need to be specified
+     * If handler is known pass it to constructor to avoid retrieving it later
+     * Component, area and itemid must not conflict with the ones in handler
+     *
+     * @param int $id
+     * @param \stdClass|null $record
+     * @param handler|null $handler
+     * @return category_controller
+     * @throws \moodle_exception
+     * @throws \coding_exception
+     */
+    public static function create(int $id, \stdClass $record = null, handler $handler = null) : category_controller {
+        global $DB;
+        if ($id && $record) {
+            // This warning really should be in persistent as well.
+            debugging('Too many parameters, either id need to be specified or a record, but not both.',
+                DEBUG_DEVELOPER);
+        }
+        if ($id) {
+            if (!$record = $DB->get_record(category::TABLE, array('id' => $id), '*', IGNORE_MISSING)) {
+                throw new \moodle_exception('categorynotfound', 'core_customfield');
+            }
+        }
+        if (empty($record->component)) {
+            if (!$handler) {
+                throw new \coding_exception('Not enough parameters to initialise category_controller - unknown component');
+            }
+            $record->component = $handler->get_component();
+        }
+        if (empty($record->area)) {
+            if (!$handler) {
+                throw new \coding_exception('Not enough parameters to initialise category_controller - unknown area');
+            }
+            $record->area = $handler->get_area();
+        }
+        if (!isset($record->itemid)) {
+            if (!$handler) {
+                throw new \coding_exception('Not enough parameters to initialise category_controller - unknown itemid');
+            }
+            $record->itemid = $handler->get_itemid();
+        }
+        $category = new self(0, $record);
+        if (!$category->get('contextid')) {
+            // If contextid was not present in the record we can find it out from the handler.
+            $handlernew = $handler ?? $category->get_handler();
+            $category->set('contextid', $handlernew->get_configuration_context()->id);
+        }
+        if ($handler) {
+            $category->set_handler($handler);
+        }
+        return $category;
+    }
+
+    /**
+     * Persistent getter parser.
+     *
+     * @param string $property
+     * @return mixed
+     */
+    final public function get($property) {
+        return $this->category->get($property);
+    }
+
+    /**
+     * Persistent setter parser.
+     *
+     * @param string $property
+     * @param mixed $value
+     */
+    final public function set($property, $value) {
+        return $this->category->set($property, $value);
+    }
+
+    /**
+     * Persistent delete parser.
+     *
+     * @return bool
+     */
+    final public function delete() {
+        return $this->category->delete();
+    }
+
+    /**
+     * Persistent save parser.
+     *
+     * @return void
+     */
+    final public function save() {
+        $this->category->save();
+    }
+
+    /**
+     * Return an array of field objects associated with this category.
+     *
+     * @return field_controller[]
+     */
+    public function get_fields() {
+        return $this->fields;
+    }
+
+    /**
+     * Adds a child field
+     *
+     * @param field_controller $field
+     */
+    public function add_field(field_controller $field) {
+        $this->fields[$field->get('id')] = $field;
+    }
+
+    /**
+     * Gets a handler, if not known retrieve it
+     *
+     * @return handler
+     */
+    public function get_handler() : handler {
+        if ($this->handler === null) {
+            $this->handler = handler::get_handler($this->get('component'), $this->get('area'), $this->get('itemid'));
+        }
+        return $this->handler;
+    }
+
+    /**
+     * Allows to set handler so we don't need to retrieve it later
+     *
+     * @param handler $handler
+     * @throws \coding_exception
+     */
+    public function set_handler(handler $handler) {
+        // Make sure there are no conflicts.
+        if ($this->get('component') !== $handler->get_component()) {
+            throw new \coding_exception('Component of the handler does not match the one from the record');
+        }
+        if ($this->get('area') !== $handler->get_area()) {
+            throw new \coding_exception('Area of the handler does not match the one from the record');
+        }
+        if ($this->get('itemid') != $handler->get_itemid()) {
+            throw new \coding_exception('Itemid of the handler does not match the one from the record');
+        }
+        if ($this->get('contextid') != $handler->get_configuration_context()->id) {
+            throw new \coding_exception('Context of the handler does not match the one from the record');
+        }
+        $this->handler = $handler;
+    }
+
+    /**
+     * Persistent to_record parser.
+     *
+     * @return \stdClass
+     */
+    final public function to_record() {
+        return $this->category->to_record();
+    }
+
+    /**
+     * Returns the category name formatted according to configuration context.
+     *
+     * @return string
+     */
+    public function get_formatted_name() : string {
+        $context = $this->get_handler()->get_configuration_context();
+        return format_string($this->get('name'), true, ['context' => $context]);
+    }
+}
diff --git a/customfield/classes/data.php b/customfield/classes/data.php
new file mode 100644 (file)
index 0000000..fc23a82
--- /dev/null
@@ -0,0 +1,107 @@
+<?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/>.
+
+/**
+ * Data persistent class
+ *
+ * @package   core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield;
+
+use core\persistent;
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * Class data
+ *
+ * @package core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class data extends persistent {
+
+    /**
+     * Database data.
+     */
+    const TABLE = 'customfield_data';
+
+    /**
+     * Return the definition of the properties of this model.
+     *
+     * @return array
+     */
+    protected static function define_properties() : array {
+        return array(
+                'fieldid'        => [
+                        'type' => PARAM_INT,
+                        'optional' => false,
+                        'null'     => NULL_NOT_ALLOWED
+                ],
+                'instanceid'       => [
+                        'type' => PARAM_INT,
+                        'optional' => false,
+                        'null'     => NULL_NOT_ALLOWED
+                ],
+                'intvalue'       => [
+                        'type'     => PARAM_INT,
+                        'optional' => true,
+                        'default'  => null,
+                        'null'     => NULL_ALLOWED
+                ],
+                'decvalue'       => [
+                        'type'     => PARAM_FLOAT,
+                        'optional' => true,
+                        'default'  => null,
+                        'null'     => NULL_ALLOWED
+                ],
+                'charvalue'      => [
+                        'type'     => PARAM_TEXT,
+                        'optional' => true,
+                        'default'  => null,
+                        'null'     => NULL_ALLOWED
+                ],
+                'shortcharvalue' => [
+                        'type'     => PARAM_TEXT,
+                        'optional' => true,
+                        'default'  => null,
+                        'null'     => NULL_ALLOWED
+                ],
+                // Mandatory field.
+                'value'          => [
+                        'type'    => PARAM_RAW,
+                        'null'    => NULL_NOT_ALLOWED,
+                        'default' => ''
+                ],
+                // Mandatory field.
+                'valueformat'    => [
+                        'type'    => PARAM_INT,
+                        'null'    => NULL_NOT_ALLOWED,
+                        'default' => FORMAT_MOODLE,
+                        'optional' => true
+                ],
+                'contextid'      => [
+                        'type'     => PARAM_INT,
+                        'optional' => false,
+                        'null'     => NULL_NOT_ALLOWED
+                ]
+        );
+    }
+
+}
diff --git a/customfield/classes/data_controller.php b/customfield/classes/data_controller.php
new file mode 100644 (file)
index 0000000..8ec6ac6
--- /dev/null
@@ -0,0 +1,365 @@
+<?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/>.
+
+/**
+ * Customfield component data controller abstract class
+ *
+ * @package   core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield;
+
+use core_customfield\output\field_data;
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * Base class for custom fields data controllers
+ *
+ * This class is a wrapper around the persistent data class that allows to define
+ * how the element behaves in the instance edit forms.
+ *
+ * Custom field plugins must define a class
+ * \{pluginname}\data_controller extends \core_customfield\data_controller
+ *
+ * @package core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class data_controller {
+    /**
+     * Data persistent
+     *
+     * @var data
+     */
+    protected $data;
+
+    /**
+     * Field that this data belongs to.
+     *
+     * @var field_controller
+     */
+    protected $field;
+
+    /**
+     * data_controller constructor.
+     *
+     * @param int $id
+     * @param \stdClass|null $record
+     */
+    public function __construct(int $id, \stdClass $record) {
+        $this->data = new data($id, $record);
+    }
+
+    /**
+     * Creates an instance of data_controller
+     *
+     * Parameters $id, $record and $field can complement each other but not conflict.
+     * If $id is not specified, fieldid must be present either in $record or in $field.
+     * If $id is not specified, instanceid must be present in $record
+     *
+     * No DB queries are performed if both $record and $field are specified.
+
+     * @param int $id
+     * @param \stdClass|null $record
+     * @param field_controller|null $field
+     * @return data_controller
+     * @throws \coding_exception
+     * @throws \moodle_exception
+     */
+    public static function create(int $id, \stdClass $record = null, field_controller $field = null) : data_controller {
+        global $DB;
+        if ($id && $record) {
+            // This warning really should be in persistent as well.
+            debugging('Too many parameters, either id need to be specified or a record, but not both.',
+                DEBUG_DEVELOPER);
+        }
+        if ($id) {
+            $record = $DB->get_record(data::TABLE, array('id' => $id), '*', MUST_EXIST);
+        } else if (!$record) {
+            $record = new \stdClass();
+        }
+
+        if (!$field && empty($record->fieldid)) {
+            throw new \coding_exception('Not enough parameters to initialise data_controller - unknown field');
+        }
+        if (!$field) {
+            $field = field_controller::create($record->fieldid);
+        }
+        if (empty($record->fieldid)) {
+            $record->fieldid = $field->get('id');
+        }
+        if ($field->get('id') != $record->fieldid) {
+            throw new \coding_exception('Field id from the record does not match field from the parameter');
+        }
+        $type = $field->get('type');
+        $customfieldtype = "\\customfield_{$type}\\data_controller";
+        if (!class_exists($customfieldtype) || !is_subclass_of($customfieldtype, self::class)) {
+            throw new \moodle_exception('errorfieldtypenotfound', 'core_customfield', '', s($type));
+        }
+        $datacontroller = new $customfieldtype(0, $record);
+        $datacontroller->field = $field;
+        return $datacontroller;
+    }
+
+    /**
+     * Returns the name of the field to be used on HTML forms.
+     *
+     * @return string
+     */
+    protected function get_form_element_name() : string {
+        return 'customfield_' . $this->get_field()->get('shortname');
+    }
+
+    /**
+     * Persistent getter parser.
+     *
+     * @param string $property
+     * @return mixed
+     */
+    final public function get($property) {
+        return $this->data->get($property);
+    }
+
+    /**
+     * Persistent setter parser.
+     *
+     * @param string $property
+     * @param mixed $value
+     * @return data
+     */
+    final public function set($property, $value) {
+        return $this->data->set($property, $value);
+    }
+
+    /**
+     * Return the name of the field in the db table {customfield_data} where the data is stored
+     *
+     * Must be one of the following:
+     *   intvalue - can store integer values, this field is indexed
+     *   decvalue - can store decimal values
+     *   shortcharvalue - can store character values up to 255 characters long, this field is indexed
+     *   charvalue - can store character values up to 1333 characters long, this field is not indexed but
+     *     full text search is faster than on field 'value'
+     *   value - can store character values of unlimited length ("text" field in the db)
+     *
+     * @return string
+     */
+    abstract public function datafield() : string;
+
+    /**
+     * Delete data. Element can override it if related information needs to be deleted as well (such as files)
+     *
+     * @return bool
+     */
+    public function delete() {
+        return $this->data->delete();
+    }
+
+    /**
+     * Persistent save parser.
+     *
+     * @return void
+     */
+    public function save() {
+        $this->data->save();
+    }
+
+    /**
+     * Field associated with this data
+     *
+     * @return field_controller
+     */
+    public function get_field() : field_controller {
+        return $this->field;
+    }
+
+    /**
+     * Saves the data coming from form
+     *
+     * @param \stdClass $datanew data coming from the form
+     */
+    public function instance_form_save(\stdClass $datanew) {
+        $elementname = $this->get_form_element_name();
+        if (!property_exists($datanew, $elementname)) {
+            return;
+        }
+        $value = $datanew->$elementname;
+        $this->data->set($this->datafield(), $value);
+        $this->data->set('value', $value);
+        $this->save();
+    }
+
+    /**
+     * Prepares the custom field data related to the object to pass to mform->set_data() and adds them to it
+     *
+     * This function must be called before calling $form->set_data($object);
+     *
+     * @param \stdClass $instance the instance that has custom fields, if 'id' attribute is present the custom
+     *    fields for this instance will be added, otherwise the default values will be added.
+     */
+    public function instance_form_before_set_data(\stdClass $instance) {
+        $instance->{$this->get_form_element_name()} = $this->get_value();
+    }
+
+    /**
+     * Checks if the value is empty
+     *
+     * @param mixed $value
+     * @return bool
+     */
+    protected function is_empty($value) : bool {
+        if ($this->datafield() === 'value' || $this->datafield() === 'charvalue' || $this->datafield() === 'shortcharvalue') {
+            return '' . $value === '';
+        }
+        return empty($value);
+    }
+
+    /**
+     * Checks if the value is unique
+     *
+     * @param mixed $value
+     * @return bool
+     */
+    protected function is_unique($value) : bool {
+        global $DB;
+        $datafield = $this->datafield();
+        $where = "fieldid = ? AND {$datafield} = ?";
+        $params = [$this->get_field()->get('id'), $value];
+        if ($this->get('id')) {
+            $where .= ' AND id <> ?';
+            $params[] = $this->get('id');
+        }
+        return !$DB->record_exists_select('customfield_data', $where, $params);
+    }
+
+    /**
+     * Called from instance edit form in validation()
+     *
+     * @param array $data
+     * @param array $files
+     * @return array array of errors
+     */
+    public function instance_form_validation(array $data, array $files) : array {
+        $errors = [];
+        $elementname = $this->get_form_element_name();
+        if ($this->get_field()->get_configdata_property('uniquevalues') == 1) {
+            $value = $data[$elementname];
+            if (!$this->is_empty($value) && !$this->is_unique($value)) {
+                $errors[$elementname] = get_string('erroruniquevalues', 'core_customfield');
+            }
+        }
+        return $errors;
+    }
+
+    /**
+     * Called from instance edit form in definition_after_data()
+     *
+     * @param \MoodleQuickForm $mform
+     */
+    public function instance_form_definition_after_data(\MoodleQuickForm $mform) {
+
+    }
+
+    /**
+     * Used by handlers to display data on various places.
+     *
+     * @return string
+     */
+    public function display() : string {
+        global $PAGE;
+        $output = $PAGE->get_renderer('core_customfield');
+        return $output->render(new field_data($this));
+    }
+
+    /**
+     * Returns the default value as it would be stored in the database (not in human-readable format).
+     *
+     * @return mixed
+     */
+    public abstract function get_default_value();
+
+    /**
+     * Returns the value as it is stored in the database or default value if data record is not present
+     *
+     * @return mixed
+     */
+    public function get_value() {
+        if (!$this->get('id')) {
+            return $this->get_default_value();
+        }
+        return $this->get($this->datafield());
+    }
+
+    /**
+     * Return the context of the field
+     *
+     * @return \context
+     */
+    public function get_context() : \context {
+        if ($this->get('contextid')) {
+            return \context::instance_by_id($this->get('contextid'));
+        } else if ($this->get('instanceid')) {
+            return $this->get_field()->get_handler()->get_instance_context($this->get('instanceid'));
+        } else {
+            // Context is not yet known (for example, entity is not yet created).
+            return \context_system::instance();
+        }
+    }
+
+    /**
+     * Add a field to the instance edit form.
+     *
+     * @param \MoodleQuickForm $mform
+     */
+    public abstract function instance_form_definition(\MoodleQuickForm $mform);
+
+    /**
+     * Returns value in a human-readable format or default value if data record is not present
+     *
+     * This is the default implementation that most likely needs to be overridden
+     *
+     * @return mixed|null value or null if empty
+     */
+    public function export_value() {
+        $value = $this->get_value();
+
+        if ($this->is_empty($value)) {
+            return null;
+        }
+
+        if ($this->datafield() === 'intvalue') {
+            return (int)$value;
+        } else if ($this->datafield() === 'decvalue') {
+            return (float)$value;
+        } else if ($this->datafield() === 'value') {
+            return format_text($value, $this->get('valueformat'), ['context' => $this->get_context()]);
+        } else {
+            return format_string($value, true, ['context' => $this->get_context()]);
+        }
+    }
+
+    /**
+     * Persistent to_record parser.
+     *
+     * @return \stdClass
+     */
+    final public function to_record() {
+        return $this->data->to_record();
+    }
+}
diff --git a/customfield/classes/event/category_created.php b/customfield/classes/event/category_created.php
new file mode 100644 (file)
index 0000000..5a83d29
--- /dev/null
@@ -0,0 +1,84 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Custom field category created event.
+ *
+ * @package    core_customfield
+ * @copyright  2018 Daniel Neis Araujo <daniel@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield\event;
+
+use core_customfield\category_controller;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Custom field category created event class.
+ *
+ * @package    core_customfield
+ * @since      Moodle 3.6
+ * @copyright  2018 Daniel Neis Araujo <daniel@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class category_created extends \core\event\base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'customfield_category';
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an instance from a category controller object
+     *
+     * @param category_controller $category
+     * @return category_created
+     */
+    public static function create_from_object(category_controller $category) : category_created {
+        $eventparams = [
+            'objectid' => $category->get('id'),
+            'context'  => $category->get_handler()->get_configuration_context(),
+            'other'    => ['name' => $category->get('name')]
+        ];
+        $event = self::create($eventparams);
+        $event->add_record_snapshot($event->objecttable, $category->to_record());
+        return $event;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventcategorycreated', 'core_customfield');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' created the category with id '$this->objectid'.";
+    }
+}
diff --git a/customfield/classes/event/category_deleted.php b/customfield/classes/event/category_deleted.php
new file mode 100644 (file)
index 0000000..1e08f25
--- /dev/null
@@ -0,0 +1,84 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Custom field category created event.
+ *
+ * @package    core_customfield
+ * @copyright  2018 Toni Barbera <toni@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield\event;
+
+use core_customfield\category_controller;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Custom field category created event class.
+ *
+ * @package    core_customfield
+ * @since      Moodle 3.6
+ * @copyright  2018 Toni Barbera <toni@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class category_deleted extends \core\event\base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'customfield_category';
+        $this->data['crud'] = 'd';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an instance from a category controller object
+     *
+     * @param category_controller $category
+     * @return category_deleted
+     */
+    public static function create_from_object(category_controller $category) : category_deleted {
+        $eventparams = [
+            'objectid' => $category->get('id'),
+            'context'  => $category->get_handler()->get_configuration_context(),
+            'other'    => ['name' => $category->get('name')]
+        ];
+        $event = self::create($eventparams);
+        $event->add_record_snapshot($event->objecttable, $category->to_record());
+        return $event;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventcategorydeleted', 'core_customfield');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' deleted the category with id '$this->objectid'.";
+    }
+}
diff --git a/customfield/classes/event/category_updated.php b/customfield/classes/event/category_updated.php
new file mode 100644 (file)
index 0000000..ba7bc2c
--- /dev/null
@@ -0,0 +1,84 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Custom field category updated event.
+ *
+ * @package    core_customfield
+ * @copyright  2018 Daniel Neis Araujo <daniel@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield\event;
+
+use core_customfield\category_controller;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Custom field category updated event class.
+ *
+ * @package    core_customfield
+ * @since      Moodle 3.6
+ * @copyright  2018 Daniel Neis Araujo <daniel@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class category_updated extends \core\event\base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'customfield_category';
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an instance from a category controller object
+     *
+     * @param category_controller $category
+     * @return category_updated
+     */
+    public static function create_from_object(category_controller $category) : category_updated {
+        $eventparams = [
+            'objectid' => $category->get('id'),
+            'context'  => $category->get_handler()->get_configuration_context(),
+            'other'    => ['name' => $category->get('name')]
+        ];
+        $event = self::create($eventparams);
+        $event->add_record_snapshot($event->objecttable, $category->to_record());
+        return $event;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventcategoryupdated', 'core_customfield');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' updated the category with id '$this->objectid'.";
+    }
+}
diff --git a/customfield/classes/event/field_created.php b/customfield/classes/event/field_created.php
new file mode 100644 (file)
index 0000000..20d4f55
--- /dev/null
@@ -0,0 +1,87 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Custom field created event.
+ *
+ * @package    core_customfield
+ * @copyright  2018 Daniel Neis Araujo <daniel@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield\event;
+
+use core_customfield\field_controller;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Custom field created event class.
+ *
+ * @package    core_customfield
+ * @since      Moodle 3.6
+ * @copyright  2018 Daniel Neis Araujo <daniel@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class field_created extends \core\event\base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'customfield_field';
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an instance from a field controller object
+     *
+     * @param field_controller $field
+     * @return field_created
+     */
+    public static function create_from_object(field_controller $field) : field_created {
+        $eventparams = [
+            'objectid' => $field->get('id'),
+            'context'  => $field->get_handler()->get_configuration_context(),
+            'other'    => [
+                'shortname' => $field->get('shortname'),
+                'name'      => $field->get('name')
+            ]
+        ];
+        $event = self::create($eventparams);
+        $event->add_record_snapshot($event->objecttable, $field->to_record());
+        return $event;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventfieldcreated', 'core_customfield');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' created the field with id '$this->objectid'.";
+    }
+}
diff --git a/customfield/classes/event/field_deleted.php b/customfield/classes/event/field_deleted.php
new file mode 100644 (file)
index 0000000..586705d
--- /dev/null
@@ -0,0 +1,87 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Custom field updated event.
+ *
+ * @package    core_customfield
+ * @copyright  2018 Toni Barbera <toni@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield\event;
+
+use core_customfield\field_controller;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Custom field updated event class.
+ *
+ * @package    core_customfield
+ * @since      Moodle 3.6
+ * @copyright  2018 Toni Barbera <toni@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class field_deleted extends \core\event\base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'customfield_field';
+        $this->data['crud'] = 'd';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an instance from a field controller object
+     *
+     * @param field_controller $field
+     * @return field_deleted
+     */
+    public static function create_from_object(field_controller $field) : field_deleted {
+        $eventparams = [
+            'objectid' => $field->get('id'),
+            'context'  => $field->get_handler()->get_configuration_context(),
+            'other'    => [
+                'shortname' => $field->get('shortname'),
+                'name'      => $field->get('name')
+            ]
+        ];
+        $event = self::create($eventparams);
+        $event->add_record_snapshot($event->objecttable, $field->to_record());
+        return $event;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventfielddeleted', 'core_customfield');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' deleted the field with id '$this->objectid'.";
+    }
+}
diff --git a/customfield/classes/event/field_updated.php b/customfield/classes/event/field_updated.php
new file mode 100644 (file)
index 0000000..619a4a4
--- /dev/null
@@ -0,0 +1,87 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Custom field updated event.
+ *
+ * @package    core_customfield
+ * @copyright  2018 Daniel Neis Araujo <daniel@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield\event;
+
+use core_customfield\field_controller;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Custom field updated event class.
+ *
+ * @package    core_customfield
+ * @since      Moodle 3.6
+ * @copyright  2018 Daniel Neis Araujo <daniel@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class field_updated extends \core\event\base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'customfield_field';
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an instance from a field controller object
+     *
+     * @param field_controller $field
+     * @return field_updated
+     */
+    public static function create_from_object(field_controller $field) : field_updated {
+        $eventparams = [
+            'objectid' => $field->get('id'),
+            'context'  => $field->get_handler()->get_configuration_context(),
+            'other'    => [
+                'shortname' => $field->get('shortname'),
+                'name'      => $field->get('name')
+            ]
+        ];
+        $event = self::create($eventparams);
+        $event->add_record_snapshot($event->objecttable, $field->to_record());
+        return $event;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventfieldupdated', 'core_customfield');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' updated the field with id '$this->objectid'.";
+    }
+}
diff --git a/customfield/classes/field.php b/customfield/classes/field.php
new file mode 100644 (file)
index 0000000..1f7b7e1
--- /dev/null
@@ -0,0 +1,97 @@
+<?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/>.
+
+/**
+ * Field persistent class
+ *
+ * @package   core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield;
+
+use core\persistent;
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * Class field
+ *
+ * @package core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class field extends persistent {
+
+    /**
+     * Database table.
+     */
+    const TABLE = 'customfield_field';
+
+    /**
+     * Return the definition of the properties of this model.
+     *
+     * @return array
+     */
+    protected static function define_properties() : array {
+        return array(
+                'name' => [
+                        'type' => PARAM_TEXT,
+                ],
+                'shortname' => [
+                        'type' => PARAM_TEXT,
+                ],
+                'type' => [
+                        'type' => PARAM_PLUGIN,
+                ],
+                'description' => [
+                        'type' => PARAM_RAW,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                ],
+                'descriptionformat' => [
+                        'type' => PARAM_INT,
+                        'default' => FORMAT_MOODLE,
+                        'optional' => true
+                ],
+                'sortorder' => [
+                        'type' => PARAM_INT,
+                        'optional' => true,
+                        'default' => -1,
+                ],
+                'categoryid' => [
+                        'type' => PARAM_INT
+                ],
+                'configdata' => [
+                        'type' => PARAM_RAW,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                ],
+        );
+    }
+
+    /**
+     * Get decoded configdata.
+     *
+     * @return array
+     */
+    protected function get_configdata() : array {
+        return json_decode($this->raw_get('configdata'), true) ?? array();
+    }
+}
diff --git a/customfield/classes/field_config_form.php b/customfield/classes/field_config_form.php
new file mode 100644 (file)
index 0000000..06a6c40
--- /dev/null
@@ -0,0 +1,134 @@
+<?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/>.
+
+/**
+ * Customfield package
+ *
+ * @package   core_customfield
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield;
+
+defined('MOODLE_INTERNAL') || die;
+
+global $CFG;
+require_once($CFG->libdir . '/formslib.php');
+
+/**
+ * Class field_config_form
+ *
+ * @package core_customfield
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class field_config_form extends \moodleform {
+
+    /**
+     * Class definition
+     *
+     * @throws \coding_exception
+     */
+    public function definition() {
+        global $PAGE;
+        $mform = $this->_form;
+
+        $field = $this->_customdata['field'];
+        if (!($field && $field instanceof field_controller)) {
+            throw new \coding_exception('Field must be passed in customdata');
+        }
+        $handler = $field->get_handler();
+
+        $mform->addElement('header', '_commonsettings', get_string('commonsettings', 'core_customfield'));
+
+        $mform->addElement('text', 'name', get_string('fieldname', 'core_customfield'), 'size="50"');
+        $mform->addRule('name', null, 'required', null, 'client');
+        $mform->setType('name', PARAM_TEXT);
+
+        // Accepted values for 'shortname' would follow [a-z0-9_] pattern,
+        // but we are accepting any PARAM_TEXT value here,
+        // and checking [a-zA-Z0-9_] pattern in validation() function to throw an error when needed.
+        $mform->addElement('text', 'shortname', get_string('fieldshortname', 'core_customfield'), 'size=20');
+        $mform->addHelpButton('shortname', 'shortname', 'core_customfield');
+        $mform->addRule('shortname', null, 'required', null, 'client');
+        $mform->setType('shortname', PARAM_TEXT);
+
+        $desceditoroptions = $handler->get_description_text_options();
+        $mform->addElement('editor', 'description_editor', get_string('description', 'core_customfield'), null, $desceditoroptions);
+        $mform->addHelpButton('description_editor', 'description', 'core_customfield');
+
+        // If field is required.
+        $mform->addElement('selectyesno', 'configdata[required]', get_string('isfieldrequired', 'core_customfield'));
+        $mform->addHelpButton('configdata[required]', 'isfieldrequired', 'core_customfield');
+        $mform->setType('configdata[required]', PARAM_BOOL);
+
+        // If field data is unique.
+        $mform->addElement('selectyesno', 'configdata[uniquevalues]', get_string('isdataunique', 'core_customfield'));
+        $mform->addHelpButton('configdata[uniquevalues]', 'isdataunique', 'core_customfield');
+        $mform->setType('configdata[uniquevalues]', PARAM_BOOL);
+
+        // Field specific settings from field type.
+        $field->config_form_definition($mform);
+
+        // Handler/component settings.
+        $handler->config_form_definition($mform);
+
+        // We add hidden fields.
+        $mform->addElement('hidden', 'categoryid');
+        $mform->setType('categoryid', PARAM_INT);
+
+        $mform->addElement('hidden', 'type');
+        $mform->setType('type', PARAM_COMPONENT);
+
+        $mform->addElement('hidden', 'id');
+        $mform->setType('id', PARAM_INT);
+
+        $this->add_action_buttons(true);
+    }
+
+    /**
+     * Field data validation
+     *
+     * @param array $data
+     * @param array $files
+     * @return array
+     */
+    public function validation($data, $files = array()) {
+        global $DB;
+
+        $errors = array();
+        /** @var field_controller $field */
+        $field = $this->_customdata['field'];
+        $handler = $field->get_handler();
+
+        // Check the shortname is specified and is unique for this component-area-itemid combination.
+        if (!preg_match('/^[a-z0-9_]+$/', $data['shortname'])) {
+            // Check allowed pattern (numbers, letters and underscore).
+            $errors['shortname'] = get_string('invalidshortnameerror', 'core_customfield');
+        } else if ($DB->record_exists_sql('SELECT 1 FROM {customfield_field} f ' .
+            'JOIN {customfield_category} c ON c.id = f.categoryid ' .
+            'WHERE f.shortname = ? AND f.id <> ? AND c.component = ? AND c.area = ? AND c.itemid = ?',
+            [$data['shortname'], $data['id'],
+                $handler->get_component(), $handler->get_area(), $handler->get_itemid()])) {
+            $errors['shortname'] = get_string('formfieldcheckshortname', 'core_customfield');
+        }
+
+        $errors = array_merge($errors, $field->config_form_validation($data, $files));
+
+        return $errors;
+    }
+}
diff --git a/customfield/classes/field_controller.php b/customfield/classes/field_controller.php
new file mode 100644 (file)
index 0000000..b9da5b9
--- /dev/null
@@ -0,0 +1,252 @@
+<?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/>.
+
+/**
+ * Field controller abstract class
+ *
+ * @package   core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield;
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * Base class for custom fields controllers
+ *
+ * This class is a wrapper around the persistent field class that allows to define the field
+ * configuration
+ *
+ * Custom field plugins must define a class
+ * \{pluginname}\field_controller extends \core_customfield\field_controller
+ *
+ * @package core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class field_controller {
+
+    /**
+     * Field persistent class
+     *
+     * @var field
+     */
+    protected $field;
+
+    /**
+     * Category of the field.
+     *
+     * @var category_controller
+     */
+    protected $category;
+
+    /**
+     * Constructor.
+     *
+     * @param int $id
+     * @param \stdClass|null $record
+     */
+    public function __construct(int $id = 0, \stdClass $record = null) {
+        $this->field = new field($id, $record);
+    }
+
+    /**
+     * Creates an instance of field_controller
+     *
+     * Parameters $id, $record and $category can complement each other but not conflict.
+     * If $id is not specified, categoryid must be present either in $record or in $category.
+     * If $id is not specified, type must be present in $record
+     *
+     * No DB queries are performed if both $record and $category are specified.
+     *
+     * @param int $id
+     * @param \stdClass|null $record
+     * @param category_controller|null $category
+     * @return field_controller will return the instance of the class from the customfield element plugin
+     * @throws \coding_exception
+     * @throws \moodle_exception
+     */
+    public static function create(int $id, \stdClass $record = null, category_controller $category = null) : field_controller {
+        global $DB;
+        if ($id && $record) {
+            // This warning really should be in persistent as well.
+            debugging('Too many parameters, either id need to be specified or a record, but not both.',
+                DEBUG_DEVELOPER);
+        }
+        if ($id) {
+            if (!$record = $DB->get_record(field::TABLE, array('id' => $id), '*', IGNORE_MISSING)) {
+                throw new \moodle_exception('fieldnotfound', 'core_customfield');
+            }
+        }
+
+        if (empty($record->categoryid)) {
+            if (!$category) {
+                throw new \coding_exception('Not enough parameters to initialise field_controller - unknown category');
+            } else {
+                $record->categoryid = $category->get('id');
+            }
+        }
+        if (empty($record->type)) {
+            throw new \coding_exception('Not enough parameters to initialise field_controller - unknown field type');
+        }
+
+        $type = $record->type;
+        if (!$category) {
+            $category = category_controller::create($record->categoryid);
+        }
+        if ($category->get('id') != $record->categoryid) {
+            throw new \coding_exception('Category of the field does not match category from the parameter');
+        }
+
+        $customfieldtype = "\\customfield_{$type}\\field_controller";
+        if (!class_exists($customfieldtype) || !is_subclass_of($customfieldtype, self::class)) {
+            throw new \moodle_exception('errorfieldtypenotfound', 'core_customfield', '', s($type));
+        }
+        $fieldcontroller = new $customfieldtype(0, $record);
+        $fieldcontroller->category = $category;
+        $category->add_field($fieldcontroller);
+        return $fieldcontroller;
+    }
+
+    /**
+     * Validate the data on the field configuration form
+     *
+     * Plugins can override it
+     *
+     * @param array $data from the add/edit profile field form
+     * @param array $files
+     * @return array associative array of error messages
+     */
+    public function config_form_validation(array $data, $files = array()) : array {
+        return array();
+    }
+
+
+    /**
+     * Persistent getter parser.
+     *
+     * @param string $property
+     * @return mixed
+     */
+    final public function get(string $property) {
+        return $this->field->get($property);
+    }
+
+    /**
+     * Persistent setter parser.
+     *
+     * @param string $property
+     * @param mixed $value
+     * @return field
+     */
+    final public function set($property, $value) {
+        return $this->field->set($property, $value);
+    }
+
+    /**
+     * Delete a field and all associated data
+     *
+     * Plugins may override it if it is necessary to delete related data (such as files)
+     *
+     * Not that the delete() method from data_controller is not called here.
+     *
+     * @return bool
+     */
+    public function delete() : bool {
+        global $DB;
+        $DB->delete_records('customfield_data', ['fieldid' => $this->get('id')]);
+        return $this->field->delete();
+    }
+
+    /**
+     * Save or update the persistent class to database.
+     *
+     * @return void
+     */
+    public function save() {
+        $this->field->save();
+    }
+
+    /**
+     * Persistent to_record parser.
+     *
+     * @return \stdClass
+     */
+    final public function to_record() {
+        return $this->field->to_record();
+    }
+
+    /**
+     * Get the category associated with this field
+     *
+     * @return category_controller
+     */
+    public final function get_category() : category_controller {
+        return $this->category;
+    }
+
+    /**
+     * Get configdata property.
+     *
+     * @param string $property name of the property
+     * @return mixed
+     */
+    public function get_configdata_property(string $property) {
+        $configdata = $this->field->get('configdata');
+        if (!isset($configdata[$property])) {
+            return null;
+        }
+        return $configdata[$property];
+    }
+
+    /**
+     * Returns a handler for this field
+     *
+     * @return handler
+     */
+    public final function get_handler() : handler {
+        return $this->get_category()->get_handler();
+    }
+
+    /**
+     * Prepare the field data to set in the configuration form
+     *
+     * Plugin can override if some preprocessing required for editor or filemanager fields
+     *
+     * @param \stdClass $formdata
+     */
+    public function prepare_for_config_form(\stdClass $formdata) {
+    }
+
+    /**
+     * Add specific settings to the field configuration form, for example "default value"
+     *
+     * @param \MoodleQuickForm $mform
+     */
+    public abstract function config_form_definition(\MoodleQuickForm $mform);
+
+    /**
+     * Returns the field name formatted according to configuration context.
+     *
+     * @return string
+     */
+    public function get_formatted_name() : string {
+        $context = $this->get_handler()->get_configuration_context();
+        return format_string($this->get('name'), true, ['context' => $context]);
+    }
+}
diff --git a/customfield/classes/handler.php b/customfield/classes/handler.php
new file mode 100644 (file)
index 0000000..af25c2c
--- /dev/null
@@ -0,0 +1,840 @@
+<?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/>.
+
+/**
+ * The abstract custom fields handler
+ *
+ * @package   core_customfield
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield;
+
+use core_customfield\output\field_data;
+use stdClass;
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * Base class for custom fields handlers
+ *
+ * This handler provides callbacks for field configuration form and also allows to add the fields to the instance editing form
+ *
+ * Every plugin that wants to use custom fields must define a handler class:
+ * <COMPONENT_OR_PLUGIN>\customfield\<AREA>_handler extends \core_customfield\handler
+ *
+ * To initiate a class use an appropriate static method:
+ * - <handlerclass>::create - to create an instance of a known handler
+ * - \core_customfield\handler::get_handler - to create an instance of a handler for given component/area/itemid
+ *
+ * Also handler is automatically created when the following methods are called:
+ * - \core_customfield\api::get_field($fieldid)
+ * - \core_customfield\api::get_category($categoryid)
+ *
+ * @package core_customfield
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class handler {
+
+    /**
+     * The component this handler handles
+     *
+     * @var string $component
+     */
+    private $component;
+
+    /**
+     * The area within the component
+     *
+     * @var string $area
+     */
+    private $area;
+
+    /**
+     * The id of the item within the area and component
+
+     * @var int $itemid
+     */
+    private $itemid;
+
+    /**
+     * @var category_controller[]
+     */
+    protected $categories = null;
+
+    /**
+     * Handler constructor.
+     *
+     * @param int $itemid
+     */
+    protected final function __construct(int $itemid = 0) {
+        if (!preg_match('|^(\w+_[\w_]+)\\\\customfield\\\\([\w_]+)_handler$|', static::class, $matches)) {
+            throw new \coding_exception('Handler class name must have format: <PLUGIN>\\customfield\\<AREA>_handler');
+        }
+        $this->component = $matches[1];
+        $this->area = $matches[2];
+        $this->itemid = $itemid;
+    }
+
+    /**
+     * Returns an instance of the handler
+     *
+     * Some areas may choose to use singleton/caching here
+     *
+     * @param int $itemid
+     * @return handler
+     */
+    public static function create(int $itemid = 0) : handler {
+        return new static($itemid);
+    }
+
+    /**
+     * Returns an instance of handler by component/area/itemid
+     *
+     * @param string $component component name of full frankenstyle plugin name
+     * @param string $area name of the area (each component/plugin may define handlers for multiple areas)
+     * @param int $itemid item id if the area uses them (usually not used)
+     * @return handler
+     */
+    public static function get_handler(string $component, string $area, int $itemid = 0) : handler {
+        $classname = $component . '\\customfield\\' . $area . '_handler';
+        if (class_exists($classname) && is_subclass_of($classname, self::class)) {
+            return $classname::create($itemid);
+        }
+        $a = ['component' => s($component), 'area' => s($area)];
+        throw new \moodle_exception('unknownhandler', 'core_customfield', (object)$a);
+    }
+
+    /**
+     * Get component
+     *
+     * @return string
+     */
+    public function get_component() : string {
+        return $this->component;
+    }
+
+    /**
+     * Get area
+     *
+     * @return string
+     */
+    public function get_area() : string {
+        return $this->area;
+    }
+
+    /**
+     * Context that should be used for new categories created by this handler
+     *
+     * @return \context
+     */
+    abstract public function get_configuration_context() : \context;
+
+    /**
+     * URL for configuration of the fields on this handler.
+     *
+     * @return \moodle_url
+     */
+    abstract public function get_configuration_url() : \moodle_url;
+
+    /**
+     * Context that should be used for data stored for the given record
+     *
+     * @param int $instanceid id of the instance or 0 if the instance is being created
+     * @return \context
+     */
+    abstract public function get_instance_context(int $instanceid = 0) : \context;
+
+    /**
+     * Get itemid
+     *
+     * @return int|null
+     */
+    public function get_itemid() : int {
+        return $this->itemid;
+    }
+
+    /**
+     * Uses categories
+     *
+     * @return bool
+     */
+    public function uses_categories() : bool {
+        return true;
+    }
+
+    /**
+     * The form to create or edit a field
+     *
+     * @param field_controller $field
+     * @return field_config_form
+     */
+    public function get_field_config_form(field_controller $field) : field_config_form {
+         $form = new field_config_form(null, ['field' => $field]);
+         $form->set_data(api::prepare_field_for_config_form($field));
+         return $form;
+    }
+
+    /**
+     * Generates a name for the new category
+     *
+     * @param int $suffix
+     * @return string
+     */
+    protected function generate_category_name($suffix = 0) : string {
+        if ($suffix) {
+            return get_string('otherfieldsn', 'core_customfield', $suffix);
+        } else {
+            return get_string('otherfields', 'core_customfield');
+        }
+    }
+
+    /**
+     * Creates a new category and inserts it to the database
+     *
+     * @param string $name name of the category, null to generate automatically
+     * @return int id of the new category
+     */
+    public function create_category(string $name = null) : int {
+        global $DB;
+        $params = ['component' => $this->get_component(), 'area' => $this->get_area(), 'itemid' => $this->get_itemid()];
+
+        if (empty($name)) {
+            for ($suffix = 0; $suffix < 100; $suffix++) {
+                $name = $this->generate_category_name($suffix);
+                if (!$DB->record_exists(category::TABLE, $params + ['name' => $name])) {
+                    break;
+                }
+            }
+        }
+
+        $category = category_controller::create(0, (object)['name' => $name], $this);
+        api::save_category($category);
+        $this->clear_configuration_cache();
+        return $category->get('id');
+    }
+
+    /**
+     * Validate that the given category belongs to this handler
+     *
+     * @param category_controller $category
+     * @return category_controller
+     * @throws \moodle_exception
+     */
+    protected function validate_category(category_controller $category) : category_controller {
+        $categories = $this->get_categories_with_fields();
+        if (!array_key_exists($category->get('id'), $categories)) {
+            throw new \moodle_exception('categorynotfound', 'core_customfield');
+        }
+        return $categories[$category->get('id')];
+    }
+
+    /**
+     * Validate that the given field belongs to this handler
+     *
+     * @param field_controller $field
+     * @return field_controller
+     * @throws \moodle_exception
+     */
+    protected function validate_field(field_controller $field) : field_controller {
+        if (!array_key_exists($field->get('categoryid'), $this->get_categories_with_fields())) {
+            throw new \moodle_exception('fieldnotfound', 'core_customfield');
+        }
+        $category = $this->get_categories_with_fields()[$field->get('categoryid')];
+        if (!array_key_exists($field->get('id'), $category->get_fields())) {
+            throw new \moodle_exception('fieldnotfound', 'core_customfield');
+        }
+        return $category->get_fields()[$field->get('id')];
+    }
+
+    /**
+     * Change name for a field category
+     *
+     * @param category_controller $category
+     * @param string $name
+     */
+    public function rename_category(category_controller $category, string $name) {
+        $this->validate_category($category);
+        $category->set('name', $name);
+        api::save_category($category);
+        $this->clear_configuration_cache();
+    }
+
+    /**
+     * Change sort order of the categories
+     *
+     * @param category_controller $category category that needs to be moved
+     * @param int $beforeid id of the category this category needs to be moved before, 0 to move to the end
+     */
+    public function move_category(category_controller $category, int $beforeid = 0) {
+        $category = $this->validate_category($category);
+        api::move_category($category, $beforeid);
+        $this->clear_configuration_cache();
+    }
+
+    /**
+     * Permanently delete category, all fields in it and all associated data
+     *
+     * @param category_controller $category
+     * @return bool
+     */
+    public function delete_category(category_controller $category) : bool {
+        $category = $this->validate_category($category);
+        $result = api::delete_category($category);
+        $this->clear_configuration_cache();
+        return $result;
+    }
+
+    /**
+     * Deletes all data and all fields and categories defined in this handler
+     */
+    public function delete_all() {
+        $categories = $this->get_categories_with_fields();
+        foreach ($categories as $category) {
+            api::delete_category($category);
+        }
+        $this->clear_configuration_cache();
+    }
+
+    /**
+     * Permanently delete a custom field configuration and all associated data
+     *
+     * @param field_controller $field
+     * @return bool
+     */
+    public function delete_field_configuration(field_controller $field) : bool {
+        $field = $this->validate_field($field);
+        $result = api::delete_field_configuration($field);
+        $this->clear_configuration_cache();
+        return $result;
+    }
+
+    /**
+     * Change fields sort order, move field to another category
+     *
+     * @param field_controller $field field that needs to be moved
+     * @param int $categoryid category that needs to be moved
+     * @param int $beforeid id of the category this category needs to be moved before, 0 to move to the end
+     */
+    public function move_field(field_controller $field, int $categoryid, int $beforeid = 0) {
+        $field = $this->validate_field($field);
+        api::move_field($field, $categoryid, $beforeid);
+        $this->clear_configuration_cache();
+    }
+
+    /**
+     * The current user can configure custom fields on this component.
+     *
+     * @return bool
+     */
+    abstract public function can_configure() : bool;
+
+    /**
+     * The current user can edit given custom fields on the given instance
+     *
+     * Called to filter list of fields displayed on the instance edit form
+     *
+     * Capability to edit/create instance is checked separately
+     *
+     * @param field_controller $field
+     * @param int $instanceid id of the instance or 0 if the instance is being created
+     * @return bool
+     */
+    abstract public function can_edit(field_controller $field, int $instanceid = 0) : bool;
+
+    /**
+     * The current user can view the value of the custom field for a given custom field and instance
+     *
+     * Called to filter list of fields returned by methods get_instance_data(), get_instances_data(),
+     * export_instance_data(), export_instance_data_object()
+     *
+     * Access to the instance itself is checked by handler before calling these methods
+     *
+     * @param field_controller $field
+     * @param int $instanceid
+     * @return bool
+     */
+    abstract public function can_view(field_controller $field, int $instanceid) : bool;
+
+    /**
+     * Returns the custom field values for an individual instance
+     *
+     * The caller must check access to the instance itself before invoking this method
+     *
+     * The result is an array of data_controller objects
+     *
+     * @param int $instanceid
+     * @param bool $returnall return data for all fields (by default only visible fields)
+     * @return data_controller[] array of data_controller objects indexed by fieldid. All fields are present,
+     *     some data_controller objects may have 'id', some not
+     *     In the last case data_controller::get_value() and export_value() functions will return default values.
+     */
+    public function get_instance_data(int $instanceid, bool $returnall = false) : array {
+        $fields = $returnall ? $this->get_fields() : $this->get_visible_fields($instanceid);
+        return api::get_instance_fields_data($fields, $instanceid);
+    }
+
+    /**
+     * Returns the custom fields values for multiple instances
+     *
+     * The caller must check access to the instance itself before invoking this method
+     *
+     * The result is an array of data_controller objects
+     *
+     * @param int[] $instanceids
+     * @param bool $returnall return data for all fields (by default only visible fields)
+     * @return data_controller[][] 2-dimension array, first index is instanceid, second index is fieldid.
+     *     All instanceids and all fieldids are present, some data_controller objects may have 'id', some not.
+     *     In the last case data_controller::get_value() and export_value() functions will return default values.
+     */
+    public function get_instances_data(array $instanceids, bool $returnall = false) : array {
+        $result = api::get_instances_fields_data($this->get_fields(), $instanceids);
+
+        if (!$returnall) {
+            // Filter only by visible fields (list of visible fields may be different for each instance).
+            $handler = $this;
+            foreach ($instanceids as $instanceid) {
+                $result[$instanceid] = array_filter($result[$instanceid], function(data_controller $d) use ($handler) {
+                    return $handler->can_view($d->get_field(), $d->get('instanceid'));
+                });
+            }
+        }
+        return $result;
+    }
+
+    /**
+     * Returns the custom field values for an individual instance ready to be displayed
+     *
+     * The caller must check access to the instance itself before invoking this method
+     *
+     * The result is an array of \core_customfield\output\field_data objects
+     *
+     * @param int $instanceid
+     * @param bool $returnall
+     * @return \core_customfield\output\field_data[]
+     */
+    public function export_instance_data(int $instanceid, bool $returnall = false) : array {
+        return array_map(function($d) {
+            return new field_data($d);
+        }, $this->get_instance_data($instanceid, $returnall));
+    }
+
+    /**
+     * Returns the custom field values for an individual instance ready to be displayed
+     *
+     * The caller must check access to the instance itself before invoking this method
+     *
+     * The result is a class where properties are fields short names and the values their export values for this instance
+     *
+     * @param int $instanceid
+     * @param bool $returnall
+     * @return stdClass
+     */
+    public function export_instance_data_object(int $instanceid, bool $returnall = false) : stdClass {
+        $rv = new stdClass();
+        foreach ($this->export_instance_data($instanceid, $returnall) as $d) {
+            $rv->{$d->get_shortname()} = $d->get_value();
+        }
+        return $rv;
+    }
+
+    /**
+     * Display visible custom fields.
+     * This is a sample implementation that can be overridden in each handler.
+     *
+     * @param data_controller[] $fieldsdata
+     * @return string
+     */
+    public function display_custom_fields_data(array $fieldsdata) : string {
+        global $PAGE;
+        $output = $PAGE->get_renderer('core_customfield');
+        $content = '';
+        foreach ($fieldsdata as $data) {
+            $fd = new field_data($data);
+            $content .= $output->render($fd);
+        }
+
+        return $content;
+    }
+
+    /**
+     * Returns array of categories, each of them contains a list of fields definitions.
+     *
+     * @return category_controller[]
+     */
+    public function get_categories_with_fields() : array {
+        if ($this->categories === null) {
+            $this->categories = api::get_categories_with_fields($this->get_component(), $this->get_area(), $this->get_itemid());
+        }
+        $handler = $this;
+        array_walk($this->categories, function(category_controller $c) use ($handler) {
+            $c->set_handler($handler);
+        });
+        return $this->categories;
+    }
+
+    /**
+     * Clears a list of categories with corresponding fields definitions.
+     */
+    protected function clear_configuration_cache() {
+        $this->categories = null;
+    }
+
+    /**
+     * Checks if current user can backup a given field
+     *
+     * Capability to backup the instance does not need to be checked here
+     *
+     * @param field_controller $field
+     * @param int $instanceid
+     * @return bool
+     */
+    protected function can_backup(field_controller $field, int $instanceid) : bool {
+        return $this->can_view($field, $instanceid) || $this->can_edit($field, $instanceid);
+    }
+
+    /**
+     * Get raw data associated with all fields current user can view or edit
+     *
+     * @param int $instanceid
+     * @return array
+     */
+    public function get_instance_data_for_backup(int $instanceid) : array {
+        $finalfields = [];
+        $data = $this->get_instance_data($instanceid, true);
+        foreach ($data as $d) {
+            if ($d->get('id') && $this->can_backup($d->get_field(), $instanceid)) {
+                $finalfields[] = [
+                    'id' => $d->get('id'),
+                    'shortname' => $d->get_field()->get('shortname'),
+                    'type' => $d->get_field()->get('type'),
+                    'value' => $d->get_value(),
+                    'valueformat' => $d->get('valueformat')];
+            }
+        }
+        return $finalfields;
+    }
+
+    /**
+     * Form data definition callback.
+     *
+     * This method is called from moodleform::definition_after_data and allows to tweak
+     * mform with some data coming directly from the field plugin data controller.
+     *
+     * @param \MoodleQuickForm $mform
+     * @param int $instanceid
+     */
+    public function instance_form_definition_after_data(\MoodleQuickForm $mform, int $instanceid = 0) {
+        $editablefields = $this->get_editable_fields($instanceid);
+        $fields = api::get_instance_fields_data($editablefields, $instanceid);
+
+        foreach ($fields as $formfield) {
+            $formfield->instance_form_definition_after_data($mform);
+        }
+    }
+
+    /**
+     * Prepares the custom fields data related to the instance to pass to mform->set_data()
+     *
+     * Example:
+     *   $instance = $DB->get_record(...);
+     *   // .... prepare editor, filemanager, add tags, etc.
+     *   $handler->instance_form_before_set_data($instance);
+     *   $form->set_data($instance);
+     *
+     * @param stdClass $instance the instance that has custom fields, if 'id' attribute is present the custom
+     *    fields for this instance will be added, otherwise the default values will be added.
+     */
+    public function instance_form_before_set_data(stdClass $instance) {
+        $instanceid = !empty($instance->id) ? $instance->id : 0;
+        $fields = api::get_instance_fields_data($this->get_editable_fields($instanceid), $instanceid);
+
+        foreach ($fields as $formfield) {
+            $formfield->instance_form_before_set_data($instance);
+        }
+    }
+
+    /**
+     * Saves the given data for custom fields, must be called after the instance is saved and id is present
+     *
+     * Example:
+     *   if ($data = $form->get_data()) {
+     *     // ... save main instance, set $data->id if instance was created.
+     *     $handler->instance_form_save($data);
+     *     redirect(...);
+     *   }
+     *
+     * @param stdClass $instance data received from a form
+     * @param bool $isnewinstance if this is call is made during instance creation
+     */
+    public function instance_form_save(stdClass $instance, bool $isnewinstance = false) {
+        if (empty($instance->id)) {
+            throw new \coding_exception('Caller must ensure that id is already set in data before calling this method');
+        }
+        if (!preg_grep('/^customfield_/', array_keys((array)$instance))) {
+            // For performance.
+            return;
+        }
+        $editablefields = $this->get_editable_fields($isnewinstance ? 0 : $instance->id);
+        $fields = api::get_instance_fields_data($editablefields, $instance->id);
+        foreach ($fields as $data) {
+            if (!$data->get('id')) {
+                $data->set('contextid', $this->get_instance_context($instance->id)->id);
+            }
+            $data->instance_form_save($instance);
+        }
+    }
+
+    /**
+     * Validates the given data for custom fields, used in moodleform validation() function
+     *
+     * Example:
+     *   public function validation($data, $files) {
+     *     $errors = [];
+     *     // .... check other fields.
+     *     $errors = array_merge($errors, $handler->instance_form_validation($data, $files));
+     *     return $errors;
+     *   }
+     *
+     * @param array $data
+     * @param array $files
+     * @return array validation errors
+     */
+    public function instance_form_validation(array $data, array $files) {
+        $instanceid = empty($data['id']) ? 0 : $data['id'];
+        $editablefields = $this->get_editable_fields($instanceid);
+        $fields = api::get_instance_fields_data($editablefields, $instanceid);
+        $errors = [];
+        foreach ($fields as $formfield) {
+            $errors += $formfield->instance_form_validation($data, $files);
+        }
+        return $errors;
+    }
+
+    /**
+     * Adds custom fields to instance editing form
+     *
+     * Example:
+     *   public function definition() {
+     *     // ... normal instance definition, including hidden 'id' field.
+     *     $handler->instance_form_definition($this->_form, $instanceid);
+     *     $this->add_action_buttons();
+     *   }
+     *
+     * @param \MoodleQuickForm $mform
+     * @param int $instanceid id of the instance, can be null when instance is being created
+     */
+    public function instance_form_definition(\MoodleQuickForm $mform, int $instanceid = 0) {
+
+        $editablefields = $this->get_editable_fields($instanceid);
+        $fieldswithdata = api::get_instance_fields_data($editablefields, $instanceid);
+        $lastcategoryid = null;
+        foreach ($fieldswithdata as $data) {
+            $categoryid = $data->get_field()->get_category()->get('id');
+            if ($categoryid != $lastcategoryid) {
+                $mform->addElement('header', 'category_' . $categoryid,
+                    format_string($data->get_field()->get_category()->get('name')));
+                $lastcategoryid = $categoryid;
+            }
+            $data->instance_form_definition($mform);
+            $field = $data->get_field()->to_record();
+            if (strlen($field->description)) {
+                // Add field description.
+                $context = $this->get_configuration_context();
+                $value = file_rewrite_pluginfile_urls($field->description, 'pluginfile.php',
+                    $context->id, 'core_customfield', 'description', $field->id);
+                $value = format_text($value, $field->descriptionformat, ['context' => $context]);
+                $mform->addElement('static', 'customfield_' . $field->shortname . '_static', '', $value);
+            }
+        }
+    }
+
+    /**
+     * Get field types array
+     *
+     * @return array
+     */
+    public function get_available_field_types() :array {
+        return api::get_available_field_types();
+    }
+
+    /**
+     * Options for processing embedded files in the field description.
+     *
+     * Handlers may want to extend it to disable files support and/or specify 'noclean'=>true
+     * Context is not necessary here
+     *
+     * @return array
+     */
+    public function get_description_text_options() : array {
+        global $CFG;
+        require_once($CFG->libdir.'/formslib.php');
+        return [
+            'maxfiles' => EDITOR_UNLIMITED_FILES,
+            'maxbytes' => $CFG->maxbytes,
+            'context' => $this->get_configuration_context()
+        ];
+    }
+
+    /**
+     * Save the field configuration with the data from the form
+     *
+     * @param field_controller $field
+     * @param stdClass $data data from the form
+     */
+    public function save_field_configuration(field_controller $field, stdClass $data) {
+        if ($field->get('id')) {
+            $field = $this->validate_field($field);
+        } else {
+            $this->validate_category($field->get_category());
+        }
+        api::save_field_configuration($field, $data);
+        $this->clear_configuration_cache();
+    }
+
+    /**
+     * Creates or updates custom field data for a instanceid from backup data.
+     *
+     * The handlers have to override it if they support backup
+     *
+     * @param \restore_task $task
+     * @param array $data
+     */
+    public function restore_instance_data_from_backup(\restore_task $task, array $data) {
+        throw new \coding_exception('Must be implemented in the handler');
+    }
+
+    /**
+     * Returns list of fields defined for this instance as an array (not groupped by categories)
+     *
+     * Fields are sorted in the same order they would appear on the instance edit form
+     *
+     * Note that this function returns all fields in all categories regardless of whether the current user
+     * can view or edit data associated with them
+     *
+     * @return field_controller[]
+     */
+    public function get_fields() : array {
+        $categories = $this->get_categories_with_fields();
+        $fields = [];
+        foreach ($categories as $category) {
+            foreach ($category->get_fields() as $field) {
+                $fields[$field->get('id')] = $field;
+            }
+        }
+        return $fields;
+    }
+
+    /**
+     * Get visible fields
+     *
+     * @param int $instanceid
+     * @return field_controller[]
+     */
+    protected function get_visible_fields(int $instanceid) : array {
+        $handler = $this;
+        return array_filter($this->get_fields(),
+            function($field) use($handler, $instanceid) {
+                return $handler->can_view($field, $instanceid);
+            }
+        );
+    }
+
+    /**
+     * Get editable fields
+     *
+     * @param int $instanceid
+     * @return field_controller[]
+     */
+    public function get_editable_fields(int $instanceid) : array {
+        $handler = $this;
+        return array_filter($this->get_fields(),
+            function($field) use($handler, $instanceid) {
+                return $handler->can_edit($field, $instanceid);
+            }
+        );
+    }
+
+    /**
+     * Allows to add custom controls to the field configuration form that will be saved in configdata
+     *
+     * @param \MoodleQuickForm $mform
+     */
+    public function config_form_definition(\MoodleQuickForm $mform) {
+    }
+
+    /**
+     * Deletes all data related to all fields of an instance.
+     *
+     * @param int $instanceid
+     */
+    public function delete_instance(int $instanceid) {
+        $fielddata = api::get_instance_fields_data($this->get_fields(), $instanceid, false);
+        foreach ($fielddata as $data) {
+            $data->delete();
+        }
+    }
+
+    /**
+     * Set up page customfield/edit.php
+     *
+     * Handler should override this method and set page context
+     *
+     * @param field_controller $field
+     * @return string page heading
+     */
+    public function setup_edit_page(field_controller $field) : string {
+        global $PAGE;
+
+        // Page context.
+        $context = $this->get_configuration_context();
+        if ($context->contextlevel == CONTEXT_MODULE) {
+            list($course, $cm) = get_course_and_cm_from_cmid($context->instanceid, '', $context->get_course_context()->instanceid);
+            require_login($course, false, $cm);
+        } else if ($context->contextlevel == CONTEXT_COURSE) {
+            require_login($context->instanceid, false);
+        } else {
+            $PAGE->set_context(null); // This will set to system context only if the context was not set before.
+            if ($PAGE->context->id != $context->id) {
+                // In case of user or block context level this method must be overridden.
+                debugging('Handler must override setup_edit_page() and set the page context before calling parent method.',
+                    DEBUG_DEVELOPER);
+            }
+        }
+
+        // Set up url and title.
+        if ($field->get('id')) {
+            $field = $this->validate_field($field);
+        } else {
+            $this->validate_category($field->get_category());
+        }
+        $url = new \moodle_url('/customfield/edit.php',
+            ['id' => $field->get('id'), 'type' => $field->get('type'), 'categoryid' => $field->get('categoryid')]);
+
+        $PAGE->set_url($url);
+        $typestr = get_string('pluginname', 'customfield_' . $field->get('type'));
+        if ($field->get('id')) {
+            $title = get_string('editingfield', 'core_customfield',
+                $field->get_formatted_name());
+        } else {
+            $title = get_string('addingnewcustomfield', 'core_customfield', $typestr);
+        }
+        $PAGE->set_title($title);
+        return $title;
+    }
+}
diff --git a/customfield/classes/output/field_data.php b/customfield/classes/output/field_data.php
new file mode 100644 (file)
index 0000000..76f261d
--- /dev/null
@@ -0,0 +1,114 @@
+<?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/>.
+
+/**
+ *  core_customfield field value renderable.
+ *
+ * @package   core_customfield
+ * @copyright 2018 Daniel Neis Araujo <danielneis@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield\output;
+
+use core_customfield\data_controller;
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * core_customfield field value renderable class.
+ *
+ * @package   core_customfield
+ * @copyright 2018 Daniel Neis Araujo <danielneis@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class field_data implements \renderable, \templatable {
+
+    /** @var \core_customfield\data_controller */
+    protected $data;
+
+    /**
+     * Renderable constructor.
+     *
+     * @param \core_customfield\data_controller $data
+     */
+    public function __construct(\core_customfield\data_controller $data) {
+        $this->data = $data;
+    }
+
+    /**
+     * Returns the data value formatted for the output
+     *
+     * @return mixed|null
+     */
+    public function get_value() {
+        return $this->data->export_value();
+    }
+
+    /**
+     * Returns the field type (checkbox, date, text, ...)
+     *
+     * @return string
+     */
+    public function get_type() : string {
+        return $this->data->get_field()->get('type');
+    }
+
+    /**
+     * Returns the field short name
+     *
+     * @return string
+     */
+    public function get_shortname() : string {
+        return $this->data->get_field()->get('shortname');
+    }
+
+    /**
+     * Returns the field name formatted for the output
+     *
+     * @return string
+     */
+    public function get_name() : string {
+        return $this->data->get_field()->get_formatted_name();
+    }
+
+    /**
+     * Returns the data controller used to create this object if additional attributes are needed
+     *
+     * @return data_controller
+     */
+    public function get_data_controller() : data_controller {
+        return $this->data;
+    }
+
+    /**
+     * Export data for using as template context.
+     *
+     * @param \renderer_base $output
+     * @return \stdClass
+     */
+    public function export_for_template(\renderer_base $output) {
+        $value = $this->get_value();
+        return (object)[
+            'value' => $value,
+            'type' => $this->get_type(),
+            'shortname' => $this->get_shortname(),
+            'name' => $this->get_name(),
+            'hasvalue' => ($value !== null),
+            'instanceid' => $this->data->get('instanceid')
+        ];
+    }
+}
diff --git a/customfield/classes/output/management.php b/customfield/classes/output/management.php
new file mode 100644 (file)
index 0000000..e451b27
--- /dev/null
@@ -0,0 +1,132 @@
+<?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/>.
+
+/**
+ * Customfield component output.
+ *
+ * @package   core_customfield
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield\output;
+
+use core_customfield\api;
+use core_customfield\handler;
+use renderable;
+use templatable;
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * Class management
+ *
+ * @package core_customfield
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class management implements renderable, templatable {
+
+    /**
+     * @var handler
+     */
+    protected $handler;
+    /**
+     * @var
+     */
+    protected $categoryid;
+
+    /**
+     * management constructor.
+     *
+     * @param \core_customfield\handler $handler
+     */
+    public function __construct(\core_customfield\handler $handler) {
+        $this->handler = $handler;
+    }
+
+    /**
+     * Export for template
+     *
+     * @param \renderer_base $output
+     * @return array|object|\stdClass
+     */
+    public function export_for_template(\renderer_base $output) {
+        $data = new \stdClass();
+
+        $fieldtypes = $this->handler->get_available_field_types();
+
+        $data->component = $this->handler->get_component();
+        $data->area = $this->handler->get_area();
+        $data->itemid = $this->handler->get_itemid();
+        $data->usescategories = $this->handler->uses_categories();
+        $categories = $this->handler->get_categories_with_fields();
+
+        $categoriesarray = array();
+
+        foreach ($categories as $category) {
+
+            $categoryarray = array();
+            $categoryarray['id'] = $category->get('id');
+            $categoryarray['nameeditable'] = $output->render(api::get_category_inplace_editable($category, true));
+            $categoryarray['movetitle'] = get_string('movecategory', 'core_customfield',
+                $category->get_formatted_name());
+
+            $categoryarray['fields'] = array();
+
+            foreach ($category->get_fields() as $field) {
+
+                $fieldname = $field->get_formatted_name();
+                $fieldarray['type'] = $fieldtypes[$field->get('type')];
+                $fieldarray['id'] = $field->get('id');
+                $fieldarray['name'] = $fieldname;
+                $fieldarray['shortname'] = $field->get('shortname');
+                $fieldarray['movetitle'] = get_string('movefield', 'core_customfield', $fieldname);
+
+                $fieldarray['editfieldurl'] = (new \moodle_url('/customfield/edit.php', [
+                        'id' => $fieldarray['id'],
+                ]))->out(false);
+
+                $categoryarray['fields'][] = $fieldarray;
+            }
+
+            $menu = new \action_menu();
+            $menu->set_alignment(\action_menu::BL, \action_menu::BL);
+            $menu->set_menu_trigger(get_string('createnewcustomfield', 'core_customfield'));
+
+            $baseaddfieldurl = new \moodle_url('/customfield/edit.php',
+                    array('action' => 'editfield', 'categoryid' => $category->get('id')));
+            foreach ($fieldtypes as $type => $fieldname) {
+                $addfieldurl = new \moodle_url($baseaddfieldurl, array('type' => $type));
+                $action = new \action_menu_link_secondary($addfieldurl, null, $fieldname);
+                $menu->add($action);
+            }
+            $menu->attributes['class'] .= ' float-left mr-1';
+
+            $categoryarray['addfieldmenu'] = $output->render($menu);
+
+            $categoriesarray[] = $categoryarray;
+        }
+
+        $data->categories = $categoriesarray;
+
+        if (empty($data->categories)) {
+            $data->nocategories = get_string('nocategories', 'core_customfield');
+        }
+
+        return $data;
+    }
+}
diff --git a/customfield/classes/output/renderer.php b/customfield/classes/output/renderer.php
new file mode 100644 (file)
index 0000000..2fa455d
--- /dev/null
@@ -0,0 +1,62 @@
+<?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/>.
+
+/**
+ * Renderer.
+ *
+ * @package   core_customfield
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+use plugin_renderer_base;
+
+/**
+ * Renderer class.
+ *
+ * @package   core_customfield
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class renderer extends plugin_renderer_base {
+
+    /**
+     * Render custom field management interface.
+     *
+     * @param \core_customfield\output\management $list
+     * @return string HTML
+     */
+    protected function render_management(\core_customfield\output\management $list) {
+        $context = $list->export_for_template($this);
+
+        return $this->render_from_template('core_customfield/list', $context);
+    }
+
+    /**
+     * Render single custom field value
+     *
+     * @param \core_customfield\output\field_data $field
+     * @return string HTML
+     */
+    protected function render_field_data(\core_customfield\output\field_data $field) {
+        $context = $field->export_for_template($this);
+        return $this->render_from_template('core_customfield/field_data', $context);
+    }
+}
\ No newline at end of file
diff --git a/customfield/classes/privacy/customfield_provider.php b/customfield/classes/privacy/customfield_provider.php
new file mode 100644 (file)
index 0000000..8d05a5b
--- /dev/null
@@ -0,0 +1,84 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains interface customfield_provider
+ *
+ * @package core_customfield
+ * @copyright 2018 Marina Glancy
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield\privacy;
+
+use core_customfield\data_controller;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Interface customfield_provider, all customfield plugins need to implement it
+ *
+ * @package core_customfield
+ * @copyright 2018 Marina Glancy
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface customfield_provider extends
+        \core_privacy\local\request\plugin\subplugin_provider,
+
+        // The customfield plugins do not need to do anything themselves for the shared_userlist.
+        // This is all handled by the component core_customfield.
+        \core_privacy\local\request\shared_userlist_provider
+    {
+
+    /**
+     * Preprocesses data object that is going to be exported
+     *
+     * Minimum implementation:
+     *     writer::with_context($data->get_context())->export_data($subcontext, $exportdata);
+     *
+     * @param data_controller $data
+     * @param \stdClass $exportdata generated object to be exported
+     * @param array $subcontext subcontext to use when exporting
+     * @return mixed
+     */
+    public static function export_customfield_data(data_controller $data, \stdClass $exportdata, array $subcontext);
+
+    /**
+     * Allows plugins to delete everything they store related to the data (usually files)
+     *
+     * If plugin does not store any related files or other information, implement as an empty function
+     *
+     * @param string $dataidstest select query for data id (note that it may also return data for other field types)
+     * @param array $params named parameters for the select query
+     * @param array $contextids list of affected data contexts
+     * @return mixed
+     */
+    public static function before_delete_data(string $dataidstest, array $params, array $contextids);
+
+    /**
+     * Allows plugins to delete everything they store related to the field configuration (usually files)
+     *
+     * The implementation should not delete data or anything related to the data, since "before_delete_data" is
+     * invoked separately.
+     *
+     * If plugin does not store any related files or other information, implement as an empty function
+     *
+     * @param string $fieldidstest select query for field id (note that it may also return fields of other types)
+     * @param array $params named parameters for the select query
+     * @param int[] $contextids list of affected configuration contexts
+     */
+    public static function before_delete_fields(string $fieldidstest, array $params, array $contextids);
+}
diff --git a/customfield/classes/privacy/provider.php b/customfield/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..cf7d2fe
--- /dev/null
@@ -0,0 +1,492 @@
+<?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/>.
+
+/**
+ * Customfield component provider class
+ *
+ * @package   core_customfield
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_customfield\data_controller;
+use core_customfield\handler;
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\writer;
+use core_privacy\manager;
+use Horde\Socket\Client\Exception;
+
+/**
+ * Class provider
+ *
+ * Customfields API does not directly store userid and does not perform any export or delete functionality by itself
+ *
+ * However this class defines several functions that can be utilized by components that use customfields API to
+ * export/delete user data.
+ *
+ * @package core_customfield
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+        // Customfield store data.
+        \core_privacy\local\metadata\provider,
+
+        // The customfield subsystem stores data on behalf of other components.
+        \core_privacy\local\request\subsystem\plugin_provider,
+        \core_privacy\local\request\shared_userlist_provider  {
+
+    /**
+     * Return the fields which contain personal data.
+     *
+     * @param collection $collection a reference to the collection to use to store the metadata.
+     * @return collection the updated collection of metadata items.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_database_table(
+            'customfield_data',
+            [
+                'fieldid' => 'privacy:metadata:customfield_data:fieldid',
+                'instanceid' => 'privacy:metadata:customfield_data:instanceid',
+                'intvalue' => 'privacy:metadata:customfield_data:intvalue',
+                'decvalue' => 'privacy:metadata:customfield_data:decvalue',
+                'shortcharvalue' => 'privacy:metadata:customfield_data:shortcharvalue',
+                'charvalue' => 'privacy:metadata:customfield_data:charvalue',
+                'value' => 'privacy:metadata:customfield_data:value',
+                'valueformat' => 'privacy:metadata:customfield_data:valueformat',
+                'timecreated' => 'privacy:metadata:customfield_data:timecreated',
+                'timemodified' => 'privacy:metadata:customfield_data:timemodified',
+                'contextid' => 'privacy:metadata:customfield_data:contextid',
+            ],
+            'privacy:metadata:customfield_data'
+        );
+
+        // Link to subplugins.
+        $collection->add_plugintype_link('customfield', [], 'privacy:metadata:customfieldpluginsummary');
+
+        $collection->link_subsystem('core_files', 'privacy:metadata:filepurpose');
+
+        return $collection;
+    }
+
+    /**
+     * Returns contexts that have customfields data
+     *
+     * To be used in implementations of core_user_data_provider::get_contexts_for_userid
+     * Caller needs to transfer the $userid to the select subqueries for
+     * customfield_category->itemid and/or customfield_data->instanceid
+     *
+     * @param string $component
+     * @param string $area
+     * @param string $itemidstest subquery for selecting customfield_category->itemid
+     * @param string $instanceidstest subquery for selecting customfield_data->instanceid
+     * @param array $params array of named parameters
+     * @return contextlist
+     */
+    public static function get_customfields_data_contexts(string $component, string $area,
+            string $itemidstest = 'IS NOT NULL', string $instanceidstest = 'IS NOT NULL', array $params = []) : contextlist {
+
+        $sql = "SELECT d.contextid FROM {customfield_category} c
+            JOIN {customfield_field} f ON f.categoryid = c.id
+            JOIN {customfield_data} d ON d.fieldid = f.id AND d.instanceid $instanceidstest
+            WHERE c.component = :cfcomponent AND c.area = :cfarea AND c.itemid $itemidstest";
+
+        $contextlist = new contextlist();
+        $contextlist->add_from_sql($sql, self::get_params($component, $area, $params));
+
+        return $contextlist;
+    }
+
+    /**
+     * Returns contexts that have customfields configuration (categories and fields)
+     *
+     * To be used in implementations of core_user_data_provider::get_contexts_for_userid in cases when user is
+     * an owner of the fields configuration
+     * Caller needs to transfer the $userid to the select subquery for customfield_category->itemid
+     *
+     * @param string $component
+     * @param string $area
+     * @param string $itemidstest subquery for selecting customfield_category->itemid
+     * @param array $params array of named parameters for itemidstest subquery
+     * @return contextlist
+     */
+    public static function get_customfields_configuration_contexts(string $component, string $area,
+            string $itemidstest = 'IS NOT NULL', array $params = []) : contextlist {
+
+        $sql = "SELECT c.contextid FROM {customfield_category} c
+            WHERE c.component = :cfcomponent AND c.area = :cfarea AND c.itemid $itemidstest";
+        $params['component'] = $component;
+        $params['area'] = $area;
+
+        $contextlist = new contextlist();
+        $contextlist->add_from_sql($sql, self::get_params($component, $area, $params));
+
+        return $contextlist;
+
+    }
+
+    /**
+     * Exports customfields data
+     *
+     * To be used in implementations of core_user_data_provider::export_user_data
+     * Caller needs to transfer the $userid to the select subqueries for
+     * customfield_category->itemid and/or customfield_data->instanceid
+     *
+     * @param approved_contextlist $contextlist
+     * @param string $component
+     * @param string $area
+     * @param string $itemidstest subquery for selecting customfield_category->itemid
+     * @param string $instanceidstest subquery for selecting customfield_data->instanceid
+     * @param array $params array of named parameters for itemidstest and instanceidstest subqueries
+     * @param array $subcontext subcontext to use in context_writer::export_data, if null (default) the
+     *     "Custom fields data" will be used;
+     *     the data id will be appended to the subcontext array.
+     */
+    public static function export_customfields_data(approved_contextlist $contextlist, string $component, string $area,
+                string $itemidstest = 'IS NOT NULL', string $instanceidstest = 'IS NOT NULL', array $params = [],
+                array $subcontext = null) {
+        global $DB;
+
+        // This query is very similar to api::get_instances_fields_data() but also works for multiple itemids
+        // and has a context filter.
+        list($contextidstest, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED, 'cfctx');
+        $sql = "SELECT d.*, f.type AS fieldtype, f.name as fieldname, f.shortname as fieldshortname, c.itemid
+            FROM {customfield_category} c
+            JOIN {customfield_field} f ON f.categoryid = c.id
+            JOIN {customfield_data} d ON d.fieldid = f.id AND d.instanceid $instanceidstest AND d.contextid $contextidstest
+            WHERE c.component = :cfcomponent AND c.area = :cfarea AND c.itemid $itemidstest
+            ORDER BY c.itemid, c.sortorder, f.sortorder";
+        $params = self::get_params($component, $area, $params) + $contextparams;
+        $records = $DB->get_recordset_sql($sql, $params);
+
+        if ($subcontext === null) {
+            $subcontext = [get_string('customfielddata', 'core_customfield')];
+        }
+
+        /** @var handler $handler */
+        $handler = null;
+        $fields = null;
+        foreach ($records as $record) {
+            if (!$handler || $handler->get_itemid() != $record->itemid) {
+                $handler = handler::get_handler($component, $area, $record->itemid);
+                $fields = $handler->get_fields();
+            }
+            $field = (object)['type' => $record->fieldtype, 'shortname' => $record->fieldshortname, 'name' => $record->fieldname];
+            unset($record->itemid, $record->fieldtype, $record->fieldshortname, $record->fieldname);
+            try {
+                $field = array_key_exists($record->fieldid, $fields) ? $fields[$record->fieldid] : null;
+                $data = data_controller::create(0, $record, $field);
+                self::export_customfield_data($data, array_merge($subcontext, [$record->id]));
+            } catch (Exception $e) {
+                // We store some data that we can not initialise controller for. We still need to export it.
+                self::export_customfield_data_unknown($record, $field, array_merge($subcontext, [$record->id]));
+            }
+        }
+        $records->close();
+    }
+
+    /**
+     * Deletes customfields data
+     *
+     * To be used in implementations of core_user_data_provider::delete_data_for_user
+     * Caller needs to transfer the $userid to the select subqueries for
+     * customfield_category->itemid and/or customfield_data->instanceid
+     *
+     * @param approved_contextlist $contextlist
+     * @param string $component
+     * @param string $area
+     * @param string $itemidstest subquery for selecting customfield_category->itemid
+     * @param string $instanceidstest subquery for selecting customfield_data->instanceid
+     * @param array $params array of named parameters for itemidstest and instanceidstest subqueries
+     */
+    public static function delete_customfields_data(approved_contextlist $contextlist, string $component, string $area,
+            string $itemidstest = 'IS NOT NULL', string $instanceidstest = 'IS NOT NULL', array $params = []) {
+        global $DB;
+
+        list($contextidstest, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED, 'cfctx');
+        $sql = "SELECT d.id
+            FROM {customfield_category} c
+            JOIN {customfield_field} f ON f.categoryid = c.id
+            JOIN {customfield_data} d ON d.fieldid = f.id AND d.instanceid $instanceidstest AND d.contextid $contextidstest
+            WHERE c.component = :cfcomponent AND c.area = :cfarea AND c.itemid $itemidstest";
+        $params = self::get_params($component, $area, $params) + $contextparams;
+
+        self::before_delete_data('IN (' . $sql . ') ', $params);
+
+        $DB->execute("DELETE FROM {customfield_data}
+            WHERE instanceid $instanceidstest
+            AND contextid $contextidstest
+            AND fieldid IN (SELECT f.id
+                FROM {customfield_category} c
+                JOIN {customfield_field} f ON f.categoryid = c.id
+                WHERE c.component = :cfcomponent AND c.area = :cfarea AND c.itemid $itemidstest)", $params);
+    }
+
+    /**
+     * Deletes customfields configuration (categories and fields) and all relevant data
+     *
+     * To be used in implementations of core_user_data_provider::delete_data_for_user in cases when user is
+     * an owner of the fields configuration and it is considered user information (quite unlikely situtation but we never
+     * know what customfields API can be used for)
+     *
+     * Caller needs to transfer the $userid to the select subquery for customfield_category->itemid
+     *
+     * @param approved_contextlist $contextlist
+     * @param string $component
+     * @param string $area
+     * @param string $itemidstest subquery for selecting customfield_category->itemid
+     * @param array $params array of named parameters for itemidstest subquery
+     */
+    public static function delete_customfields_configuration(approved_contextlist $contextlist, string $component, string $area,
+            string $itemidstest = 'IS NOT NULL', array $params = []) {
+        global $DB;
+
+        list($contextidstest, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED, 'cfctx');
+        $params = self::get_params($component, $area, $params) + $contextparams;
+
+        $categoriesids = $DB->get_fieldset_sql("SELECT c.id
+            FROM {customfield_category} c
+            WHERE c.component = :cfcomponent AND c.area = :cfarea AND c.itemid $itemidstest AND c.contextid $contextidstest",
+            $params);
+
+        self::delete_categories($contextlist->get_contextids(), $categoriesids);
+    }
+
+    /**
+     * Deletes all customfields configuration (categories and fields) and all relevant data for the given category context
+     *
+     * To be used in implementations of core_user_data_provider::delete_data_for_all_users_in_context
+     *
+     * @param string $component
+     * @param string $area
+     * @param \context $context
+     */
+    public static function delete_customfields_configuration_for_context(string $component, string $area, \context $context) {
+        global $DB;
+        $categoriesids = $DB->get_fieldset_sql("SELECT c.id
+            FROM {customfield_category} c
+            JOIN {context} ctx ON ctx.id = c.contextid AND ctx.path LIKE :ctxpath
+            WHERE c.component = :cfcomponent AND c.area = :cfarea",
+            self::get_params($component, $area, ['ctxpath' => $context->path]));
+
+        self::delete_categories([$context->id], $categoriesids);
+    }
+
+    /**
+     * Deletes all customfields data for the given context
+     *
+     * To be used in implementations of core_user_data_provider::delete_data_for_all_users_in_context
+     *
+     * @param string $component
+     * @param string $area
+     * @param \context $context
+     */
+    public static function delete_customfields_data_for_context(string $component, string $area, \context $context) {
+        global $DB;
+
+        $sql = "SELECT d.id
+            FROM {customfield_category} c
+            JOIN {customfield_field} f ON f.categoryid = c.id
+            JOIN {customfield_data} d ON d.fieldid = f.id
+            JOIN {context} ctx ON ctx.id = d.contextid AND ctx.path LIKE :ctxpath
+            WHERE c.component = :cfcomponent AND c.area = :cfarea";
+        $params = self::get_params($component, $area, ['ctxpath' => $context->path . '%']);
+
+        self::before_delete_data('IN (' . $sql . ') ', $params);
+
+        $DB->execute("DELETE FROM {customfield_data}
+            WHERE fieldid IN (SELECT f.id
+                FROM {customfield_category} c
+                JOIN {customfield_field} f ON f.categoryid = c.id
+                WHERE c.component = :cfcomponent AND c.area = :cfarea)
+            AND contextid IN (SELECT id FROM {context} WHERE path LIKE :ctxpath)",
+            $params);
+    }
+
+    /**
+     * Checks that $params is an associative array and adds parameters for component and area
+     *
+     * @param string $component
+     * @param string $area
+     * @param array $params
+     * @return array
+     * @throws \coding_exception
+     */
+    protected static function get_params(string $component, string $area, array $params) : array {
+        if (!empty($params) && (array_keys($params) === range(0, count($params) - 1))) {
+            // Argument $params is not an associative array.
+            throw new \coding_exception('Argument $params must be an associative array!');
+        }
+        return $params + ['cfcomponent' => $component, 'cfarea' => $area];
+    }
+
+    /**
+     * Delete custom fields categories configurations, all their fields and data
+     *
+     * @param array $contextids
+     * @param array $categoriesids
+     */
+    protected static function delete_categories(array $contextids, array $categoriesids) {
+        global $DB;
+
+        if (!$categoriesids) {
+            return;
+        }
+
+        list($categoryidstest, $catparams) = $DB->get_in_or_equal($categoriesids, SQL_PARAMS_NAMED, 'cfcat');
+        $datasql = "SELECT d.id FROM {customfield_data} d JOIN {customfield_field} f ON f.id = d.fieldid " .
+            "WHERE f.categoryid $categoryidstest";
+        $fieldsql = "SELECT f.id AS fieldid FROM {customfield_field} f WHERE f.categoryid $categoryidstest";
+
+        self::before_delete_data("IN ($datasql)", $catparams);
+        self::before_delete_fields($categoryidstest, $catparams);
+
+        $DB->execute('DELETE FROM {customfield_data} WHERE fieldid IN (' . $fieldsql . ')', $catparams);
+        $DB->execute("DELETE FROM {customfield_field} WHERE categoryid $categoryidstest", $catparams);
+        $DB->execute("DELETE FROM {customfield_category} WHERE id $categoryidstest", $catparams);
+
+    }
+
+    /**
+     * Executes callbacks from the customfield plugins to delete anything related to the data records (usually files)
+     *
+     * @param string $dataidstest
+     * @param array $params
+     */
+    protected static function before_delete_data(string $dataidstest, array $params) {
+        global $DB;
+        // Find all field types and all contexts for each field type.
+        $records = $DB->get_recordset_sql("SELECT ff.type, dd.contextid
+            FROM {customfield_data} dd
+            JOIN {customfield_field} ff ON ff.id = dd.fieldid
+            WHERE dd.id $dataidstest
+            GROUP BY ff.type, dd.contextid",
+            $params);
+
+        $fieldtypes = [];
+        foreach ($records as $record) {
+            $fieldtypes += [$record->type => []];
+            $fieldtypes[$record->type][] = $record->contextid;
+        }
+        $records->close();
+
+        // Call plugin callbacks to delete data customfield_provider::before_delete_data().
+        foreach ($fieldtypes as $fieldtype => $contextids) {
+            $classname = manager::get_provider_classname_for_component('customfield_' . $fieldtype);
+            if (class_exists($classname) && is_subclass_of($classname, customfield_provider::class)) {
+                component_class_callback($classname, 'before_delete_data', [$dataidstest, $params, $contextids]);
+            }
+        }
+    }
+
+    /**
+     * Executes callbacks from the plugins to delete anything related to the fields (usually files)
+     *
+     * Also deletes description files
+     *
+     * @param string $categoryidstest
+     * @param array $params
+     */
+    protected static function before_delete_fields(string $categoryidstest, array $params) {
+        global $DB;
+        // Find all field types and contexts.
+        $fieldsql = "SELECT f.id AS fieldid FROM {customfield_field} f WHERE f.categoryid $categoryidstest";
+        $records = $DB->get_recordset_sql("SELECT f.type, c.contextid
+            FROM {customfield_field} f
+            JOIN {customfield_category} c ON c.id = f.categoryid
+            WHERE c.id $categoryidstest",
+            $params);
+
+        $contexts = [];
+        $fieldtypes = [];
+        foreach ($records as $record) {
+            $contexts[$record->contextid] = $record->contextid;
+            $fieldtypes += [$record->type => []];
+            $fieldtypes[$record->type][] = $record->contextid;
+        }
+        $records->close();
+
+        // Delete description files.
+        foreach ($contexts as $contextid) {
+            get_file_storage()->delete_area_files_select($contextid, 'core_customfield', 'description',
+                " IN ($fieldsql) ", $params);
+        }
+
+        // Call plugin callbacks to delete fields customfield_provider::before_delete_fields().
+        foreach ($fieldtypes as $type => $contextids) {
+            $classname = manager::get_provider_classname_for_component('customfield_' . $type);
+            if (class_exists($classname) && is_subclass_of($classname, customfield_provider::class)) {
+                component_class_callback($classname, 'before_delete_fields',
+                    [" IN ($fieldsql) ", $params, $contextids]);
+            }
+        }
+        $records->close();
+    }
+
+    /**
+     * Exports one instance of custom field data
+     *
+     * @param data_controller $data
+     * @param array $subcontext subcontext to pass to content_writer::export_data
+     */
+    public static function export_customfield_data(data_controller $data, array $subcontext) {
+        $context = $data->get_context();
+
+        $exportdata = $data->to_record();
+        $exportdata->fieldtype = $data->get_field()->get('type');
+        $exportdata->fieldshortname = $data->get_field()->get('shortname');
+        $exportdata->fieldname = $data->get_field()->get_formatted_name();
+        $exportdata->timecreated = \core_privacy\local\request\transform::datetime($exportdata->timecreated);
+        $exportdata->timemodified = \core_privacy\local\request\transform::datetime($exportdata->timemodified);
+        unset($exportdata->contextid);
+        // Use the "export_value" by default for the 'value' attribute, however the plugins may override it in their callback.
+        $exportdata->value = $data->export_value();
+
+        $classname = manager::get_provider_classname_for_component('customfield_' . $data->get_field()->get('type'));
+        if (class_exists($classname) && is_subclass_of($classname, customfield_provider::class)) {
+            component_class_callback($classname, 'export_customfield_data', [$data, $exportdata, $subcontext]);
+        } else {
+            // Custom field plugin does not implement customfield_provider, just export default value.
+            writer::with_context($context)->export_data($subcontext, $exportdata);
+        }
+    }
+
+    /**
+     * Export data record of unknown type when we were not able to create instance of data_controller
+     *
+     * @param \stdClass $record record from db table {customfield_data}
+     * @param \stdClass $field field record with at least fields type, shortname, name
+     * @param array $subcontext
+     */
+    protected static function export_customfield_data_unknown(\stdClass $record, \stdClass $field, array $subcontext) {
+        $context = \context::instance_by_id($record->contextid);
+
+        $record->fieldtype = $field->type;
+        $record->fieldshortname = $field->shortname;
+        $record->fieldname = format_string($field->name);
+        $record->timecreated = \core_privacy\local\request\transform::datetime($record->timecreated);
+        $record->timemodified = \core_privacy\local\request\transform::datetime($record->timemodified);
+        unset($record->contextid);
+        $record->value = format_text($record->value, $record->valueformat, ['context' => $context]);
+        writer::with_context($context)->export_data($subcontext, $record);
+    }
+}
diff --git a/customfield/edit.php b/customfield/edit.php
new file mode 100644 (file)
index 0000000..1685e7e
--- /dev/null
@@ -0,0 +1,61 @@
+<?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/>.
+
+/**
+ * Edit configuration of a custom field
+ *
+ * @package   core_customfield
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(__DIR__ . '/../config.php');
+require_once($CFG->libdir . '/adminlib.php');
+
+$id         = optional_param('id', 0, PARAM_INT);
+$categoryid = optional_param('categoryid', 0, PARAM_INT);
+$type       = optional_param('type', null, PARAM_COMPONENT);
+
+if ($id) {
+    $field = \core_customfield\field_controller::create($id);
+} else if ($categoryid && $type) {
+    $category = \core_customfield\category_controller::create($categoryid);
+    $field = \core_customfield\field_controller::create(0, (object)['type' => $type], $category);
+} else {
+    print_error('fieldnotfound', 'core_customfield');
+}
+
+$handler = $field->get_handler();
+require_login();
+if (!$handler->can_configure()) {
+    print_error('nopermissionconfigure', 'core_customfield');
+}
+$title = $handler->setup_edit_page($field);
+
+$mform = $handler->get_field_config_form($field);
+if ($mform->is_cancelled()) {
+    redirect($handler->get_configuration_url());
+} else if ($data = $mform->get_data()) {
+    $handler->save_field_configuration($field, $data);
+    redirect($handler->get_configuration_url());
+}
+
+echo $OUTPUT->header();
+echo $OUTPUT->heading($title);
+
+$mform->display();
+
+echo $OUTPUT->footer();
diff --git a/customfield/externallib.php b/customfield/externallib.php
new file mode 100644 (file)
index 0000000..24da2bb
--- /dev/null
@@ -0,0 +1,296 @@
+<?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/>.
+
+/**
+ * External interface library for customfields component
+ *
+ * @package   core_customfield
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die;
+
+require_once($CFG->libdir . "/externallib.php");
+
+/**
+ * Class core_customfield_external
+ *
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_customfield_external extends external_api {
+
+    /**
+     * Parameters for delete_field
+     *
+     * @return external_function_parameters
+     */
+    public static function delete_field_parameters() {
+        return new external_function_parameters(
+                array('id' => new external_value(PARAM_INT, 'Custom field ID to delete', VALUE_REQUIRED))
+        );
+    }
+
+    /**
+     * Delete custom field function
+     *
+     * @param int $id
+     */
+    public static function delete_field($id) {
+        $params = self::validate_parameters(self::delete_field_parameters(), ['id' => $id]);
+
+        $record = \core_customfield\field_controller::create($params['id']);
+        $handler = $record->get_handler();
+        if (!$handler->can_configure()) {
+            throw new moodle_exception('nopermissionconfigure', 'core_cus