diff --git a/.upgradenotes/MDL-86065-2025082206383935.yml b/.upgradenotes/MDL-86065-2025082206383935.yml new file mode 100644 index 00000000000..f012a64988e --- /dev/null +++ b/.upgradenotes/MDL-86065-2025082206383935.yml @@ -0,0 +1,11 @@ +issueNumber: MDL-86065 +notes: + core_customfield: + - message: >- + Added parameters 'component', 'area' and 'itemid' to the + `api::get_instance_fields_data()` and `api::get_instances_fields_data()` methods. + Added a new field 'shared' to the customfield_category DB table. + Added 'component', 'area' and 'itemid' fields to the customfield_data DB table. + Modified the customfield_data DB table unique index to include the new fields. + + type: changed diff --git a/public/admin/settings/customfield.php b/public/admin/settings/customfield.php new file mode 100644 index 00000000000..913b00d17b8 --- /dev/null +++ b/public/admin/settings/customfield.php @@ -0,0 +1,45 @@ +. + +/** + * Custom fields related settings. + * + * @package core_admin + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +defined('MOODLE_INTERNAL') || die; + +/** @var admin_root $ADMIN */ +$ADMIN->add( + 'root', + new admin_category( + 'customfield', + new lang_string('customfields', 'core_customfield') + ) +); + +$ADMIN->add( + 'customfield', + new admin_externalpage( + 'sharedcustomfields', + new lang_string('sharedcustomfields', 'core_customfield'), + new moodle_url("/customfield/customfield.php") + ) +); diff --git a/public/contentbank/classes/customfield/content_handler.php b/public/contentbank/classes/customfield/content_handler.php index bbb8efc0c21..076230ce29d 100644 --- a/public/contentbank/classes/customfield/content_handler.php +++ b/public/contentbank/classes/customfield/content_handler.php @@ -170,7 +170,7 @@ class content_handler extends \core_customfield\handler { $courseid = $task->get_courseid(); $context = $this->get_instance_context($courseid); $editablefields = $this->get_editable_fields($courseid); - $records = api::get_instance_fields_data($editablefields, $courseid); + $records = $this->get_instance_fields_data($editablefields, $courseid); $target = $task->get_target(); $override = ($target != \backup::TARGET_CURRENT_ADDING && $target != \backup::TARGET_EXISTING_ADDING); diff --git a/public/course/classes/customfield/course_handler.php b/public/course/classes/customfield/course_handler.php index 47af9bf6e4b..42e42d86705 100644 --- a/public/course/classes/customfield/course_handler.php +++ b/public/course/classes/customfield/course_handler.php @@ -26,7 +26,6 @@ namespace core_course\customfield; defined('MOODLE_INTERNAL') || die; -use core_customfield\api; use core_customfield\field_controller; /** @@ -120,7 +119,7 @@ class course_handler extends \core_customfield\handler { * @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'); + $visibility = $field->get_configdata_property('visibility') ?? self::VISIBLETOALL; if ($visibility == self::NOTVISIBLE) { return false; } else if ($visibility == self::VISIBLETOTEACHERS) { @@ -222,7 +221,7 @@ class course_handler extends \core_customfield\handler { $courseid = $task->get_courseid(); $context = $this->get_instance_context($courseid); $editablefields = $this->get_editable_fields($courseid); - $records = api::get_instance_fields_data($editablefields, $courseid); + $records = $this->get_instance_fields_data($editablefields, $courseid); $target = $task->get_target(); $override = ($target != \backup::TARGET_CURRENT_ADDING && $target != \backup::TARGET_EXISTING_ADDING); diff --git a/public/customfield/amd/build/repository/toggle_shared.min.js b/public/customfield/amd/build/repository/toggle_shared.min.js new file mode 100644 index 00000000000..331b1b46a5e --- /dev/null +++ b/public/customfield/amd/build/repository/toggle_shared.min.js @@ -0,0 +1,10 @@ +define("core_customfield/repository/toggle_shared",["exports","core/ajax"],(function(_exports,_ajax){var obj; +/** + * Module to handle toggle shared category AJAX requests + * + * @module core_customfield/repository/toggle_shared + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.toggleCategory=void 0,_ajax=(obj=_ajax)&&obj.__esModule?obj:{default:obj};_exports.toggleCategory=(categoryId,component,area,itemid,state)=>{const request={methodname:"core_customfield_toggle_shared",args:{categoryid:categoryId,component:component,area:area,itemid:itemid,state:state}};return _ajax.default.call([request])[0]}})); + +//# sourceMappingURL=toggle_shared.min.js.map \ No newline at end of file diff --git a/public/customfield/amd/build/repository/toggle_shared.min.js.map b/public/customfield/amd/build/repository/toggle_shared.min.js.map new file mode 100644 index 00000000000..9a4559b7c0c --- /dev/null +++ b/public/customfield/amd/build/repository/toggle_shared.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"toggle_shared.min.js","sources":["../../src/repository/toggle_shared.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Module to handle toggle shared category AJAX requests\n *\n * @module core_customfield/repository/toggle_shared\n * @copyright 2025 David Carrillo \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Ajax from 'core/ajax';\n\n/**\n * Toggle shared category enabled\n *\n * @method\n * @param {Number} categoryId\n * @param {String} component\n * @param {String} area\n * @param {Number} itemid\n * @param {Boolean} state\n * @return {Promise}\n */\nexport const toggleCategory = (categoryId, component, area, itemid, state) => {\n const request = {\n methodname: 'core_customfield_toggle_shared',\n args: {categoryid: categoryId, component: component, area: area, itemid: itemid, state: state}\n };\n\n return Ajax.call([request])[0];\n};\n"],"names":["categoryId","component","area","itemid","state","request","methodname","args","categoryid","Ajax","call"],"mappings":";;;;;;;wKAoC8B,CAACA,WAAYC,UAAWC,KAAMC,OAAQC,eAC1DC,QAAU,CACZC,WAAY,iCACZC,KAAM,CAACC,WAAYR,WAAYC,UAAWA,UAAWC,KAAMA,KAAMC,OAAQA,OAAQC,MAAOA,eAGrFK,cAAKC,KAAK,CAACL,UAAU"} \ No newline at end of file diff --git a/public/customfield/amd/build/toggle_shared.min.js b/public/customfield/amd/build/toggle_shared.min.js new file mode 100644 index 00000000000..0bfa577552d --- /dev/null +++ b/public/customfield/amd/build/toggle_shared.min.js @@ -0,0 +1,3 @@ +define("core_customfield/toggle_shared",["exports","core/inplace_editable","core/notification","core/pending","core_customfield/repository/toggle_shared"],(function(_exports,_inplace_editable,_notification,_pending,_toggle_shared){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_notification=_interopRequireDefault(_notification),_pending=_interopRequireDefault(_pending);let initialized=!1;_exports.init=()=>{initialized||(document.addEventListener("click",(event=>{const sharedToggle=event.target.closest('[data-action="shared-toggle"]');if(sharedToggle){const pendingPromise=new _pending.default("core_customfield/shared:toggle"),categoryId=sharedToggle.dataset.id,component=sharedToggle.dataset.component,area=sharedToggle.dataset.area,itemid=sharedToggle.dataset.itemid,sharedStateToggle=+!Number(sharedToggle.dataset.state);(0,_toggle_shared.toggleCategory)(categoryId,component,area,itemid,sharedStateToggle).then((()=>(sharedToggle.dataset.state=sharedStateToggle,pendingPromise.resolve()))).catch(_notification.default.exception)}})),initialized=!0)}})); + +//# sourceMappingURL=toggle_shared.min.js.map \ No newline at end of file diff --git a/public/customfield/amd/build/toggle_shared.min.js.map b/public/customfield/amd/build/toggle_shared.min.js.map new file mode 100644 index 00000000000..829a187418b --- /dev/null +++ b/public/customfield/amd/build/toggle_shared.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"toggle_shared.min.js","sources":["../src/toggle_shared.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Custom fields shared category toggle\n *\n * @module core_customfield/toggle_shared\n * @copyright 2025 David Carrillo \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n\"use strict\";\n\nimport 'core/inplace_editable';\nimport Notification from 'core/notification';\nimport Pending from 'core/pending';\nimport {toggleCategory} from 'core_customfield/repository/toggle_shared';\n\nlet initialized = false;\n\n/**\n * Initialise toggle\n */\nexport const init = () => {\n if (initialized) {\n // We already added the event listeners (can be called multiple times by mustache template).\n return;\n }\n\n document.addEventListener('click', event => {\n // Toggle shared category.\n const sharedToggle = event.target.closest('[data-action=\"shared-toggle\"]');\n if (sharedToggle) {\n const pendingPromise = new Pending('core_customfield/shared:toggle');\n const categoryId = sharedToggle.dataset.id;\n const component = sharedToggle.dataset.component;\n const area = sharedToggle.dataset.area;\n const itemid = sharedToggle.dataset.itemid;\n const sharedStateToggle = +!Number(sharedToggle.dataset.state);\n\n toggleCategory(categoryId, component, area, itemid, sharedStateToggle)\n .then(() => {\n sharedToggle.dataset.state = sharedStateToggle;\n return pendingPromise.resolve();\n })\n .catch(Notification.exception);\n }\n });\n\n initialized = true;\n};\n"],"names":["initialized","document","addEventListener","event","sharedToggle","target","closest","pendingPromise","Pending","categoryId","dataset","id","component","area","itemid","sharedStateToggle","Number","state","then","resolve","catch","Notification","exception"],"mappings":"weA8BIA,aAAc,gBAKE,KACZA,cAKJC,SAASC,iBAAiB,SAASC,cAEzBC,aAAeD,MAAME,OAAOC,QAAQ,oCACtCF,aAAc,OACRG,eAAiB,IAAIC,iBAAQ,kCAC7BC,WAAaL,aAAaM,QAAQC,GAClCC,UAAYR,aAAaM,QAAQE,UACjCC,KAAOT,aAAaM,QAAQG,KAC5BC,OAASV,aAAaM,QAAQI,OAC9BC,oBAAsBC,OAAOZ,aAAaM,QAAQO,yCAEzCR,WAAYG,UAAWC,KAAMC,OAAQC,mBAC/CG,MAAK,KACFd,aAAaM,QAAQO,MAAQF,kBACtBR,eAAeY,aAEzBC,MAAMC,sBAAaC,eAIhCtB,aAAc"} \ No newline at end of file diff --git a/public/customfield/amd/src/repository/toggle_shared.js b/public/customfield/amd/src/repository/toggle_shared.js new file mode 100644 index 00000000000..809a05d630b --- /dev/null +++ b/public/customfield/amd/src/repository/toggle_shared.js @@ -0,0 +1,44 @@ +// 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 . + +/** + * Module to handle toggle shared category AJAX requests + * + * @module core_customfield/repository/toggle_shared + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import Ajax from 'core/ajax'; + +/** + * Toggle shared category enabled + * + * @method + * @param {Number} categoryId + * @param {String} component + * @param {String} area + * @param {Number} itemid + * @param {Boolean} state + * @return {Promise} + */ +export const toggleCategory = (categoryId, component, area, itemid, state) => { + const request = { + methodname: 'core_customfield_toggle_shared', + args: {categoryid: categoryId, component: component, area: area, itemid: itemid, state: state} + }; + + return Ajax.call([request])[0]; +}; diff --git a/public/customfield/amd/src/toggle_shared.js b/public/customfield/amd/src/toggle_shared.js new file mode 100644 index 00000000000..734773c6403 --- /dev/null +++ b/public/customfield/amd/src/toggle_shared.js @@ -0,0 +1,63 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Custom fields shared category toggle + * + * @module core_customfield/toggle_shared + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +"use strict"; + +import 'core/inplace_editable'; +import Notification from 'core/notification'; +import Pending from 'core/pending'; +import {toggleCategory} from 'core_customfield/repository/toggle_shared'; + +let initialized = false; + +/** + * Initialise toggle + */ +export const init = () => { + if (initialized) { + // We already added the event listeners (can be called multiple times by mustache template). + return; + } + + document.addEventListener('click', event => { + // Toggle shared category. + const sharedToggle = event.target.closest('[data-action="shared-toggle"]'); + if (sharedToggle) { + const pendingPromise = new Pending('core_customfield/shared:toggle'); + const categoryId = sharedToggle.dataset.id; + const component = sharedToggle.dataset.component; + const area = sharedToggle.dataset.area; + const itemid = sharedToggle.dataset.itemid; + const sharedStateToggle = +!Number(sharedToggle.dataset.state); + + toggleCategory(categoryId, component, area, itemid, sharedStateToggle) + .then(() => { + sharedToggle.dataset.state = sharedStateToggle; + return pendingPromise.resolve(); + }) + .catch(Notification.exception); + } + }); + + initialized = true; +}; diff --git a/public/customfield/classes/api.php b/public/customfield/classes/api.php index c9988376900..cbec0dc9577 100644 --- a/public/customfield/classes/api.php +++ b/public/customfield/classes/api.php @@ -49,13 +49,23 @@ class api { * @param field_controller[] $fields list of fields indexed by field id * @param int $instanceid * @param bool $adddefaults + * @param string $component + * @param string $area + * @param int $itemid * @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]; + public static function get_instance_fields_data( + array $fields, + int $instanceid, + bool $adddefaults = true, + string $component = '', + string $area = '', + int $itemid = 0 + ): array { + return self::get_instances_fields_data($fields, [$instanceid], $adddefaults, $component, $area, $itemid)[$instanceid]; } /** @@ -64,12 +74,22 @@ class api { * @param field_controller[] $fields list of fields indexed by field id * @param int[] $instanceids * @param bool $adddefaults + * @param string $component + * @param string $area + * @param int $itemid * @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 { + public static function get_instances_fields_data( + array $fields, + array $instanceids, + bool $adddefaults = true, + string $component = '', + string $area = '', + int $itemid = 0 + ): array { global $DB; // Create the results array where instances and fields order is the same as in the input arrays. @@ -82,10 +102,18 @@ class api { // 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'); + $sqlplugin = ''; + if (!empty($component) && !empty($area)) { + // If component, area and itemid are provided, filter by them. + $params['component'] = $component; + $params['area'] = $area; + $params['itemid'] = $itemid; + $sqlplugin = "AND d.component = :component AND d.area = :area AND d.itemid = :itemid"; + } $sql = "SELECT d.* FROM {customfield_field} f JOIN {customfield_data} d ON (f.id = d.fieldid AND d.instanceid {$sqlinstances}) - WHERE f.id {$sqlfields}"; + WHERE f.id {$sqlfields} $sqlplugin"; $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]); @@ -439,4 +467,24 @@ class api { } return $ret; } + + /** + * Checks if a shared category is enabled for the given entity + * + * @param int $categoryid + * @param string $component + * @param string $area + * @param int $itemid + * @return bool + */ + public static function is_shared_category_enabled(int $categoryid, string $component, string $area, int $itemid): bool { + $sql = "categoryid = :categoryid AND component = :component AND area = :area AND itemid = :itemid"; + $params = [ + 'categoryid' => $categoryid, + 'component' => $component, + 'area' => $area, + 'itemid' => $itemid, + ]; + return shared::record_exists_select($sql, $params); + } } diff --git a/public/customfield/classes/category.php b/public/customfield/classes/category.php index 4f37247cb9e..7a15c4c19b0 100644 --- a/public/customfield/classes/category.php +++ b/public/customfield/classes/category.php @@ -74,6 +74,11 @@ class category extends persistent { 'default' => -1, 'null' => NULL_ALLOWED, ], + 'shared' => [ + 'type' => PARAM_BOOL, + 'null' => NULL_NOT_ALLOWED, + 'default' => false, + ], ); } } diff --git a/public/customfield/classes/category_controller.php b/public/customfield/classes/category_controller.php index 9ead53a2699..adb6e2165e4 100644 --- a/public/customfield/classes/category_controller.php +++ b/public/customfield/classes/category_controller.php @@ -50,6 +50,15 @@ class category_controller { /** @var handler */ protected $handler; + /** @var ?string */ + protected $component = null; + + /** @var ?string */ + protected $area = null; + + /** @var ?int */ + protected $itemid = null; + /** * category constructor. * @@ -107,6 +116,8 @@ class category_controller { } $record->itemid = $handler->get_itemid(); } + // Check if the category is shared. + $record->shared = $record->component === 'core_customfield' && $record->area === 'shared'; $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. @@ -115,6 +126,10 @@ class category_controller { } if ($handler) { $category->set_handler($handler); + + $category->set_original_component($category->get_handler()->get_component()); + $category->set_original_area($category->get_handler()->get_area()); + $category->set_original_itemid($category->get_handler()->get_itemid()); } return $category; } @@ -183,7 +198,12 @@ class category_controller { public function get_handler(): handler { if ($this->handler === null) { $this->handler = handler::get_handler($this->get('component'), $this->get('area'), $this->get('itemid')); + + $this->set_original_component($this->handler->get_component()); + $this->set_original_area($this->handler->get_area()); + $this->set_original_itemid($this->handler->get_itemid()); } + return $this->handler; } @@ -208,6 +228,67 @@ class category_controller { throw new \coding_exception('Context of the handler does not match the one from the record'); } $this->handler = $handler; + + $this->component = $handler->get_component(); + $this->area = $handler->get_area(); + $this->itemid = $handler->get_itemid(); + } + + /** + * Gets the original component. + * + * @return string|null + */ + public function get_original_component(): ?string { + return $this->component; + } + + /** + * Gets the original area. + * + * @return string|null + */ + public function get_original_area(): ?string { + return $this->area; + } + + /** + * Gets the original itemid. + * + * @return int|null + */ + public function get_original_itemid(): ?int { + return $this->itemid; + } + + /** + * Sets the original component. + * + * @param string $component + * @return void + */ + public function set_original_component(string $component): void { + $this->component = $component; + } + + /** + * Sets the original area. + * + * @param string $area + * @return void + */ + public function set_original_area(string $area): void { + $this->area = $area; + } + + /** + * Sets the original itemid. + * + * @param int $itemid + * @return void + */ + public function set_original_itemid(int $itemid): void { + $this->itemid = $itemid; } /** diff --git a/public/customfield/classes/customfield/shared_handler.php b/public/customfield/classes/customfield/shared_handler.php new file mode 100644 index 00000000000..f4c13a18e95 --- /dev/null +++ b/public/customfield/classes/customfield/shared_handler.php @@ -0,0 +1,122 @@ +. + +declare(strict_types=1); + +namespace core_customfield\customfield; + +use core\context; +use core\context\system; +use core\exception\coding_exception; +use core\url; +use core_customfield\field_controller; + +/** + * Shared handler for custom fields + * + * @package core_customfield + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class shared_handler extends \core_customfield\handler { + /** + * @var shared_handler|null + */ + protected static ?shared_handler $singleton = null; + + /** + * Returns a singleton + * + * @param int $itemid + * @return self + */ + public static function create(int $itemid = 0): self { + if (static::$singleton === null) { + self::$singleton = new static($itemid); + } + return self::$singleton; + } + + /** + * Run reset code after unit tests to reset the singleton usage. + */ + public static function reset_caches(): void { + if (!PHPUNIT_TEST) { + throw new coding_exception('This feature is only intended for use in unit tests'); + } + + static::$singleton = null; + } + + /** + * The current user can configure custom fields on this component. + * + * @return bool true if the current user can configure custom fields, false otherwise + */ + public function can_configure(): bool { + return has_capability('moodle/customfield:configureshared', $this->get_configuration_context()); + } + + /** + * The current user can edit custom fields on the given report. + * + * @param field_controller $field + * @param int $instanceid id of the report to test edit permission + * @return bool true if the current user can edit custom fields, false otherwise + */ + public function can_edit(field_controller $field, int $instanceid = 0): bool { + return has_capability('moodle/customfield:configureshared', $this->get_configuration_context()); + } + + /** + * The current user can view custom fields on the given report. + * + * @param field_controller $field + * @param int $instanceid id of the report to test edit permission + * @return bool true if the current user can edit custom fields, false otherwise + */ + public function can_view(field_controller $field, int $instanceid): bool { + return true; + } + + /** + * 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 system::instance(); + } + + /** + * URL for configuration of the fields on this handler. + * + * @return url The URL to configure custom fields for this component + */ + public function get_configuration_url(): url { + return new url('/customfield/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 { + return system::instance(); + } +} diff --git a/public/customfield/classes/data.php b/public/customfield/classes/data.php index 677bf24485b..99d5a205fac 100644 --- a/public/customfield/classes/data.php +++ b/public/customfield/classes/data.php @@ -106,7 +106,18 @@ class data extends persistent { 'type' => PARAM_INT, 'optional' => false, 'null' => NULL_NOT_ALLOWED - ] + ], + 'component' => [ + 'type' => PARAM_COMPONENT, + ], + 'area' => [ + 'type' => PARAM_COMPONENT, + ], + 'itemid' => [ + 'type' => PARAM_INT, + 'optional' => true, + 'default' => 0, + ], ); } diff --git a/public/customfield/classes/data_controller.php b/public/customfield/classes/data_controller.php index 905aaec96d9..ead81c30ff8 100644 --- a/public/customfield/classes/data_controller.php +++ b/public/customfield/classes/data_controller.php @@ -113,6 +113,18 @@ abstract class data_controller { if (!class_exists($customfieldtype) || !is_subclass_of($customfieldtype, self::class)) { throw new \moodle_exception('errorfieldtypenotfound', 'core_customfield', '', s($type)); } + + $category = $field->get_category(); + $record->component = $category->get_original_component(); + $record->area = $category->get_original_area(); + $record->itemid = $category->get_original_itemid(); + + if (!$record->component || !$record->area) { + $record->component = $field->get_handler()->get_component(); + $record->area = $field->get_handler()->get_area(); + $record->itemid = $field->get_handler()->get_itemid(); + } + $datacontroller = new $customfieldtype(0, $record); $datacontroller->field = $field; return $datacontroller; @@ -210,6 +222,14 @@ abstract class data_controller { $this->data->set($datafield, $datafieldvalue); $this->data->set('value', $value); + + // Set component, area and itemid from the handler. + $category = $this->field->get_category(); + $this->data->set_many([ + 'component' => $category->get_original_component(), + 'area' => $category->get_original_area(), + 'itemid' => $category->get_original_itemid(), + ]); $this->save(); } diff --git a/public/customfield/classes/event/shared_category_usage_disabled.php b/public/customfield/classes/event/shared_category_usage_disabled.php new file mode 100644 index 00000000000..0ac38ebfdb5 --- /dev/null +++ b/public/customfield/classes/event/shared_category_usage_disabled.php @@ -0,0 +1,85 @@ +. + +declare(strict_types=1); + +namespace core_customfield\event; + +use context; +use core_customfield\shared; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Custom field shared category usage disabled event class. + * + * @package core_customfield + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class shared_category_usage_disabled extends \core\event\base { + + /** + * Initialise the event data. + */ + protected function init(): void { + $this->data['objecttable'] = 'customfield_shared'; + $this->data['crud'] = 'd'; + $this->data['edulevel'] = self::LEVEL_OTHER; + } + + /** + * Creates an instance from a shared object + * + * @param shared $shared + * @param context $context + * @return category_updated + */ + public static function create_from_object(shared $shared, context $context): shared_category_usage_disabled { + $eventparams = [ + 'objectid' => $shared->get('id'), + 'context' => $context, + 'other' => [ + 'categoryid' => $shared->get('categoryid'), + 'component' => $shared->get('component'), + 'area' => $shared->get('area'), + 'itemid' => $shared->get('itemid'), + ], + ]; + $event = self::create($eventparams); + $event->add_record_snapshot($event->objecttable, $shared->to_record()); + return $event; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name(): string { + return get_string('eventsharedcategoryusagedisabled', 'core_customfield'); + } + + /** + * Returns non-localised description of what happened. + * + * @return string + */ + public function get_description(): string { + return "The user with ID '$this->userid' disabled usage of shared category with ID '{$this->other['categoryid']}'" . + " in '{$this->other['component']}/{$this->other['area']}'"; + } +} diff --git a/public/customfield/classes/event/shared_category_usage_enabled.php b/public/customfield/classes/event/shared_category_usage_enabled.php new file mode 100644 index 00000000000..a4614a03b7d --- /dev/null +++ b/public/customfield/classes/event/shared_category_usage_enabled.php @@ -0,0 +1,85 @@ +. + +declare(strict_types=1); + +namespace core_customfield\event; + +use context; +use core_customfield\shared; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Custom field shared category usage enabled event class. + * + * @package core_customfield + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class shared_category_usage_enabled extends \core\event\base { + + /** + * Initialise the event data. + */ + protected function init(): void { + $this->data['objecttable'] = 'customfield_shared'; + $this->data['crud'] = 'c'; + $this->data['edulevel'] = self::LEVEL_OTHER; + } + + /** + * Creates an instance from a shared object + * + * @param shared $shared + * @param context $context + * @return category_updated + */ + public static function create_from_object(shared $shared, context $context): shared_category_usage_enabled { + $eventparams = [ + 'objectid' => $shared->get('id'), + 'context' => $context, + 'other' => [ + 'categoryid' => $shared->get('categoryid'), + 'component' => $shared->get('component'), + 'area' => $shared->get('area'), + 'itemid' => $shared->get('itemid'), + ], + ]; + $event = self::create($eventparams); + $event->add_record_snapshot($event->objecttable, $shared->to_record()); + return $event; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name(): string { + return get_string('eventsharedcategoryusageenabled', 'core_customfield'); + } + + /** + * Returns non-localised description of what happened. + * + * @return string + */ + public function get_description(): string { + return "The user with ID '$this->userid' enabled usage of shared category with ID '{$this->other['categoryid']}'" . + " in '{$this->other['component']}/{$this->other['area']}'"; + } +} diff --git a/public/customfield/classes/external/toggle_shared_category.php b/public/customfield/classes/external/toggle_shared_category.php new file mode 100755 index 00000000000..66fe0b01498 --- /dev/null +++ b/public/customfield/classes/external/toggle_shared_category.php @@ -0,0 +1,116 @@ +. + +declare(strict_types=1); + +namespace core_customfield\external; + +use core_customfield\event\shared_category_usage_disabled; +use core_customfield\event\shared_category_usage_enabled; +use core_customfield\handler; +use core_customfield\shared; +use core_external\external_api; +use core_external\external_value; +use core_external\external_function_parameters; + +/** + * External method for toggling shared categories + * + * @package core_customfield + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class toggle_shared_category extends external_api { + /** + * External method parameters + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'categoryid' => new external_value(PARAM_INT, 'Category ID'), + 'component' => new external_value(PARAM_COMPONENT, 'Component'), + 'area' => new external_value(PARAM_AREA, 'Area'), + 'itemid' => new external_value(PARAM_INT, 'Item ID'), + 'state' => new external_value(PARAM_BOOL, 'New state'), + ]); + } + + /** + * External method execution + * + * @param int $categoryid + * @param string $component + * @param string $area + * @param int $itemid + * @param bool $state + * @return bool + */ + public static function execute(int $categoryid, string $component, string $area, int $itemid, bool $state): bool { + [ + 'categoryid' => $categoryid, + 'component' => $component, + 'area' => $area, + 'itemid' => $itemid, + 'state' => $state, + ] = self::validate_parameters(self::execute_parameters(), [ + 'categoryid' => $categoryid, + 'component' => $component, + 'area' => $area, + 'itemid' => $itemid, + 'state' => $state, + ]); + + // Validate context. + $context = \core\context\system::instance(); + self::validate_context($context); + + $handler = handler::get_handler($component, $area, $itemid); + if (!$handler->can_configure()) { + throw new \moodle_exception('nopermissions', 'error', '', get_string('customfield:configureshared', 'core_role')); + } + + global $DB; + + $params = [ + 'categoryid' => $categoryid, + 'component' => $component, + 'area' => $area, + 'itemid' => $itemid, + ]; + + if ($state) { + $record = new shared(0, (object) $params); + $record->create(); + shared_category_usage_enabled::create_from_object($record, $handler->get_configuration_context())->trigger(); + } else { + $record = shared::get_record($params); + $DB->delete_records('customfield_shared', $params); + shared_category_usage_disabled::create_from_object($record, $handler->get_configuration_context())->trigger(); + } + + return true; + } + + /** + * External method return value + * + * @return external_value + */ + public static function execute_returns(): external_value { + return new external_value(PARAM_BOOL); + } +} diff --git a/public/customfield/classes/handler.php b/public/customfield/classes/handler.php index 9076a69fe9e..f3dc95f6b05 100644 --- a/public/customfield/classes/handler.php +++ b/public/customfield/classes/handler.php @@ -227,10 +227,41 @@ abstract class handler { */ protected function validate_category(category_controller $category): category_controller { $categories = $this->get_categories_with_fields(); - if (!array_key_exists($category->get('id'), $categories)) { + $category = $this->get_category_from_array( + $categories, + $category->get('id'), + $this->get_component(), + $this->get_area(), + $this->get_itemid() + ); + if ($category === null) { throw new \moodle_exception('categorynotfound', 'core_customfield'); } - return $categories[$category->get('id')]; + return $category; + } + + /** + * Retrieves a category_controller from an array of categories matching the given identifiers. + * + * @param array $categories Array of category_controller objects. + * @param int $categoryid The ID of the category to find. + * @param string $component The component name. + * @param string $area The area name. + * @param int $itemid The item ID. + * @return category_controller|null + */ + public function get_category_from_array( + array $categories, + int $categoryid, + string $component, + string $area, + int $itemid + ): ?category_controller { + $category = array_filter($categories, fn($category) => $category->get('id') === $categoryid && + $category->get_original_component() === $component && + $category->get_original_area() === $area && + $category->get_original_itemid() === $itemid); + return $category ? reset($category) : null; } /** @@ -241,11 +272,16 @@ abstract class handler { * @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())) { + $categories = $this->get_categories_with_fields(); + $category = $this->get_category_from_array( + $categories, + $field->get('categoryid'), + $this->get_component(), + $this->get_area(), + $this->get_itemid() + ); + + if (!$category || !array_key_exists($field->get('id'), $category->get_fields())) { throw new \moodle_exception('fieldnotfound', 'core_customfield'); } return $category->get_fields()[$field->get('id')]; @@ -375,7 +411,26 @@ abstract class handler { */ 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); + return $this->get_instance_fields_data($fields, $instanceid); + } + + /** + * For the given instance and list of fields fields retrieves data associated with them using the current entity context + * + * @param field_controller[] $fields Array of field_controller objects. + * @param int $instanceid The instance ID. + * @param bool $adddefaults Whether to add default values for fields without data. + * @return data_controller[] Array of data_controller objects indexed by fieldid. + */ + public function get_instance_fields_data(array $fields, int $instanceid, bool $adddefaults = true): array { + return api::get_instance_fields_data( + $fields, + $instanceid, + $adddefaults, + $this->get_component(), + $this->get_area(), + $this->get_itemid() + ); } /** @@ -464,15 +519,43 @@ abstract class handler { /** * Returns array of categories, each of them contains a list of fields definitions. * + * @param bool $ismanagementpage Whether we are on the management page to show all shared categories or not. * @return category_controller[] */ - public function get_categories_with_fields(): array { + public function get_categories_with_fields(bool $ismanagementpage = false): array { if ($this->categories === null) { + $sharedcategories = []; $this->categories = api::get_categories_with_fields($this->get_component(), $this->get_area(), $this->get_itemid()); + // Avoid duplication when we are in the shared custom fields page. + if ($this->get_component() !== 'core_customfield' && $this->get_area() !== 'shared') { + $sharedcategories = api::get_categories_with_fields('core_customfield', 'shared', 0); + // Filter only by enabled shared categories. + if (!$ismanagementpage) { + $sharedcategories = array_filter($sharedcategories, function (category_controller $cc) { + return api::is_shared_category_enabled( + $cc->get('id'), + $this->get_component(), + $this->get_area(), + $this->get_itemid() + ); + }); + } + } + $this->categories = array_merge($this->categories, $sharedcategories); } $handler = $this; - array_walk($this->categories, function(category_controller $c) use ($handler) { - $c->set_handler($handler); + array_walk($this->categories, function (category_controller $cc) use ($handler) { + if ($cc->get('area') === 'shared') { + $sharedhandler = \core_customfield\customfield\shared_handler::create(); + $cc->set_handler($sharedhandler); + } else { + // Set the handler for the category. + $cc->set_handler($handler); + } + + $cc->set_original_component($handler->get_component()); + $cc->set_original_area($handler->get_area()); + $cc->set_original_itemid($handler->get_itemid()); }); return $this->categories; } @@ -566,7 +649,7 @@ abstract class handler { */ 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); + $fields = $this->get_instance_fields_data($editablefields, $instanceid); foreach ($fields as $formfield) { $formfield->instance_form_definition_after_data($mform); @@ -587,7 +670,7 @@ abstract class handler { */ 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); + $fields = $this->get_instance_fields_data($this->get_editable_fields($instanceid), $instanceid); foreach ($fields as $formfield) { $formfield->instance_form_before_set_data($instance); @@ -616,7 +699,7 @@ abstract class handler { return; } $editablefields = $this->get_editable_fields($isnewinstance ? 0 : $instance->id); - $fields = api::get_instance_fields_data($editablefields, $instance->id); + $fields = $this->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); @@ -643,7 +726,7 @@ abstract class handler { 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); + $fields = $this->get_instance_fields_data($editablefields, $instanceid); $errors = []; foreach ($fields as $formfield) { $errors += $formfield->instance_form_validation($data, $files); @@ -670,7 +753,7 @@ abstract class handler { ?string $headerlangidentifier = null, ?string $headerlangcomponent = null) { $editablefields = $this->get_editable_fields($instanceid); - $fieldswithdata = api::get_instance_fields_data($editablefields, $instanceid); + $fieldswithdata = $this->get_instance_fields_data($editablefields, $instanceid); $lastcategoryid = null; foreach ($fieldswithdata as $data) { $categoryid = $data->get_field()->get_category()->get('id'); @@ -819,7 +902,7 @@ abstract class handler { * @param int $instanceid */ public function delete_instance(int $instanceid) { - $fielddata = api::get_instance_fields_data($this->get_fields(), $instanceid, false); + $fielddata = $this->get_instance_fields_data($this->get_fields(), $instanceid, false); foreach ($fielddata as $data) { $data->delete(); } diff --git a/public/customfield/classes/output/management.php b/public/customfield/classes/output/management.php index 2fe4cb330f7..1ff22a3b696 100644 --- a/public/customfield/classes/output/management.php +++ b/public/customfield/classes/output/management.php @@ -26,6 +26,7 @@ namespace core_customfield\output; use core_customfield\api; use core_customfield\handler; +use core_customfield\shared; use renderable; use templatable; @@ -65,6 +66,7 @@ class management implements renderable, templatable { * @return array|object|\stdClass */ public function export_for_template(\renderer_base $output) { + global $DB; $data = new \stdClass(); $fieldtypes = $this->handler->get_available_field_types(); @@ -73,17 +75,48 @@ class management implements renderable, templatable { $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(); + $categories = $this->handler->get_categories_with_fields(true); + + // Get all enabled shared categories at once. + $sharedcategoriesenabled = shared::get_records([ + 'component' => $data->component, + 'area' => $data->area, + 'itemid' => $data->itemid, + ]); $categoriesarray = array(); foreach ($categories as $category) { + $canedit = $data->component === $category->get('component') && $data->area === $category->get('area'); + $categoryarray = array(); $categoryarray['id'] = $category->get('id'); - $categoryarray['nameeditable'] = $output->render(api::get_category_inplace_editable($category, true)); + $categoryarray['nameeditable'] = $canedit ? $output->render(api::get_category_inplace_editable($category, true)) : + $category->get_formatted_name(); $categoryarray['movetitle'] = get_string('movecategory', 'core_customfield', $category->get_formatted_name()); + $categoryarray['canedit'] = $canedit; + + $toggleenabled = (bool) array_filter( + $sharedcategoriesenabled, + fn($record) => $record->get('categoryid') === $category->get('id') + ); + $attributes = [ + ['name' => 'data-id', 'value' => $category->get('id')], + ['name' => 'data-action', 'value' => 'shared-toggle'], + ['name' => 'data-state', 'value' => $toggleenabled], + ['name' => 'data-component', 'value' => $data->component], + ['name' => 'data-area', 'value' => $data->area], + ['name' => 'data-itemid', 'value' => $data->itemid], + ]; + $categoryarray['toggle'] = $output->render_from_template('core/toggle', [ + 'id' => 'shared-toggle-' . $category->get('id'), + 'checked' => $toggleenabled, + 'extraattributes' => $attributes, + 'label' => get_string('enableplugin', 'core_admin', $category->get_formatted_name()), + 'labelclasses' => 'visually-hidden', + ]); $categoryarray['fields'] = array(); @@ -95,22 +128,31 @@ class management implements renderable, templatable { $fieldarray['name'] = $fieldname; $fieldarray['shortname'] = $field->get('shortname'); $fieldarray['movetitle'] = get_string('movefield', 'core_customfield', $fieldname); + $categoryarray['canedit'] = $canedit; $categoryarray['fields'][] = $fieldarray; } - $menu = new \action_menu(); - $menu->set_menu_trigger(get_string('createnewcustomfield', 'core_customfield')); + if ($canedit) { + $menu = new \action_menu(); + $menu->set_menu_trigger(get_string('createnewcustomfield', 'core_customfield')); - foreach ($fieldtypes as $type => $fieldname) { - $action = new \action_menu_link_secondary(new \moodle_url('#'), null, $fieldname, - ['data-role' => 'addfield', 'data-categoryid' => $category->get('id'), 'data-type' => $type, - 'data-typename' => $fieldname]); - $menu->add($action); + foreach ($fieldtypes as $type => $fieldname) { + $params = [ + 'data-role' => 'addfield', + 'data-categoryid' => $category->get('id'), + 'data-type' => $type, + 'data-typename' => $fieldname, + ]; + $action = new \action_menu_link_secondary(new \core\url('#'), null, $fieldname, $params); + $menu->add($action); + } + $menu->attributes['class'] .= ' float-start me-1'; + + $categoryarray['addfieldmenu'] = $output->render($menu); + } else { + $categoryarray['addfieldmenu'] = ''; } - $menu->attributes['class'] .= ' float-start me-1'; - - $categoryarray['addfieldmenu'] = $output->render($menu); $categoriesarray[] = $categoryarray; } diff --git a/public/customfield/classes/shared.php b/public/customfield/classes/shared.php new file mode 100644 index 00000000000..86d1825e432 --- /dev/null +++ b/public/customfield/classes/shared.php @@ -0,0 +1,57 @@ +. + +namespace core_customfield; + +use core\persistent; + +/** + * Customfield shared persistent class + * + * @package core_customfield + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class shared extends persistent { + /** + * Database table. + */ + const TABLE = 'customfield_shared'; + + /** + * Return the definition of the properties of this model. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'categoryid' => [ + 'type' => PARAM_INT, + ], + 'component' => [ + 'type' => PARAM_COMPONENT, + ], + 'area' => [ + 'type' => PARAM_COMPONENT, + ], + 'itemid' => [ + 'type' => PARAM_INT, + 'optional' => true, + 'default' => 0, + ], + ]; + } +} diff --git a/public/customfield/customfield.php b/public/customfield/customfield.php new file mode 100644 index 00000000000..f884228c8ad --- /dev/null +++ b/public/customfield/customfield.php @@ -0,0 +1,38 @@ +. + +/** + * Manage shared custom fields + * + * @package core_customfield + * @copyright 2025 David Carrillo + * @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('sharedcustomfields'); + +/** @var \core_customfield\output\renderer $output */ +$output = $PAGE->get_renderer('core_customfield'); +$handler = \core_customfield\customfield\shared_handler::create(); +$outputpage = new \core_customfield\output\management($handler); + +echo $output->header(), + $output->heading(new lang_string('sharedcustomfields', 'core_customfield')), + $output->render($outputpage), + $output->footer(); diff --git a/public/customfield/externallib.php b/public/customfield/externallib.php index 27a2860331b..e18e2dcd7aa 100644 --- a/public/customfield/externallib.php +++ b/public/customfield/externallib.php @@ -120,6 +120,7 @@ class core_customfield_external extends external_api { 'id' => new external_value(PARAM_INT, 'id'), 'nameeditable' => new external_value(PARAM_RAW, 'inplace editable name'), 'addfieldmenu' => new external_value(PARAM_RAW, 'addfieldmenu'), + 'canedit' => new external_value(PARAM_BOOL, 'can edit'), 'fields' => new external_multiple_structure( new external_single_structure( array( @@ -130,6 +131,7 @@ class core_customfield_external extends external_api { ) ) , '', VALUE_OPTIONAL), + 'toggle' => new external_value(PARAM_RAW, 'toggle'), ) ) ), diff --git a/public/customfield/field/number/classes/external/recalculate.php b/public/customfield/field/number/classes/external/recalculate.php index b2f0aac28f3..5411c5c944f 100644 --- a/public/customfield/field/number/classes/external/recalculate.php +++ b/public/customfield/field/number/classes/external/recalculate.php @@ -81,7 +81,7 @@ class recalculate extends external_api { $provider->recalculate($instanceid); - $data = \core_customfield\api::get_instance_fields_data( + $data = $handler->get_instance_fields_data( [$fieldid => $field], $instanceid)[$fieldid]; return ['value' => $data->export_value()]; diff --git a/public/customfield/field/number/classes/local/numberproviders/nofactivities.php b/public/customfield/field/number/classes/local/numberproviders/nofactivities.php index 5f844f9362f..22f3bad784d 100644 --- a/public/customfield/field/number/classes/local/numberproviders/nofactivities.php +++ b/public/customfield/field/number/classes/local/numberproviders/nofactivities.php @@ -129,7 +129,7 @@ class nofactivities extends provider_base { } } else if (empty($record->dataid) || (int)$record->decvalue != $value) { // Stored value is out of date. - $data = \core_customfield\api::get_instance_fields_data( + $data = $this->field->get_handler()->get_instance_fields_data( [$fieldid => $this->field], (int)$record->id)[$fieldid]; $data->set('contextid', context_course::instance($record->id)->id); $data->set('decvalue', $value); diff --git a/public/customfield/field/number/tests/local/numberproviders/nofactivities/nofactivities_test.php b/public/customfield/field/number/tests/local/numberproviders/nofactivities/nofactivities_test.php index 32b1880273c..47a862a8d95 100644 --- a/public/customfield/field/number/tests/local/numberproviders/nofactivities/nofactivities_test.php +++ b/public/customfield/field/number/tests/local/numberproviders/nofactivities/nofactivities_test.php @@ -156,7 +156,13 @@ final class nofactivities_test extends advanced_testcase { ], ]); $getdata = fn(\customfield_number\field_controller $field): \customfield_number\data_controller => - \core_customfield\api::get_instance_fields_data([$field->get('id') => $field], (int)$course1->id)[$field->get('id')]; + \core_customfield\api::get_instance_fields_data( + [$field->get('id') => $field], + (int)$course1->id, + true, + 'core_course', + 'course' + )[$field->get('id')]; // Recalculate the value of the field and assert it is set to 1 (one activity in the course). (new \customfield_number\task\cron())->execute(); diff --git a/public/customfield/templates/list.mustache b/public/customfield/templates/list.mustache index 2bfd55e85d9..5ca89ab2cc4 100644 --- a/public/customfield/templates/list.mustache +++ b/public/customfield/templates/list.mustache @@ -42,6 +42,7 @@ { "id": "0", "nameeditable": "Other fields", "addfieldmenu": "Add field", + "canedit": true, "fields": [ { "id": 0, "name": "Field name", "shortname": "shortname", "type": "Text" }, { "id": 0, "name": "Another field", "shortname": "checkme", "type": "Checkbox" } @@ -50,6 +51,7 @@ { "id": "00", "nameeditable": "Empty category", "addfieldmenu": "Add field", + "canedit": true, "fields": [] } ], "singleselect" : "select" @@ -71,19 +73,26 @@ {{{nocategories}}} {{/categories}} -
{{#categories}} + {{#canedit}}
{{/canedit}}
-
+
{{#usescategories}}

- {{> core/drag_handle}} + {{#canedit}}{{> core/drag_handle}}{{/canedit}} {{{nameeditable}}} + {{#canedit}} {{#pix}} t/delete, core, {{#str}} delete, moodle {{/str}} {{/pix}} + {{/canedit}}

+ {{^canedit}} +
+ {{{toggle}}} +
+ {{/canedit}} {{/usescategories}}
@@ -91,7 +100,7 @@
- +
@@ -103,14 +112,16 @@ {{#fields}} - + {{/fields}} @@ -121,12 +132,13 @@
{{#str}} customfield, core_customfield {{/str}}
{{> core/drag_handle}}{{{name}}}{{#canedit}}{{> core/drag_handle}}{{/canedit}}{{{name}}} {{{shortname}}} {{{type}}} + {{#canedit}} {{#pix}} t/edit, core, {{#str}} edit, moodle {{/str}} {{/pix}} {{#pix}} t/delete, core, {{#str}} delete, moodle {{/str}} {{/pix}} + {{/canedit}}
+ {{#canedit}}
{{/canedit}} {{/categories}} -
{{#js}} - require(['core_customfield/form'], function(s) { + require(['core_customfield/form', 'core_customfield/toggle_shared'], function(s, toggle) { s.init(); + toggle.init(); }); {{/js}} diff --git a/public/customfield/tests/api_test.php b/public/customfield/tests/api_test.php index a057b9859e0..96d4ee6fa5a 100644 --- a/public/customfield/tests/api_test.php +++ b/public/customfield/tests/api_test.php @@ -181,7 +181,14 @@ final class api_test extends \advanced_testcase { $handler = \core_course\customfield\course_handler::create(); $c1id = $handler->create_category(); - $c1 = $handler->get_categories_with_fields()[$c1id]; + $categories = $handler->get_categories_with_fields(); + $c1 = $handler->get_category_from_array( + $categories, + $c1id, + $handler->get_component(), + $handler->get_area(), + $handler->get_itemid() + ); $this->assertEquals('Other fields', $c1->get('name')); $this->assertEquals($handler->get_component(), $c1->get('component')); $this->assertEquals($handler->get_area(), $c1->get('area')); @@ -191,8 +198,21 @@ final class api_test extends \advanced_testcase { // Generate more categories and make sure they have different names. $c2id = $handler->create_category(); $c3id = $handler->create_category(); - $c2 = $handler->get_categories_with_fields()[$c2id]; - $c3 = $handler->get_categories_with_fields()[$c3id]; + $categories = $handler->get_categories_with_fields(); + $c2 = $handler->get_category_from_array( + $categories, + $c2id, + $handler->get_component(), + $handler->get_area(), + $handler->get_itemid() + ); + $c3 = $handler->get_category_from_array( + $categories, + $c3id, + $handler->get_component(), + $handler->get_area(), + $handler->get_itemid() + ); $this->assertEquals('Other fields 1', $c2->get('name')); $this->assertEquals('Other fields 2', $c3->get('name')); } diff --git a/public/customfield/tests/behat/shared_custom_fields.feature b/public/customfield/tests/behat/shared_custom_fields.feature new file mode 100644 index 00000000000..e503b885fa6 --- /dev/null +++ b/public/customfield/tests/behat/shared_custom_fields.feature @@ -0,0 +1,83 @@ +@core @core_course @core_customfield @javascript +Feature: Create shared categories and fields + In order to have shared custom fields + As a manager + I need to create, edit, remove and sort shared custom field's categories + + Scenario: Create and delete a category for shared custom fields + Given I log in as "admin" + When I navigate to "Custom fields > Shared custom fields" in site administration + And I press "Add a new category" + And I wait until the page is ready + Then I should see "Other fields" in the "#customfield_catlist" "css_element" + And I click on "[data-role='deletecategory']" "css_element" + And I click on "Yes" "button" in the "Confirm" "dialogue" + And I wait until the page is ready + And I wait until "Other fields" "text" does not exist + + Scenario: Shared customfields are displayed in other entities + Given the following "custom field categories" exist: + | name | component | area | itemid | + | My shared category | core_customfield | shared | 0 | + And the following "custom fields" exist: + | name | category | type | shortname | + | Shared field 1 | My shared category | text | f1 | + And I log in as "admin" + And I navigate to "Courses > Default settings > Course custom fields" in site administration + Then I should see "My shared category" in the "#customfield_catlist" "css_element" + And I should see "Shared field 1" in the "#customfield_catlist" "css_element" + And I navigate to "Users > Accounts > Cohort custom fields" in site administration + And I should see "My shared category" in the "#customfield_catlist" "css_element" + And I should see "Shared field 1" in the "#customfield_catlist" "css_element" + + Scenario: Shared custom fields cannot be reordered, edited or deleted from other entities + Given the following "custom field categories" exist: + | name | component | area | itemid | + | My shared category | core_customfield | shared | 0 | + | My course category | core_course | course | 0 | + And the following "custom fields" exist: + | name | category | type | shortname | + | Shared field 1 | My shared category | text | f1 | + | Course field 1 | My course category | text | f2 | + And I log in as "admin" + And I navigate to "Courses > Default settings > Course custom fields" in site administration + # Check that the delete category link exists for course categories but not for shared categories. + Then "Delete" "link" should exist in the ".//div[contains(@class, 'categoryinstance')][.//span[@data-value='My course category']]" "xpath_element" + And "Delete" "link" should not exist in the ".//div[contains(@class,'categoryinstance')]/h3[normalize-space(text())='My shared category']" "xpath_element" + # Check that the inplaceeditable exists for course categories but not for shared categories. + And "//div[contains(@class,'categoryinstance') and contains(.,'My course category') and .//span[contains(@class,'inplaceeditable')]]" "xpath_element" should exist + And "//div[contains(@class,'categoryinstance') and contains(.,'My shared category') and .//span[contains(@class,'inplaceeditable')]]" "xpath_element" should not exist + # Check that the move category option exists for course categories but not for shared categories. + And "//span[contains(@class,'movecategory')][.//span[@title='Move \"My course category\"']]" "xpath_element" should exist + And "//span[contains(@class,'movecategory')][.//span[@title='Move \"My shared category\"']]" "xpath_element" should not exist + # Check that the move field option exists for course fields but not for shared fields. + And "//tr[@data-field-name='Course field 1']//span[@title='Move \"Course field 1\"']" "xpath_element" should exist + And "//tr[@data-field-name='Shared field 1']//span[@title='Move \"Shared field 1\"']" "xpath_element" should not exist + + Scenario: Select which shared custom fields categories are used in the course entity + Given the following "custom field categories" exist: + | name | component | area | itemid | + | My shared category 1 | core_customfield | shared | 0 | + | My shared category 2 | core_customfield | shared | 0 | + | My course category | core_course | course | 0 | + And the following "custom fields" exist: + | name | category | type | shortname | + | Shared field 1 | My shared category 1 | text | f1 | + | Shared field 2 | My shared category 2 | text | f2 | + | Course field 1 | My course category | text | f3 | + And the following "courses" exist: + | shortname | fullname | + | C1 | Course 1 | + And I log in as "admin" + When I am on the "C1" "Course" page + And I navigate to "Settings" in current page administration + Then I should see "My course category" + And I should not see "My shared category 1" + And I should not see "My shared category 2" + And I navigate to "Courses > Default settings > Course custom fields" in site administration + And I toggle the "Enable My shared category 1" admin switch "on" + And I am on the "C1" "Course" page + And I navigate to "Settings" in current page administration + And I should see "My course category" + And I should see "My shared category 1" + And I should not see "My shared category 2" diff --git a/public/customfield/tests/external/toggle_shared_category_test.php b/public/customfield/tests/external/toggle_shared_category_test.php new file mode 100644 index 00000000000..4199b23b75c --- /dev/null +++ b/public/customfield/tests/external/toggle_shared_category_test.php @@ -0,0 +1,112 @@ +. + +declare(strict_types=1); + +namespace core_customfield\external; + +use core_external\external_api; +use core_customfield\shared; +use core_customfield_generator; + +/** + * Unit tests for custom field toggle_shared_category external method + * + * @package core_customfield + * @covers \core_customfield\external\toggle_shared_category + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class toggle_shared_category_test extends \core_external\tests\externallib_testcase { + /** + * Test execute + */ + public function test_execute(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + /** @var core_customfield_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); + $sharedcategory = $generator->create_category(['component' => 'core_customfield', 'area' => 'shared']); + $generator->create_field([ + 'categoryid' => $sharedcategory->get('id'), + 'name' => 'My shared field', + 'shortname' => 'mysharedfield', + 'type' => 'text', + ]); + + $this->assertEmpty(shared::get_records()); + + $result = toggle_shared_category::execute( + $sharedcategory->get('id'), + 'core_course', + 'course', + 0, + true + ); + $result = external_api::clean_returnvalue(toggle_shared_category::execute_returns(), $result); + $this->assertTrue($result); + $records = shared::get_records(); + $record = reset($records); + $this->assertEquals($sharedcategory->get('id'), $record->get('categoryid')); + $this->assertEquals('core_course', $record->get('component')); + $this->assertEquals('course', $record->get('area')); + $this->assertEquals(0, $record->get('itemid')); + + $result = toggle_shared_category::execute( + $sharedcategory->get('id'), + 'core_course', + 'course', + 0, + false + ); + $result = external_api::clean_returnvalue(toggle_shared_category::execute_returns(), $result); + $this->assertTrue($result); + $this->assertEmpty(shared::get_records()); + } + + /** + * Test execute with no permission + */ + public function test_execute_no_permission(): void { + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + + /** @var core_customfield_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); + $sharedcategory = $generator->create_category(['component' => 'core_customfield', 'area' => 'shared']); + $generator->create_field([ + 'categoryid' => $sharedcategory->get('id'), + 'name' => 'My shared field', + 'shortname' => 'mysharedfield', + 'type' => 'text', + ]); + + $this->setUser($user); + + $this->expectException(\moodle_exception::class); + $str = "Sorry, but you do not currently have permissions to do that (Configure shared custom fields)."; + $this->expectExceptionMessage($str); + toggle_shared_category::execute( + $sharedcategory->get('id'), + 'core_course', + 'course', + 0, + true + ); + } +} diff --git a/public/customfield/tests/generator/lib.php b/public/customfield/tests/generator/lib.php index 6da6bf30f82..427fdf73c1f 100644 --- a/public/customfield/tests/generator/lib.php +++ b/public/customfield/tests/generator/lib.php @@ -71,7 +71,14 @@ class core_customfield_generator extends component_generator_base { $handler = \core_customfield\handler::get_handler($record->component, $record->area, $record->itemid); $categoryid = $handler->create_category($record->name); - return $handler->get_categories_with_fields()[$categoryid]; + $categories = $handler->get_categories_with_fields(); + return $handler->get_category_from_array( + $categories, + $categoryid, + $handler->get_component(), + $handler->get_area(), + $handler->get_itemid() + ); } /** @@ -139,7 +146,15 @@ class core_customfield_generator extends component_generator_base { $field = field_controller::create(0, (object)['type' => $record->type], $category); $handler->save_field_configuration($field, $record); - return $handler->get_categories_with_fields()[$field->get('categoryid')]->get_fields()[$field->get('id')]; + $categories = $handler->get_categories_with_fields(); + $category = $handler->get_category_from_array( + $categories, + $field->get('categoryid'), + $handler->get_component(), + $handler->get_area(), + $handler->get_itemid() + ); + return $category->get_fields()[$field->get('id')]; } /** diff --git a/public/group/classes/customfield/group_handler.php b/public/group/classes/customfield/group_handler.php index df3a194a648..ffa47249cd9 100644 --- a/public/group/classes/customfield/group_handler.php +++ b/public/group/classes/customfield/group_handler.php @@ -169,7 +169,7 @@ class group_handler extends handler { $instanceid = $data['groupid']; $context = $this->get_instance_context($instanceid); $editablefields = $this->get_editable_fields($instanceid); - $records = api::get_instance_fields_data($editablefields, $instanceid); + $records = $this->get_instance_fields_data($editablefields, $instanceid); foreach ($records as $d) { $field = $d->get_field(); diff --git a/public/group/classes/customfield/grouping_handler.php b/public/group/classes/customfield/grouping_handler.php index 3fdac037871..1bd951bb3f0 100644 --- a/public/group/classes/customfield/grouping_handler.php +++ b/public/group/classes/customfield/grouping_handler.php @@ -170,7 +170,7 @@ class grouping_handler extends handler { $instanceid = $data['groupingid']; $context = $this->get_instance_context($instanceid); $editablefields = $this->get_editable_fields($instanceid); - $records = api::get_instance_fields_data($editablefields, $instanceid); + $records = $this->get_instance_fields_data($editablefields, $instanceid); foreach ($records as $d) { $field = $d->get_field(); diff --git a/public/lang/en/customfield.php b/public/lang/en/customfield.php index 16e6ae99d8f..19ed96fbbd3 100644 --- a/public/lang/en/customfield.php +++ b/public/lang/en/customfield.php @@ -50,6 +50,8 @@ $string['eventcategoryupdated'] = 'Custom field category updated'; $string['eventfieldcreated'] = 'Custom field created'; $string['eventfielddeleted'] = 'Custom field deleted'; $string['eventfieldupdated'] = 'Custom field updated'; +$string['eventsharedcategoryusagedisabled'] = 'Shared category usage disabled'; +$string['eventsharedcategoryusageenabled'] = 'Shared category usage enabled'; $string['fieldname'] = 'Name'; $string['fieldnotfound'] = 'Field not found'; $string['fieldshortname'] = 'Short name'; @@ -85,6 +87,8 @@ $string['privacy:metadata:customfield_data:valueformat'] = 'The format of the va $string['privacy:metadata:customfield_data:valuetrust'] = 'The trust flag of the value, when it is text'; $string['privacy:metadata:customfieldpluginsummary'] = 'Fields for various components'; $string['privacy:metadata:filepurpose'] = 'File attached to the custom field data'; +$string['sharedcategoryhelptext'] = 'When enabled, the custom fields in this category will be used'; +$string['sharedcustomfields'] = 'Shared custom fields'; $string['shortname'] = 'Short name'; $string['shortname_help'] = 'The short name must be unique and can only contain alphanumeric lowercase characters and underscores (_). It is not displayed anywhere on the site, but may be used for synchronisation with external systems or in web services.'; $string['showdate'] = 'Show date'; diff --git a/public/lang/en/role.php b/public/lang/en/role.php index 0711785369f..4efe575eaed 100644 --- a/public/lang/en/role.php +++ b/public/lang/en/role.php @@ -302,6 +302,7 @@ $string['checkpermissionsin'] = 'Check permissions in {$a}'; $string['checksystempermissionsfor'] = 'Check system permissions for {$a->fullname}'; $string['checkuserspermissionshere'] = 'Check permissions for {$a->fullname} has in this {$a->contextlevel}'; $string['chooseroletoassign'] = 'Please choose a role to assign'; +$string['customfield:configureshared'] = 'Configure shared custom fields'; $string['inactiveformorethan'] = 'inactive for more than {$a->timeperiod}'; $string['ingroup'] = 'in the group "{$a->group}"'; $string['inherit'] = 'Inherit'; diff --git a/public/lib/db/access.php b/public/lib/db/access.php index 75ed0984ded..9c0c402ac2c 100644 --- a/public/lib/db/access.php +++ b/public/lib/db/access.php @@ -2849,4 +2849,12 @@ $capabilities = array( 'manager' => CAP_ALLOW, ], ], + + // Allow users to configure shared custom fields. + 'moodle/customfield:configureshared' => [ + 'captype' => 'write', + 'riskbitmap' => RISK_PERSONAL, + 'contextlevel' => CONTEXT_SYSTEM, + 'archetypes' => [], + ], ); diff --git a/public/lib/db/install.xml b/public/lib/db/install.xml index b4d070ad7e7..95eed21f29a 100644 --- a/public/lib/db/install.xml +++ b/public/lib/db/install.xml @@ -4292,6 +4292,7 @@ + @@ -4338,6 +4339,9 @@ + + + @@ -4345,12 +4349,32 @@ - + + + + + + + + + + + + + + + + + + + + +
diff --git a/public/lib/db/services.php b/public/lib/db/services.php index 851005b42ae..c400e5e1fab 100644 --- a/public/lib/db/services.php +++ b/public/lib/db/services.php @@ -2927,6 +2927,13 @@ $functions = array( 'type' => 'write', 'ajax' => true, ), + 'core_customfield_toggle_shared' => [ + 'classname' => core_customfield\external\toggle_shared_category::class, + 'methodname' => 'execute', + 'description' => 'Toggle shared category state', + 'type' => 'write', + 'ajax' => true, + ], 'core_h5p_get_trusted_h5p_file' => [ 'classname' => 'core_h5p\external', 'methodname' => 'get_trusted_h5p_file', diff --git a/public/lib/db/upgrade.php b/public/lib/db/upgrade.php index 580382d7782..f4425cfc7f8 100644 --- a/public/lib/db/upgrade.php +++ b/public/lib/db/upgrade.php @@ -2190,5 +2190,110 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2025090200.02); } + if ($oldversion < 2025091600.01) { + // Define field shared to be added to customfield_category. + $table = new xmldb_table('customfield_category'); + $field = new xmldb_field('shared', XMLDB_TYPE_INTEGER, '1', null, null, null, '0', 'contextid'); + + // Conditionally launch add field shared. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define field component to be added to customfield_data. + $table = new xmldb_table('customfield_data'); + $field = new xmldb_field('component', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null, 'contextid'); + + // Conditionally launch add field component. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define field area to be added to customfield_data. + $table = new xmldb_table('customfield_data'); + $field = new xmldb_field('area', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null, 'component'); + + // Conditionally launch add field area. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define field itemid to be added to customfield_data. + $table = new xmldb_table('customfield_data'); + $field = new xmldb_field('itemid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'area'); + + // Conditionally launch add field itemid. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define index instanceid-fieldid (unique) to be dropped form customfield_data. + $table = new xmldb_table('customfield_data'); + $index = new xmldb_index('instanceid-fieldid', XMLDB_INDEX_UNIQUE, ['instanceid', 'fieldid']); + + // Conditionally launch drop index instanceid-fieldid. + if ($dbman->index_exists($table, $index)) { + $dbman->drop_index($table, $index); + } + + // Define index instanceid-fieldid-component-area-itemid (unique) to be added to customfield_data. + $table = new xmldb_table('customfield_data'); + $index = new xmldb_index( + 'instanceid-fieldid-component-area-itemid', + XMLDB_INDEX_UNIQUE, + ['instanceid', 'fieldid', 'component', 'area', 'itemid'] + ); + + // Conditionally launch add index instanceid-fieldid-component-area-itemid. + if (!$dbman->index_exists($table, $index)) { + $dbman->add_index($table, $index); + } + + // Populate component, area and itemid for each record in customfield_data table. + $sql = "SELECT d.id, c.component, c.area, c.itemid + FROM {customfield_data} d + JOIN {customfield_field} f ON d.fieldid = f.id + JOIN {customfield_category} c ON f.categoryid = c.id"; + $records = $DB->get_records_sql($sql); + + foreach ($records as $r) { + $DB->update_record('customfield_data', (object)[ + 'id' => $r->id, + 'component' => $r->component, + 'area' => $r->area, + 'itemid' => $r->itemid, + ]); + } + + // Define table customfield_shared to be created. + $table = new xmldb_table('customfield_shared'); + + // Adding fields to table customfield_shared. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('categoryid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('component', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null); + $table->add_field('area', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null); + $table->add_field('itemid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('usermodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + + // Adding keys to table customfield_shared. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $table->add_key('categoryid', XMLDB_KEY_FOREIGN, ['categoryid'], 'customfield_category', ['id']); + $table->add_key('usermodified', XMLDB_KEY_FOREIGN, ['usermodified'], 'user', ['id']); + + // Adding indexes to table customfield_shared. + $table->add_index('categoryid-component-area-itemid', XMLDB_INDEX_UNIQUE, ['categoryid', 'component', 'area', 'itemid']); + + // Conditionally launch create table for customfield_shared. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2025091600.01); + } + return true; } diff --git a/public/lib/phpunit/classes/util.php b/public/lib/phpunit/classes/util.php index 27f712e199f..fc295db0d33 100644 --- a/public/lib/phpunit/classes/util.php +++ b/public/lib/phpunit/classes/util.php @@ -276,6 +276,9 @@ class phpunit_util extends testing_util { if (class_exists('\core_reportbuilder\customfield\report_handler')) { \core_reportbuilder\customfield\report_handler::reset_caches(); } + if (class_exists('\core_customfield\customfield\shared_handler')) { + \core_customfield\customfield\shared_handler::reset_caches(); + } // Clear static cache within restore. if (class_exists('restore_section_structure_step')) { diff --git a/public/question/bank/customfields/classes/customfield/question_handler.php b/public/question/bank/customfields/classes/customfield/question_handler.php index 772e20ce7d1..785bd790882 100644 --- a/public/question/bank/customfields/classes/customfield/question_handler.php +++ b/public/question/bank/customfields/classes/customfield/question_handler.php @@ -208,7 +208,7 @@ class question_handler extends \core_customfield\handler { */ public function get_field_data(\core_customfield\field_controller $field, int $instanceid): \core_customfield\data_controller { $fields = [$field->get('id') => $field]; - $fieldsdata = api::get_instance_fields_data($fields, $instanceid); + $fieldsdata = $this->get_instance_fields_data($fields, $instanceid); return $fieldsdata[$field->get('id')]; } @@ -309,7 +309,7 @@ class question_handler extends \core_customfield\handler { public function restore_instance_data_from_backup(\restore_task $task, array $data) { $editablefields = $this->get_editable_fields($data['newquestion']); - $records = api::get_instance_fields_data($editablefields, $data['newquestion']); + $records = $this->get_instance_fields_data($editablefields, $data['newquestion']); $target = $task->get_target(); $override = ($target != \backup::TARGET_CURRENT_ADDING && $target != \backup::TARGET_EXISTING_ADDING); diff --git a/public/version.php b/public/version.php index f508b2c780a..d74949b4526 100644 --- a/public/version.php +++ b/public/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2025091600.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2025091600.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '5.1dev+ (Build: 20250916)'; // Human-friendly version name